341 lines
14 KiB
TypeScript
341 lines
14 KiB
TypeScript
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 ClueNav from './components/ClueNav'
|
|
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 api from './api'
|
|
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 setInputMode = useStore((s) => s.setInputMode)
|
|
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 />
|
|
<ClueNav />
|
|
<div className="game-input-mode" role="group" aria-label={t('settings.display.inputMode')}>
|
|
{(['auto', 'keyboard', 'stylus'] as const).map(m => (
|
|
<button key={m} type="button"
|
|
className={`btn btn-sm ${inputMode === m ? 'btn-primary' : ''}`}
|
|
aria-pressed={inputMode === m}
|
|
onClick={() => { setInputMode(m); api.fetchSettings().then(s => api.saveSettings({ ...s, inputMode: m })) }}>
|
|
{m === 'auto' ? t('settings.display.inputAuto') : m === 'keyboard' ? t('settings.display.inputKeyboard') : t('settings.display.inputStylus')}
|
|
</button>
|
|
))}
|
|
</div>
|
|
{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>
|
|
)
|
|
}
|