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
+405
View File
@@ -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>&#8592;</kbd> <kbd>&#8593;</kbd> <kbd>&#8595;</kbd> <kbd>&#8594;</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>
</>
)
}