daily preview renderer

This commit is contained in:
2026-06-01 00:39:36 +03:00
parent bc9345fe1d
commit af1faefbe0
@@ -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<HTMLCanvasElement>(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 (
<div style={{ position: 'absolute', left: '-9999px', top: '-9999px' }}>
<canvas ref={canvasRef} width={W} height={H} />
</div>
)
}