clue panel

This commit is contained in:
2025-12-10 00:25:07 +02:00
parent 570acf193a
commit d77e5aefcb
+204
View File
@@ -0,0 +1,204 @@
import { useState, useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { IconArrowRight, IconArrowDown, IconMessage2Filled, IconCheck, IconX } from '@tabler/icons-react'
import { motion, AnimatePresence } from 'motion/react'
import { useStore } from '../store'
import * as api from '../api'
import Tooltip from './ui/Tooltip'
import type { Direction } from '../types'
export default function CluePanel() {
const puzzle = useStore((s) => s.puzzle)
const activeClue = useStore((s) => s.activeClue)
const direction = useStore((s) => s.direction)
const wordSpans = useStore((s) => s.wordSpans)
const selectCell = useStore((s) => s.selectCell)
const solved = useStore((s) => s.solved)
const entries = useStore((s) => s.entries)
const gameMode = useStore((s) => s.gameMode)
const listRef = useRef<HTMLDivElement>(null)
const [viewTab, setViewTab] = useState<Direction>(direction)
const [editingClue, setEditingClue] = useState<number | null>(null)
const [clueText, setClueText] = useState('')
const [submitting, setSubmitting] = useState(false)
const { t } = useTranslation()
const [submitted, setSubmitted] = useState<Set<number>>(new Set())
useEffect(() => {
setViewTab(direction)
}, [direction])
useEffect(() => {
if (!activeClue || !listRef.current) return
const el = listRef.current.querySelector('.clue-item.active')
if (el) {
el.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
}
}, [activeClue, direction])
if (!puzzle) return null
const clues = viewTab === 'across' ? puzzle.clues_across : puzzle.clues_down
const isBlind = gameMode === 'blind' && !solved
const isWordFilled = (num: number): boolean => {
const span = wordSpans.find(s => s.number === num && s.direction === viewTab)
if (!span) return false
return span.cells.every(([r, c]) => entries[r]?.[c])
}
const getWordForClue = (num: number): string => {
const span = wordSpans.find(s => s.number === num && s.direction === viewTab)
if (!span) return ''
return span.cells.map(([r, c]) => entries[r]?.[c] || '').join('')
}
const onClueClick = (num: number) => {
const span = wordSpans.find(
(s) => s.number === num && s.direction === viewTab,
)
if (span && span.cells.length > 0) {
selectCell(span.cells[0][0], span.cells[0][1])
}
}
const startEdit = (num: number) => {
setEditingClue(num)
setClueText('')
}
const cancelEdit = () => {
setEditingClue(null)
setClueText('')
}
const submitClue = async (num: number) => {
const word = getWordForClue(num)
if (!word || !clueText.trim()) return
setSubmitting(true)
try {
await api.submitClue(word, clueText.trim())
setSubmitted(prev => new Set(prev).add(num))
setEditingClue(null)
setClueText('')
} catch { /* ignored */ }
setSubmitting(false)
}
return (
<div className={`clue-panel ${solved ? 'clue-panel-solved' : ''}`} role="region" aria-label="Clues" aria-live="polite">
<div className="clue-tabs" role="tablist">
<button
className={`clue-tab ${viewTab === 'across' ? 'active' : ''}`}
onClick={() => setViewTab('across')}
role="tab"
aria-selected={viewTab === 'across'}
aria-controls="clue-tabpanel"
id="clue-tab-across"
>
<IconArrowRight size={11} style={{ verticalAlign: -1, marginRight: 4 }} aria-hidden="true" />{t('game.across')}
</button>
<button
className={`clue-tab ${viewTab === 'down' ? 'active' : ''}`}
onClick={() => setViewTab('down')}
role="tab"
aria-selected={viewTab === 'down'}
aria-controls="clue-tabpanel"
id="clue-tab-down"
>
<IconArrowDown size={11} style={{ verticalAlign: -1, marginRight: 4 }} aria-hidden="true" />{t('game.down')}
</button>
</div>
<div className="clue-list" ref={listRef} role="tabpanel" id="clue-tabpanel"
aria-labelledby={`clue-tab-${viewTab}`}>
{clues.map((clue) => (
<div key={clue.number}>
<button type="button"
className={`clue-item ${activeClue === clue.number && direction === viewTab ? 'active' : ''}`}
onClick={() => onClueClick(clue.number)}
>
<span className="clue-num">{clue.number}</span>
<span className="clue-text">
{isBlind && !(activeClue === clue.number && direction === viewTab) && !isWordFilled(clue.number)
? <span className="clue-hidden">- - -</span>
: <>
{clue.text}
{clue.wordplay_type && (
<span className="clue-wordplay-tag">{clue.wordplay_type}</span>
)}
</>
}
</span>
<AnimatePresence>
{solved && !submitted.has(clue.number) && editingClue !== clue.number && (
<Tooltip text={t('game.suggestClue')}>
<motion.button
className="clue-suggest-btn"
onClick={e => { e.stopPropagation(); startEdit(clue.number) }}
aria-label={t('game.suggestClue')}
initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0 }}
transition={{ duration: 0.2, delay: clue.number * 0.02 }}
>
<IconMessage2Filled size={12} aria-hidden="true" />
</motion.button>
</Tooltip>
)}
{submitted.has(clue.number) && (
<motion.span
className="clue-submitted"
initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.2 }}
>
<IconCheck size={12} />
</motion.span>
)}
</AnimatePresence>
</button>
<AnimatePresence>
{editingClue === clue.number && (
<motion.div
className="clue-suggest-form"
onClick={e => e.stopPropagation()}
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
style={{ overflow: 'hidden' }}
>
<div style={{ padding: '0.5rem 1.25rem 0.75rem' }}>
<span className="clue-suggest-word">{getWordForClue(clue.number)}</span>
<input
type="text"
placeholder={t('game.clueSubmitPlaceholder')}
aria-label={t('game.clueSubmitPlaceholder')}
value={clueText}
onChange={e => setClueText(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') submitClue(clue.number)
if (e.key === 'Escape') cancelEdit()
}}
maxLength={500}
autoFocus
/>
<div className="clue-suggest-actions">
<button className="btn btn-sm btn-primary" onClick={() => submitClue(clue.number)} disabled={submitting || !clueText.trim()}>
{t('game.submit')}
</button>
<button className="btn btn-sm btn-ghost" onClick={cancelEdit} aria-label={t('common.cancel')}>
<IconX size={12} aria-hidden="true" />
</button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
))}
</div>
</div>
)
}