clue panel
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user