diff --git a/frontend/src/components/EmbedPlayer.tsx b/frontend/src/components/EmbedPlayer.tsx new file mode 100644 index 0000000..8480e53 --- /dev/null +++ b/frontend/src/components/EmbedPlayer.tsx @@ -0,0 +1,134 @@ +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import Grid from './Grid' +import CluePanel from './CluePanel' +import ActiveClue from './ActiveClue' +import { useKeyboard } from '../hooks/useKeyboard' +import { useStore } from '../store' +import type { ClientPuzzle } from '../types' + +interface Props { + puzzleId: string + showTimer: boolean + showHints: boolean +} + +function buildWordSpans(puzzle: ClientPuzzle) { + const spans: { number: number; direction: 'across' | 'down'; cells: [number, number][] }[] = [] + for (const clue of puzzle.clues_across) { + const key = Object.entries(puzzle.cell_numbers).find(([, n]) => n === clue.number)?.[0] + if (!key) continue + const [r, c] = key.split(',').map(Number) + const cells: [number, number][] = [] + for (let j = c; j < puzzle.width && puzzle.cells[r][j] !== 'black'; j++) cells.push([r, j]) + spans.push({ number: clue.number, direction: 'across', cells }) + } + for (const clue of puzzle.clues_down) { + const key = Object.entries(puzzle.cell_numbers).find(([, n]) => n === clue.number)?.[0] + if (!key) continue + const [r, c] = key.split(',').map(Number) + const cells: [number, number][] = [] + for (let i = r; i < puzzle.height && puzzle.cells[i][c] !== 'black'; i++) cells.push([i, c]) + spans.push({ number: clue.number, direction: 'down', cells }) + } + return spans +} + +function makeEmptyGrid(w: number, h: number) { + return Array.from({ length: h }, () => Array(w).fill('')) +} + +function makeStatusGrid(w: number, h: number) { + return Array.from({ length: h }, () => Array(w).fill('default')) +} + +export default function EmbedPlayer({ puzzleId, showTimer, showHints }: Props) { + const { t } = useTranslation() + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const puzzle = useStore(s => s.puzzle) + const solved = useStore(s => s.solved) + const elapsed = useStore(s => s.elapsed) + const startTime = useStore(s => s.startTime) + const paused = useStore(s => s.paused) + const tick = useStore(s => s.tick) + useKeyboard() + + // timer + useEffect(() => { + if (!startTime || paused || !showTimer) return + const id = setInterval(tick, 1000) + return () => clearInterval(id) + }, [startTime, paused, tick, showTimer]) + + useEffect(() => { + if (!puzzleId) { + const t0 = setTimeout(() => { setError(t('embed.noPuzzleId')); setLoading(false) }, 0) + return () => clearTimeout(t0) + } + + fetch(`/api/puzzles/s/${puzzleId}`) + .then(r => { if (!r.ok) throw new Error(t('embed.notFound')); return r.json() }) + .then(data => { + // SAFETY: /api/puzzles/s/{id} always answers with the ClientPuzzle shape for existing ids + const p = data.puzzle as ClientPuzzle + const spans = buildWordSpans(p) + useStore.setState({ + puzzleId: data.id, + shortId: data.short_id, + puzzle: p, + entries: makeEmptyGrid(p.width, p.height), + cellStatus: makeStatusGrid(p.width, p.height), + selectedCell: null, + direction: 'across', + activeClue: null, + wordSpans: spans, + loading: false, + startTime: Date.now(), + elapsed: 0, + paused: false, + solved: false, + hintsUsed: 0, + }) + setLoading(false) + }) + .catch(e => { setError(e.message); setLoading(false) }) + }, [puzzleId]) + + // notify parent on completion + useEffect(() => { + if (solved && window.parent !== window) { + window.parent.postMessage({ type: 'cruciverb:complete', elapsed }, '*') + } + }, [solved, elapsed]) + + if (loading) return
+ if (error) return
{error}
+ if (!puzzle) return null + + const mins = Math.floor(elapsed / 60) + const secs = elapsed % 60 + + return ( +
+ {solved &&
{t('embed.solved')}
} +
+ {t('embed.brand')} + {showTimer && {mins}:{secs.toString().padStart(2, '0')}} +
+ +
+
+ +
+ +
+ {showHints && !solved && ( +
+ + +
+ )} +
+ ) +}