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 | 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 } if (window.location.pathname === '/community/today') { return } if (instancePage) { return { setInstancePage(null); window.history.replaceState(null, '', '/') }} /> } if (showEditor) { return useStore.getState().setShowEditor(false)} /> } if (showAdmin) { return useStore.getState().setShowAdmin(false)} /> } if (mpMode === 'party' && mpConnected) { return ( ) } return (
{t('app.skipToContent')}
{isOffline && ( {t('app.offline')} )} {loading && (

{t('app.generating')}

)} {error && (
{error}
)}
{showOnboarding && } {puzzle ? (
{solved && gameMode === 'speedrun' && elapsed >= timeLimit && ( {t('game.timeUp')} )} {solved && !(gameMode === 'speedrun' && elapsed >= timeLimit) && gameMode !== 'marathon' && (
{t('game.solved')} {solvePercentile !== null && solveTotalSolvers !== null && solveTotalSolvers > 1 && ( {t('game.fasterThan', { percent: Math.round(solvePercentile), rank: solveRank, total: solveTotalSolvers })} )}
{t('game.solvedHint')} {dailyChallenge && challengeCompleted && (
{t('challenge.completed', { challenge: t(`challenge.types.${dailyChallenge}`) })}
)} {dailyChallenge && !challengeCompleted && (
{t('challenge.notMet', { challenge: t(`challenge.types.${dailyChallenge}`) })}
)}
{showHeatmap && ( )}
)}
{solved && gameMode === 'marathon' && ( {t('game.marathonComplete', { count: marathonCount })} )} {marathonFailed && ( {t('game.marathonOver', { count: marathonCount })} )} {dailyChallenge && !solved && ( {t('challenge.todaysChallenge')}: {t(`challenge.types.${dailyChallenge}`)} {t(`challenge.descriptions.${dailyChallenge}`)} )}

{t('app.crosswordGrid')}

{(['auto', 'keyboard', 'stylus'] as const).map(m => ( ))}
{inputMode !== 'keyboard' && {}} />}

{t('app.gameControls')}

{t('app.clues')}

) : (
)}
) }