app shell and header

This commit is contained in:
2025-12-02 17:47:34 +02:00
parent c3c839c64f
commit bc8ed01e65
2 changed files with 731 additions and 0 deletions
+326
View File
@@ -0,0 +1,326 @@
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { motion, AnimatePresence, MotionConfig } from 'motion/react'
import { IconShare2 } from '@tabler/icons-react'
import Grid from './components/Grid'
import CluePanel from './components/CluePanel'
import ActiveClue from './components/ActiveClue'
import Header from './components/Header'
import InfoBar from './components/InfoBar'
import ZoomStrip from './components/ZoomStrip'
import Welcome from './components/Welcome'
import OnboardingModal from './components/OnboardingModal'
import PuzzleRating from './components/PuzzleRating'
import Leaderboard from './components/Leaderboard'
import EditorPanel from './components/EditorPanel'
import AdminPanel from './components/AdminPanel'
import InstancePages from './components/InstancePages'
import ReceiptNotification from './components/ReceiptNotification'
import SolveHeatmap from './components/SolveHeatmap'
import PartyPage from './components/PartyPage'
import DailyPreviewRenderer from './components/DailyPreviewRenderer'
import CommunityToday from './components/CommunityToday'
import { useKeyboard } from './hooks/useKeyboard'
import { useNarrator } from './hooks/useNarrator'
import { useSounds } from './hooks/useSounds'
import { useStore } from './store'
import { generateShareText } from './share'
import * as offlineStore from './offline'
export default function App() {
const puzzle = useStore((s) => s.puzzle)
const loading = useStore((s) => s.loading)
const error = useStore((s) => s.error)
const solved = useStore((s) => s.solved)
const inputMode = useStore((s) => s.inputMode)
const fetchSession = useStore((s) => s.fetchSession)
const loadByShortId = useStore((s) => s.loadByShortId)
const checkPendingReceipts = useStore((s) => s.checkPendingReceipts)
const showOnboarding = useStore((s) => s.showOnboarding)
const showEditor = useStore((s) => s.showEditor)
const showAdmin = useStore((s) => s.showAdmin)
const gameMode = useStore((s) => s.gameMode)
const marathonCount = useStore((s) => s.marathonCount)
const marathonFailed = useStore((s) => s.marathonFailed)
const timeLimit = useStore((s) => s.timeLimit)
const elapsed = useStore((s) => s.elapsed)
const puzzleId = useStore((s) => s.puzzleId)
const solveRank = useStore((s) => s.solveRank)
const solvePercentile = useStore((s) => s.solvePercentile)
const solveTotalSolvers = useStore((s) => s.solveTotalSolvers)
const entries = useStore((s) => s.entries)
const cellStatus = useStore((s) => s.cellStatus)
const cellEverWrong = useStore((s) => s.cellEverWrong)
const hintsUsed = useStore((s) => s.hintsUsed)
const shortId = useStore((s) => s.shortId)
const dailyDate = useStore((s) => s.dailyDate)
const dailyPreset = useStore((s) => s.dailyPreset)
const dailyChallenge = useStore((s) => s.dailyChallenge)
const challengeCompleted = useStore((s) => s.challengeCompleted)
const showHeatmap = useStore((s) => s.showHeatmap)
const mpMode = useStore((s) => s.mpMode)
const mpConnected = useStore((s) => s.mpConnected)
const { t, i18n } = useTranslation()
const [isOffline, setIsOffline] = useState(!navigator.onLine)
const [instancePage, setInstancePage] = useState<'about' | 'health' | null>(() =>
window.location.pathname === '/about' ? 'about' : null
)
const [shareCopied, setShareCopied] = useState(false)
useKeyboard()
useNarrator()
useSounds()
// offline status
useEffect(() => offlineStore.onOnlineChange(online => setIsOffline(!online)), [])
// route detection
useEffect(() => {
const path = window.location.pathname
if (path === '/about') { window.history.replaceState(null, '', '/about'); return }
fetch('/api/admin/config').then(r => r.json()).then(cfg => {
const adminPath = cfg.path || '/admin'
if (path === adminPath) {
useStore.getState().setShowAdmin(true)
window.history.replaceState(null, '', '/')
}
}).catch(() => {})
}, [])
// autosave progress to IndexedDB
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
if (!puzzleId || !puzzle || solved) return
if (saveTimer.current) clearTimeout(saveTimer.current)
saveTimer.current = setTimeout(() => {
offlineStore.saveProgress(puzzleId, entries).catch(() => {})
}, 1000)
return () => { if (saveTimer.current) clearTimeout(saveTimer.current) }
}, [entries, puzzleId, puzzle, solved])
useEffect(() => {
fetchSession()
checkPendingReceipts()
const path = window.location.pathname
const match = path.match(/^\/p\/([a-zA-Z0-9]+)$/)
if (match) {
loadByShortId(match[1])
window.history.replaceState(null, '', '/')
}
const dailyMatch = path.match(/^\/daily\/([a-z]+)$/)
if (dailyMatch) {
const today = new Date().toISOString().split('T')[0]
useStore.getState().loadDaily(today, dailyMatch[1])
window.history.replaceState(null, '', '/')
}
}, [])
// page title
useEffect(() => {
if (mpMode === 'party' && mpConnected) document.title = 'Party - cruciverb'
else if (puzzle) document.title = 'Puzzle - cruciverb'
else document.title = 'cruciverb'
}, [puzzle, mpMode, mpConnected])
// html lang attribute
useEffect(() => {
document.documentElement.lang = i18n.language
}, [i18n.language])
if (window.location.pathname === '/internal/generate-daily-image') {
return <DailyPreviewRenderer />
}
if (window.location.pathname === '/community/today') {
return <CommunityToday />
}
if (instancePage) {
return <InstancePages page={instancePage} onBack={() => { setInstancePage(null); window.history.replaceState(null, '', '/') }} />
}
if (showEditor) {
return <EditorPanel onClose={() => useStore.getState().setShowEditor(false)} />
}
if (showAdmin) {
return <AdminPanel onClose={() => useStore.getState().setShowAdmin(false)} />
}
if (mpMode === 'party' && mpConnected) {
return (
<MotionConfig reducedMotion="user">
<div className="app">
<a href="#main-content" className="skip-link">{t('app.skipToContent')}</a>
<Header />
<div id="main-content"><PartyPage /></div>
<ReceiptNotification />
</div>
</MotionConfig>
)
}
return (
<MotionConfig reducedMotion="user">
<div className="app">
<a href="#main-content" className="skip-link">{t('app.skipToContent')}</a>
<Header />
<AnimatePresence>
{isOffline && (
<motion.div className="offline-banner" key="offline" role="status"
initial={{ opacity: 0, y: -20 }} animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }} transition={{ duration: 0.2 }}>
{t('app.offline')}
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{loading && (
<motion.div className="loading-overlay" key="loading"
initial={{ opacity: 0 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} transition={{ duration: 0.2 }}>
<motion.div className="loading-card" role="status" aria-live="polite"
initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 10 }}
transition={{ duration: 0.2 }}>
<div className="spinner" />
<p>{t('app.generating')}</p>
</motion.div>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{error && (
<motion.div className="main-content" key="error"
initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }} transition={{ duration: 0.15 }}>
<div className="error-banner" role="alert">{error}</div>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{showOnboarding && <OnboardingModal key="onboarding" />}
</AnimatePresence>
{puzzle ? (
<div className="main-content" id="main-content">
<AnimatePresence>
{solved && gameMode === 'speedrun' && elapsed >= timeLimit && (
<motion.div className="solved-banner" key="timeup" role="status"
style={{ background: 'var(--color-error)' }}
initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }} transition={{ duration: 0.2 }}>
{t('game.timeUp')}
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{solved && !(gameMode === 'speedrun' && elapsed >= timeLimit) && gameMode !== 'marathon' && (
<motion.div key="solved" style={{ textAlign: 'center' }}
initial={{ opacity: 0 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} transition={{ duration: 0.2 }}>
<div className="solved-banner" role="status">
{t('game.solved')}
{solvePercentile !== null && solveTotalSolvers !== null && solveTotalSolvers > 1 && (
<span className="solved-rank">
{t('game.fasterThan', { percent: Math.round(solvePercentile), rank: solveRank, total: solveTotalSolvers })}
</span>
)}
</div>
<button className="btn btn-sm share-result-btn" onClick={() => {
if (!puzzle) return
const text = generateShareText(puzzle, entries, cellStatus, elapsed, hintsUsed, shortId, dailyPreset, dailyDate, cellEverWrong)
navigator.clipboard.writeText(text).then(() => {
setShareCopied(true)
setTimeout(() => setShareCopied(false), 2000)
})
}}>
<IconShare2 size={14} style={{ verticalAlign: -2 }} />
{' '}{shareCopied ? t('common.copied') : t('game.shareResult')}
</button>
<span className="solved-hint">{t('game.solvedHint')}</span>
{dailyChallenge && challengeCompleted && (
<div className="challenge-result challenge-success">
{t('challenge.completed', { challenge: t(`challenge.types.${dailyChallenge}`) })}
</div>
)}
{dailyChallenge && !challengeCompleted && (
<div className="challenge-result challenge-failed">
{t('challenge.notMet', { challenge: t(`challenge.types.${dailyChallenge}`) })}
</div>
)}
<div className="post-solve-actions">
<button className="btn btn-sm btn-ghost" onClick={() => useStore.setState({ showHeatmap: !showHeatmap })}>
{showHeatmap ? t('heatmap.hide') : t('heatmap.show')}
</button>
</div>
<AnimatePresence>
{showHeatmap && (
<motion.div key="heatmap"
initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }} transition={{ duration: 0.2 }}>
<SolveHeatmap />
</motion.div>
)}
</AnimatePresence>
<PuzzleRating />
<Leaderboard />
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{solved && gameMode === 'marathon' && (
<motion.div className="solved-banner" key="marathon-done" role="status"
initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }} transition={{ duration: 0.2 }}>
{t('game.marathonComplete', { count: marathonCount })}
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{marathonFailed && (
<motion.div className="solved-banner" key="marathon-fail" role="status"
style={{ background: 'var(--color-error)' }}
initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }} transition={{ duration: 0.2 }}>
{t('game.marathonOver', { count: marathonCount })}
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{dailyChallenge && !solved && (
<motion.div className="challenge-banner" key="challenge"
initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }} transition={{ duration: 0.15 }}>
<span className="challenge-banner-title">{t('challenge.todaysChallenge')}: {t(`challenge.types.${dailyChallenge}`)}</span>
<span className="challenge-banner-desc">{t(`challenge.descriptions.${dailyChallenge}`)}</span>
</motion.div>
)}
</AnimatePresence>
<ActiveClue />
<div className="game-area">
<div className="grid-column">
<h2 className="visually-hidden">{t('app.crosswordGrid')}</h2>
<Grid />
{inputMode !== 'keyboard' && <ZoomStrip onDone={() => {}} />}
<h2 className="visually-hidden">{t('app.gameControls')}</h2>
<InfoBar />
</div>
<h2 className="visually-hidden">{t('app.clues')}</h2>
<CluePanel />
</div>
</div>
) : (
<div id="main-content"><Welcome /></div>
)}
<ReceiptNotification />
</div>
</MotionConfig>
)
}
+405
View File
@@ -0,0 +1,405 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { motion, AnimatePresence } from 'motion/react'
import { IconFlameFilled, IconShare, IconSettings, IconPlus, IconTrophyFilled,
IconMessageFilled, IconPencilFilled, IconDotsFilled, IconKeyFilled, IconWorldFilled,
IconGraphFilled, IconChartAreaLineFilled, IconMessagesFilled, IconDeviceSpeakerFilled,
IconKeyboardFilled, IconX, IconClockFilled, IconArchiveFilled, IconSnowflake,
IconTargetArrow } from '@tabler/icons-react'
import { useStore } from '../store'
import Select from './ui/Select'
import Tooltip from './ui/Tooltip'
import SettingsPanel from './SettingsPanel'
import { applySettings } from '../settingsApply'
import ShareModal from './ShareModal'
import AchievementsPanel from './AchievementsPanel'
import CommunityCluesPanel from './CommunityCluesPanel'
import DailyLeaderboard from './DailyLeaderboard'
import AnalyticsDashboard from './AnalyticsDashboard'
import MultiplayerPanel from './MultiplayerPanel'
import PuzzleHistory from './PuzzleHistory'
import PuzzleArchive from './PuzzleArchive'
import ClueTrainer from './ClueTrainer'
import * as api from '../api'
export default function Header() {
const puzzle = useStore((s) => s.puzzle)
const loading = useStore((s) => s.loading)
const generatePuzzle = useStore((s) => s.generatePuzzle)
const shortId = useStore((s) => s.shortId)
const streak = useStore((s) => s.streak)
const streakFreezes = useStore((s) => s.streakFreezes)
const narratorEnabled = useStore((s) => s.narratorEnabled)
const mpRoomCode = useStore((s) => s.mpRoomCode)
const mpPlayers = useStore((s) => s.mpPlayers)
const mpConnected = useStore((s) => s.mpConnected)
const [showSettings, setShowSettings] = useState(false)
const [showShare, setShowShare] = useState(false)
const [showAchievements, setShowAchievements] = useState(false)
const [showCommunity, setShowCommunity] = useState(false)
const [showLeaderboard, setShowLeaderboard] = useState(false)
const [showAnalytics, setShowAnalytics] = useState(false)
const [showMultiplayer, setShowMultiplayer] = useState(false)
const [showHistory, setShowHistory] = useState(false)
const [showArchive, setShowArchive] = useState(false)
const [showTrainer, setShowTrainer] = useState(false)
const setShowEditor = useStore((s) => s.setShowEditor)
const { t } = useTranslation()
const [showMore, setShowMore] = useState(false)
const [showShortcuts, setShowShortcuts] = useState(false)
const moreRef = useRef<HTMLDivElement>(null)
useEffect(() => {
api.fetchSettings().then(s => {
if (s && Object.keys(s).length) {
applySettings({ theme: 'system', colorblind: 'none', textScale: 100, cookieLifetime: 2592000, ...s })
if (s.inputMode === 'auto' || s.inputMode === 'keyboard' || s.inputMode === 'stylus') useStore.getState().setInputMode(s.inputMode)
}
})
}, [])
const [showPanel, setShowPanel] = useState(false)
const [size, setSize] = useState(9)
const [fill, setFill] = useState(78)
const [diff, setDiff] = useState(1)
const panelRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!showPanel) return
function onClick(e: MouseEvent) {
const target = e.target
if (panelRef.current && (target instanceof Node && !panelRef.current.contains(target))) setShowPanel(false)
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') setShowPanel(false)
}
document.addEventListener('mousedown', onClick)
document.addEventListener('keydown', onKeyDown)
return () => { document.removeEventListener('mousedown', onClick); document.removeEventListener('keydown', onKeyDown) }
}, [showPanel])
const menuItemsRef = useRef<HTMLButtonElement[]>([])
const focusMenuItem = useCallback((idx: number) => {
const items = menuItemsRef.current
if (!items.length) return
const i = ((idx % items.length) + items.length) % items.length
items[i]?.focus()
}, [])
useEffect(() => {
if (!showMore) return
// focus first menu item on open
requestAnimationFrame(() => {
menuItemsRef.current = Array.from(
moreRef.current?.querySelectorAll('[role="menuitem"]') || []
).filter((el): el is HTMLButtonElement => el instanceof HTMLButtonElement)
focusMenuItem(0)
})
function onClick(e: MouseEvent) {
const target = e.target
if (moreRef.current && (target instanceof Node && !moreRef.current.contains(target))) setShowMore(false)
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
setShowMore(false)
moreRef.current?.querySelector<HTMLButtonElement>('.btn')?.focus()
}
const items = menuItemsRef.current
if (!items.length) return
const active = document.activeElement instanceof HTMLButtonElement ? document.activeElement : null
const idx = active ? items.indexOf(active) : -1
if (e.key === 'ArrowDown') { e.preventDefault(); focusMenuItem(idx + 1) }
if (e.key === 'ArrowUp') { e.preventDefault(); focusMenuItem(idx - 1) }
if (e.key === 'Home') { e.preventDefault(); focusMenuItem(0) }
if (e.key === 'End') { e.preventDefault(); focusMenuItem(items.length - 1) }
}
document.addEventListener('mousedown', onClick)
document.addEventListener('keydown', onKeyDown)
return () => { document.removeEventListener('mousedown', onClick); document.removeEventListener('keydown', onKeyDown) }
}, [showMore, focusMenuItem])
const startPreset = (preset: string) => { setShowPanel(false); generatePuzzle(preset) }
const startCustom = () => { setShowPanel(false); generatePuzzle(undefined, size, (100 - fill) / 100, diff) }
return (
<>
<header className="header">
<div className="header-left">
<button type="button" className="header-brand" onClick={() => useStore.setState({ puzzle: null, puzzleId: null, shortId: null, solved: false, error: null })}>{t('app.name')}</button>
{streak && streak.current > 0 && (
<span className="header-streak"><IconFlameFilled size={12} aria-hidden="true" /> {streak.current}</span>
)}
{streakFreezes > 0 && (
<Tooltip text={t('header.freezeTooltip', { count: streakFreezes })}>
<span className="header-freeze"><IconSnowflake size={12} aria-hidden="true" /> {streakFreezes}</span>
</Tooltip>
)}
{narratorEnabled && (
<Tooltip text={t('header.narratorActive')}>
<span className="header-narrator-tag" aria-label={t('header.narratorActive')}>
<IconDeviceSpeakerFilled size={12} aria-hidden="true" /> {t('header.narrator')}
</span>
</Tooltip>
)}
{mpConnected && mpRoomCode && (
<button type="button" className="header-room-tag" onClick={() => setShowMultiplayer(true)}>
<span className="mp-status-dot mp-connected" /> {mpRoomCode} - {mpPlayers.length}p
</button>
)}
</div>
<div className="header-actions">
{/* primary actions - always visible */}
{puzzle && shortId && (
<Tooltip text={t('header.share')}>
<button className="btn btn-sm btn-ghost" onClick={() => setShowShare(true)} aria-label={t('header.share')}>
<IconShare size={16} aria-hidden="true" />
</button>
</Tooltip>
)}
<Tooltip text={t('header.playTogether')}>
<button className="btn btn-sm btn-ghost" onClick={() => setShowMultiplayer(true)} aria-label={t('header.playTogether')}>
<IconMessagesFilled size={16} aria-hidden="true" />
</button>
</Tooltip>
{puzzle && (
<Tooltip text={t('header.communityClues')}>
<button className="btn btn-sm btn-ghost" onClick={() => setShowCommunity(true)} aria-label={t('header.communityClues')}>
<IconMessageFilled size={16} aria-hidden="true" />
</button>
</Tooltip>
)}
<Tooltip text={t('header.leaderboard')}>
<button className="btn btn-sm btn-ghost" onClick={() => setShowLeaderboard(true)} aria-label={t('header.leaderboard')}>
<IconGraphFilled size={16} aria-hidden="true" />
</button>
</Tooltip>
{/* secondary actions - grouped in "more" menu */}
<div style={{ position: 'relative' }} ref={moreRef}>
<Tooltip text={t('header.more')}>
<button className="btn btn-sm btn-ghost" onClick={() => setShowMore(!showMore)} aria-label={t('header.more')}>
<IconDotsFilled size={16} aria-hidden="true" />
</button>
</Tooltip>
<AnimatePresence>
{showMore && (
<motion.div className="header-more-menu" key="more-menu" role="menu"
initial={{ opacity: 0, scale: 0.95, y: -4 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: -4 }}
transition={{ duration: 0.12 }}>
<button className="header-menu-item" role="menuitem" onClick={() => { setShowMore(false); setShowEditor(true) }}>
<IconPencilFilled size={14} aria-hidden="true" /> {t('header.puzzleEditor')}
</button>
<button className="header-menu-item" role="menuitem" onClick={() => { setShowMore(false); setShowAchievements(true) }}>
<IconTrophyFilled size={14} aria-hidden="true" /> {t('header.achievements')}
</button>
<button className="header-menu-item" role="menuitem" onClick={() => { setShowMore(false); setShowAnalytics(true) }}>
<IconChartAreaLineFilled size={14} aria-hidden="true" /> {t('header.myStats')}
</button>
<button className="header-menu-item" role="menuitem" onClick={() => { setShowMore(false); setShowHistory(true) }}>
<IconClockFilled size={14} aria-hidden="true" /> {t('header.history')}
</button>
<button className="header-menu-item" role="menuitem" onClick={() => { setShowMore(false); setShowArchive(true) }}>
<IconArchiveFilled size={14} aria-hidden="true" /> {t('header.archive')}
</button>
<button className="header-menu-item" role="menuitem" onClick={() => { setShowMore(false); setShowTrainer(true) }}>
<IconTargetArrow size={14} aria-hidden="true" /> {t('header.clueTrainer')}
</button>
<button className="header-menu-item" role="menuitem" onClick={() => { setShowMore(false); setShowSettings(true) }}>
<IconSettings size={14} aria-hidden="true" /> {t('header.settings')}
</button>
<button className="header-menu-item" role="menuitem" onClick={() => { setShowMore(false); setShowShortcuts(true) }}>
<IconKeyboardFilled size={14} aria-hidden="true" /> {t('header.keyboardShortcuts')}
</button>
<div className="header-menu-divider" role="separator" />
<button className="header-menu-item" role="menuitem" onClick={() => { setShowMore(false); window.location.pathname = '/about' }}>
<IconWorldFilled size={14} aria-hidden="true" /> {t('header.aboutInstance')}
</button>
{window.PublicKeyCredential && (
<button className="header-menu-item" role="menuitem" onClick={async () => {
setShowMore(false)
try { await api.authenticatePasskey(); useStore.getState().fetchSession() } catch { /* ignored */ }
}}>
<IconKeyFilled size={14} aria-hidden="true" /> {t('header.signInPasskey')}
</button>
)}
</motion.div>
)}
</AnimatePresence>
</div>
{/* new puzzle button */}
<div style={{ position: 'relative' }} ref={panelRef}>
<button className="btn btn-primary" onClick={() => setShowPanel(!showPanel)} disabled={loading}>
<IconPlus size={14} aria-hidden="true" /> {t('header.newPuzzle')}
</button>
<AnimatePresence>
{showPanel && (
<motion.div className="new-game-panel" key="new-game"
initial={{ opacity: 0, scale: 0.95, y: -4 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: -4 }}
transition={{ duration: 0.15 }}>
<h3>{t('header.startNewPuzzle')}</h3>
<div className="preset-grid">
<button type="button" className="preset-card" onClick={() => startPreset('glyph')}>
<div className="preset-name">{t('header.presetGlyph')}</div><div className="preset-desc">{t('header.presetGlyphDesc')}</div>
</button>
<button type="button" className="preset-card" onClick={() => startPreset('rune')}>
<div className="preset-name">{t('header.presetRune')}</div><div className="preset-desc">{t('header.presetRuneDesc')}</div>
</button>
<button type="button" className="preset-card" onClick={() => startPreset('scroll')}>
<div className="preset-name">{t('header.presetScroll')}</div><div className="preset-desc">{t('header.presetScrollDesc')}</div>
</button>
<button type="button" className="preset-card" onClick={() => startPreset('codex')}>
<div className="preset-name">{t('header.presetCodex')}</div><div className="preset-desc">{t('header.presetCodexDesc')}</div>
</button>
<button type="button" className="preset-card" onClick={() => startPreset('grimoire')}>
<div className="preset-name">{t('header.presetGrimoire')}</div><div className="preset-desc">{t('header.presetGrimoireDesc')}</div>
</button>
</div>
<div className="custom-section">
<h4>{t('header.custom')}</h4>
<div className="custom-row">
<label>{t('header.size')}</label>
<Select value={size} onChange={v => setSize(Number(v))} options={[5,7,9,11,13,15,17,19,21].map(n => ({ value: n, label: `${n}x${n}` }))} />
</div>
<div className="custom-row">
<label htmlFor="header-fill-range">{t('header.fill')}</label>
<input id="header-fill-range" type="range" min={20} max={100} value={fill} onChange={(e) => setFill(Number(e.target.value))} />
<span className="range-value">{fill}%</span>
</div>
<div className="custom-row">
<label>{t('welcome.difficulty')}</label>
<Select value={diff} onChange={v => setDiff(Number(v))} options={[
{ value: 0, label: t('difficulty.veryEasy') }, { value: 1, label: t('difficulty.easy') },
{ value: 2, label: t('difficulty.medium') }, { value: 3, label: t('difficulty.hard') },
]} />
</div>
<button className="btn btn-primary" onClick={startCustom} disabled={loading} style={{ width: '100%', marginTop: 8 }}>{t('header.generate')}</button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</header>
<AnimatePresence>
{showSettings && <SettingsPanel onClose={() => setShowSettings(false)} />}
</AnimatePresence>
<AnimatePresence>
{showShare && <ShareModal key="share" onClose={() => setShowShare(false)} />}
</AnimatePresence>
<AnimatePresence>
{showAchievements && <AchievementsPanel key="ach" onClose={() => setShowAchievements(false)} />}
</AnimatePresence>
<AnimatePresence>
{showCommunity && <CommunityCluesPanel key="cc" onClose={() => setShowCommunity(false)} />}
</AnimatePresence>
<AnimatePresence>
{showLeaderboard && <DailyLeaderboard onClose={() => setShowLeaderboard(false)} />}
</AnimatePresence>
<AnimatePresence>
{showAnalytics && <AnalyticsDashboard onClose={() => setShowAnalytics(false)} />}
</AnimatePresence>
<AnimatePresence>
{showMultiplayer && <MultiplayerPanel onClose={() => setShowMultiplayer(false)} />}
</AnimatePresence>
<AnimatePresence>
{showHistory && <PuzzleHistory onClose={() => setShowHistory(false)} />}
</AnimatePresence>
<AnimatePresence>
{showArchive && <PuzzleArchive key="archive" onClose={() => setShowArchive(false)} />}
</AnimatePresence>
<AnimatePresence>
{showTrainer && <ClueTrainer key="trainer" onClose={() => setShowTrainer(false)} />}
</AnimatePresence>
<AnimatePresence>
{showShortcuts && (
<motion.div className="settings-overlay" onClick={() => setShowShortcuts(false)}
key="shortcuts" initial={{ opacity: 0 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} transition={{ duration: 0.15 }}>
<motion.div className="sk-modal" onClick={e => e.stopPropagation()}
role="dialog" aria-modal="true" aria-label={t('shortcuts.title')}
initial={{ opacity: 0, scale: 0.96, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.96, y: 10 }}
transition={{ duration: 0.2 }}>
<div className="sk-header">
<div>
<h3 className="sk-title">{t('shortcuts.title')}</h3>
<p className="sk-subtitle">{t('shortcuts.subtitle')}</p>
</div>
<button className="btn btn-sm btn-ghost" onClick={() => setShowShortcuts(false)} aria-label={t('common.close')}><IconX size={16} aria-hidden="true" /></button>
</div>
<div className="sk-body">
<div className="sk-section">
<h4 className="sk-section-title">{t('shortcuts.navigation')}</h4>
<div className="sk-grid">
<div className="sk-row">
<div className="sk-keys"><kbd>&#8592;</kbd> <kbd>&#8593;</kbd> <kbd>&#8595;</kbd> <kbd>&#8594;</kbd></div>
<span className="sk-desc">{t('shortcuts.arrows')}</span>
</div>
<div className="sk-row">
<div className="sk-keys"><kbd>Tab</kbd></div>
<span className="sk-desc">{t('shortcuts.tabNext')}</span>
</div>
<div className="sk-row">
<div className="sk-keys"><kbd>Shift</kbd> + <kbd>Tab</kbd></div>
<span className="sk-desc">{t('shortcuts.tabPrev')}</span>
</div>
<div className="sk-row">
<div className="sk-keys"><kbd>Space</kbd></div>
<span className="sk-desc">{t('shortcuts.space')}</span>
</div>
<div className="sk-row">
<div className="sk-keys"><kbd>Esc</kbd></div>
<span className="sk-desc">{t('shortcuts.escape')}</span>
</div>
</div>
</div>
<div className="sk-section">
<h4 className="sk-section-title">{t('shortcuts.editing')}</h4>
<div className="sk-grid">
<div className="sk-row">
<div className="sk-keys"><kbd>A</kbd> - <kbd>Z</kbd></div>
<span className="sk-desc">{t('shortcuts.letters')}</span>
</div>
<div className="sk-row">
<div className="sk-keys"><kbd>Backspace</kbd></div>
<span className="sk-desc">{t('shortcuts.backspace')}</span>
</div>
<div className="sk-row">
<div className="sk-keys"><kbd>Delete</kbd></div>
<span className="sk-desc">{t('shortcuts.delete')}</span>
</div>
</div>
</div>
<div className="sk-section">
<h4 className="sk-section-title">{t('shortcuts.narrator')}</h4>
<div className="sk-grid">
<div className="sk-row">
<div className="sk-keys"><kbd>?</kbd></div>
<span className="sk-desc">{t('shortcuts.question')}</span>
</div>
<div className="sk-row">
<div className="sk-keys"><kbd>Ctrl</kbd> + <kbd>H</kbd></div>
<span className="sk-desc">{t('shortcuts.ctrlH')}</span>
</div>
<div className="sk-row">
<div className="sk-keys"><kbd>F1</kbd></div>
<span className="sk-desc">{t('shortcuts.f1')}</span>
</div>
</div>
</div>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</>
)
}