diff --git a/frontend/src/components/Grid.tsx b/frontend/src/components/Grid.tsx new file mode 100644 index 0000000..d224cb4 --- /dev/null +++ b/frontend/src/components/Grid.tsx @@ -0,0 +1,197 @@ +import { useRef } from 'react' +import { useTranslation } from 'react-i18next' +import { motion, AnimatePresence } from 'motion/react' +import { useStore } from '../store' +import type { CellStatus } from '../types' +import InkOverlay from './InkOverlay' +import CellFlip from './CellFlip' + +function getCellSize(gridSize: number): number { + if (gridSize <= 5) return 64 + if (gridSize <= 7) return 54 + if (gridSize <= 9) return 46 + if (gridSize <= 13) return 40 + return 36 +} + +export default function Grid() { + const { t } = useTranslation() + const svgRef = useRef(null) + const puzzle = useStore((s) => s.puzzle) + const entries = useStore((s) => s.entries) + const cellStatus = useStore((s) => s.cellStatus) + const selectedCell = useStore((s) => s.selectedCell) + const direction = useStore((s) => s.direction) + const activeClue = useStore((s) => s.activeClue) + const wordSpans = useStore((s) => s.wordSpans) + const selectCell = useStore((s) => s.selectCell) + const pencilEntries = useStore((s) => s.pencilEntries) + const mpPlayers = useStore((s) => s.mpPlayers) + const inputMode = useStore((s) => s.inputMode) + + if (!puzzle) return null + + const { width, height } = puzzle + const CELL = getCellSize(Math.max(width, height)) + const NUM_SIZE = Math.round(CELL * 0.26) + const LETTER_SIZE = Math.round(CELL * 0.52) + + const activeSpan = wordSpans.find( + (s) => s.number === activeClue && s.direction === direction, + ) + const activeCells = new Set( + activeSpan?.cells.map(([r, c]) => `${r},${c}`) ?? [], + ) + + return ( +
e.preventDefault()) : undefined} + > + + + + + {Array.from({ length: height }, (_, r) => + Array.from({ length: width }, (_, c) => { + const isBlack = puzzle.cells[r][c] === 'black' + const isSelected = selectedCell?.row === r && selectedCell?.col === c + const isActive = activeCells.has(`${r},${c}`) + const num = puzzle.cell_numbers[`${r},${c}`] + const letter = entries[r]?.[c] || '' + const pencilLetter = !letter && pencilEntries[r]?.[c] ? pencilEntries[r][c] : '' + const status: CellStatus = cellStatus[r]?.[c] || 'default' + + const x = c * CELL + const y = r * CELL + + let fill = 'var(--cell-white)' + if (isBlack) fill = 'var(--cell-black)' + else if (isSelected) fill = 'var(--cell-selected)' + else if (isActive) fill = 'var(--cell-active)' + + let textColor = 'var(--text)' + if (status === 'correct') textColor = 'var(--correct)' + if (status === 'incorrect') textColor = 'var(--incorrect)' + if (status === 'revealed') textColor = 'var(--revealed)' + + const ariaLabel = isBlack ? t('grid.blackCell') : + t('grid.cellLabel', { row: r + 1, col: c + 1 }) + (num ? t('grid.cellNumber', { num }) : '') + (letter ? t('grid.cellLetter', { letter }) : t('grid.cellEmpty')) + + return ( + !isBlack && selectCell(r, c)} + style={{ cursor: isBlack ? 'default' : 'pointer' }} + role="gridcell" + aria-label={ariaLabel} + > + + {!isBlack && num && ( + + {num} + + )} + {!isBlack && letter && ( + + {letter} + + )} + {!isBlack && pencilLetter && ( + + {pencilLetter} + + )} + {status === 'correct' && ( + + )} + {status === 'incorrect' && ( + + )} + {status === 'revealed' && ( + + )} + + ) + }), + )} + + {mpPlayers.filter(p => p.cursor).map(p => { + const [r, c] = p.cursor! + return ( + + ) + })} + + + + + +
+ ) +}