From af1faefbe070f059570f32abaf6f9a7c76b6dd8c Mon Sep 17 00:00:00 2001 From: lashman Date: Mon, 1 Jun 2026 00:39:36 +0300 Subject: [PATCH] daily preview renderer --- .../src/components/DailyPreviewRenderer.tsx | 197 ++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 frontend/src/components/DailyPreviewRenderer.tsx diff --git a/frontend/src/components/DailyPreviewRenderer.tsx b/frontend/src/components/DailyPreviewRenderer.tsx new file mode 100644 index 0000000..efbdd8b --- /dev/null +++ b/frontend/src/components/DailyPreviewRenderer.tsx @@ -0,0 +1,197 @@ +import { useEffect, useRef } from 'react' +import * as api from '../api' + +const PRESETS = [ + { id: 'glyph', name: 'Glyph', desc: '5x5', tag: 'VERY EASY' }, + { id: 'rune', name: 'Rune', desc: '5x5', tag: 'EASY' }, + { id: 'scroll', name: 'Scroll', desc: '9x9', tag: 'MEDIUM' }, + { id: 'codex', name: 'Codex', desc: '15x15', tag: 'HARD' }, + { id: 'grimoire', name: 'Grimoire', desc: '21x21', tag: 'EXPERT' }, +] + +const W = 2560 +const H = 1440 + +interface GridData { + width: number + height: number + cells: string[][] +} + +function renderGrid(ctx: CanvasRenderingContext2D, grid: GridData, x: number, y: number, maxW: number, maxH: number) { + const cellSize = Math.min(maxW / grid.width, maxH / grid.height) + const gridW = cellSize * grid.width + const gridH = cellSize * grid.height + const ox = x + (maxW - gridW) / 2 + const oy = y + (maxH - gridH) / 2 + + for (let r = 0; r < grid.height; r++) { + for (let c = 0; c < grid.width; c++) { + const cx = ox + c * cellSize + const cy = oy + r * cellSize + const isBlack = grid.cells[r][c] === 'black' + ctx.fillStyle = isBlack ? '#090909' : '#1e1e1e' + ctx.fillRect(cx, cy, cellSize, cellSize) + ctx.strokeStyle = '#555555' + ctx.lineWidth = 0.5 + ctx.strokeRect(cx, cy, cellSize, cellSize) + } + } +} + +export default function DailyPreviewRenderer() { + const canvasRef = useRef(null) + + async function generateImage() { + const canvas = canvasRef.current + if (!canvas) return + const ctx = canvas.getContext('2d')! + + const today = new Date().toISOString().split('T')[0] + const dateDisplay = new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }).toUpperCase() + + const grids: (GridData | null)[] = await Promise.all( + PRESETS.map(async (p) => { + try { + const res = await api.fetchDaily(today, p.id) + return { width: res.puzzle.width, height: res.puzzle.height, cells: res.puzzle.cells } + } catch { return null } + }) + ) + + const cardW = 400 + const cardGap = 32 + const headerH = 240 + const gridPad = 24 + const bottomPad = 24 + const gridAreaW = cardW - gridPad * 2 + const maxGridH = 600 + + let tallestCard = headerH + bottomPad + for (const grid of grids) { + if (grid) { + const cellSize = Math.min(gridAreaW / grid.width, maxGridH / grid.height) + const h = headerH + cellSize * grid.height + bottomPad + if (h > tallestCard) tallestCard = h + } + } + + const topRuleH = 3 + const nameH = 190 + const dateH = 120 + const bottomRuleH = 3 + const gapAfterRule = 80 + const todaysH = 52 + const gapBeforeCards = 40 + const accentGap = 50 + const accentH = 3 + + const totalContentH = topRuleH + nameH + dateH + bottomRuleH + gapAfterRule + todaysH + gapBeforeCards + tallestCard + accentGap + accentH + const offsetY = Math.max(0, (H - totalContentH) / 2) + + ctx.fillStyle = '#111111' + ctx.fillRect(0, 0, W, H) + + let y = offsetY + + ctx.fillStyle = '#ededed' + ctx.fillRect(140, y, W - 280, topRuleH) + y += topRuleH + + ctx.fillStyle = '#ededed' + ctx.font = '900 180px "Playfair Display", Georgia, serif' + ctx.textAlign = 'center' + ctx.letterSpacing = '-5px' + ctx.fillText('CRUCIVERB', W / 2, y + nameH) + y += nameH + + ctx.fillStyle = '#ababab' + ctx.font = '500 32px "JetBrains Mono", monospace' + ctx.letterSpacing = '8px' + ctx.fillText(dateDisplay, W / 2, y + 55) + y += dateH + + ctx.fillStyle = '#ededed' + ctx.fillRect(140, y, W - 280, bottomRuleH) + y += bottomRuleH + gapAfterRule + + ctx.fillStyle = '#b5b5b5' + ctx.font = 'italic 500 52px "Playfair Display", Georgia, serif' + ctx.letterSpacing = '0px' + ctx.fillText("Today's puzzles", W / 2, y + todaysH * 0.75) + y += todaysH + gapBeforeCards + + const cardY = y + const totalCardsW = 5 * cardW + 4 * cardGap + const startX = (W - totalCardsW) / 2 + let maxCardBottom = cardY + + for (let i = 0; i < 5; i++) { + const p = PRESETS[i] + const grid = grids[i] + const cx = startX + i * (cardW + cardGap) + + let cardH = headerH + bottomPad + let gridDrawH = 0 + if (grid) { + const cellSize = Math.min(gridAreaW / grid.width, maxGridH / grid.height) + gridDrawH = cellSize * grid.height + cardH = headerH + gridDrawH + bottomPad + } + + maxCardBottom = Math.max(maxCardBottom, cardY + cardH) + + ctx.fillStyle = '#1a1a1a' + ctx.fillRect(cx, cardY, cardW, cardH) + ctx.strokeStyle = '#2a2a2a' + ctx.lineWidth = 1 + ctx.strokeRect(cx, cardY, cardW, cardH) + + ctx.fillStyle = '#ededed' + ctx.font = '700 56px "Playfair Display", Georgia, serif' + ctx.textAlign = 'center' + ctx.letterSpacing = '-1px' + ctx.fillText(p.name, cx + cardW / 2, cardY + 150) + + ctx.fillStyle = '#ababab' + ctx.font = '500 20px "JetBrains Mono", monospace' + ctx.letterSpacing = '3px' + ctx.fillText(`${p.desc} ${p.tag}`, cx + cardW / 2, cardY + 185) + + if (grid) { + renderGrid(ctx, grid, cx + gridPad, cardY + headerH, gridAreaW, gridDrawH) + } + } + + ctx.fillStyle = '#d4432f' + ctx.fillRect(140, maxCardBottom + accentGap, W - 280, accentH) + ctx.letterSpacing = '0px' + + // convert to JPEG and upload + try { + const jpegData = canvas.toDataURL('image/jpeg', 0.8) + await fetch('/api/internal/daily-image', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ data: jpegData }), + }) + document.title = 'daily-image-done' + } catch (e) { + console.error('failed to upload daily image', e) + document.title = 'daily-image-done' + } + } + + useEffect(() => { + generateImage().catch(e => { + console.error('generateImage failed:', e) + document.title = 'daily-image-done' + }) + }, []) + + return ( +
+ +
+ ) +}