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>
)
}