From bc8ed01e6598bf822ea808fc0577a4c5ca05f61e Mon Sep 17 00:00:00 2001 From: lashman Date: Tue, 2 Dec 2025 17:47:34 +0200 Subject: [PATCH] app shell and header --- frontend/src/App.tsx | 326 +++++++++++++++++++++++ frontend/src/components/Header.tsx | 405 +++++++++++++++++++++++++++++ 2 files changed, 731 insertions(+) create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/components/Header.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..42b3dd3 --- /dev/null +++ b/frontend/src/App.tsx @@ -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 | 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')}

+ + {inputMode !== 'keyboard' && {}} />} +

{t('app.gameControls')}

+ +
+

{t('app.clues')}

+ +
+
+ ) : ( +
+ )} + + +
+ + ) +} diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx new file mode 100644 index 0000000..f362c6b --- /dev/null +++ b/frontend/src/components/Header.tsx @@ -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(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(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([]) + + 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('.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 ( + <> +
+
+ + {streak && streak.current > 0 && ( + + )} + {streakFreezes > 0 && ( + + + + )} + {narratorEnabled && ( + + + + + )} + {mpConnected && mpRoomCode && ( + + )} +
+
+ {/* primary actions - always visible */} + {puzzle && shortId && ( + + + + )} + + + + {puzzle && ( + + + + )} + + + + + + {/* secondary actions - grouped in "more" menu */} +
+ + + + + {showMore && ( + + + + + + + + + +
+ + {window.PublicKeyCredential && ( + + )} + + )} + +
+ + {/* new puzzle button */} +
+ + + {showPanel && ( + +

{t('header.startNewPuzzle')}

+
+ + + + + +
+
+

{t('header.custom')}

+
+ + setFill(Number(e.target.value))} /> + {fill}% +
+
+ +