embed puzzle player

This commit is contained in:
2026-01-20 18:05:40 +02:00
parent 7855100fdd
commit d69fde8902
+134
View File
@@ -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 <div className="embed-loading"><div className="spinner" /></div>
if (error) return <div className="embed-error">{error}</div>
if (!puzzle) return null
const mins = Math.floor(elapsed / 60)
const secs = elapsed % 60
return (
<div className="embed-player">
{solved && <div className="embed-solved">{t('embed.solved')}</div>}
<div className="embed-header">
<span className="embed-brand">{t('embed.brand')}</span>
{showTimer && <span className="embed-timer">{mins}:{secs.toString().padStart(2, '0')}</span>}
</div>
<ActiveClue />
<div className="embed-game">
<div className="embed-grid">
<Grid />
</div>
<CluePanel />
</div>
{showHints && !solved && (
<div className="embed-hints">
<button className="btn btn-sm" onClick={() => useStore.getState().checkCell()}>{t('game.check')}</button>
<button className="btn btn-sm" onClick={() => useStore.getState().revealLetter()}>{t('game.reveal')}</button>
</div>
)}
</div>
)
}