app shell and header
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>←</kbd> <kbd>↑</kbd> <kbd>↓</kbd> <kbd>→</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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user