diff --git a/src/app/RootLayout.tsx b/src/app/RootLayout.tsx index eb2843b..c6b5fae 100644 --- a/src/app/RootLayout.tsx +++ b/src/app/RootLayout.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import { Link, Outlet, useRouterState } from '@tanstack/react-router'; -import { Database, Gauge, GitMerge, Languages, Menu, Moon, Radio, Scale, Sparkles, Sun, Table, TrendingUp, Trophy, Users } from 'lucide-react'; +import { Database, Gauge, GitMerge, Languages, Menu, Moon, Radio, Scale, Search, Sparkles, Sun, Table, TrendingUp, Trophy, Users } from 'lucide-react'; +import { SearchPalette } from '@/components/SearchPalette'; import type { LucideIcon } from 'lucide-react'; import { useUiStore } from '@/stores/uiStore'; import { useLocaleStore } from '@/stores/localeStore'; @@ -160,12 +161,26 @@ function MobileNav() { export function RootLayout() { const t = useT(); + const [searchOpen, setSearchOpen] = useState(false); + useEffect(() => { useTournamentStore.getState().init(); }, []); + useEffect(() => { + const onKey = (e: KeyboardEvent): void => { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { + e.preventDefault(); + setSearchOpen((v) => !v); + } + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, []); + return (
+ setSearchOpen(false)} />
@@ -191,6 +206,14 @@ export function RootLayout() {
+
diff --git a/src/components/SearchPalette.tsx b/src/components/SearchPalette.tsx new file mode 100644 index 0000000..014a929 --- /dev/null +++ b/src/components/SearchPalette.tsx @@ -0,0 +1,123 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useNavigate } from '@tanstack/react-router'; +import { Search } from 'lucide-react'; +import { teamFlag } from '@/lib/teams'; +import { useFormat } from '@/lib/i18n'; +import { useTournamentStore } from '@/stores/tournamentStore'; + +interface Hit { + kind: 'team' | 'match'; + label: string; + sub: string; + flags: string[]; + go: () => void; +} + +/** Cmd/Ctrl-K palette: jump to any team or match. */ +export function SearchPalette({ open, onClose }: { open: boolean; onClose: () => void }) { + const { t, kickoffDay } = useFormat(); + const navigate = useNavigate(); + const snapshot = useTournamentStore((s) => s.snapshot); + const [q, setQ] = useState(''); + const [sel, setSel] = useState(0); + const inputRef = useRef(null); + + useEffect(() => { + if (open) { + setQ(''); + setSel(0); + setTimeout(() => inputRef.current?.focus(), 30); + } + }, [open]); + + const hits = useMemo(() => { + if (!snapshot || q.trim().length < 2) return []; + const needle = q.trim().toLowerCase(); + const out: Hit[] = []; + const teams = new Set(); + for (const f of snapshot.fixtures) { + for (const team of [f.home.team, f.away.team]) { + if (team && !teams.has(team) && team.toLowerCase().includes(needle)) { + teams.add(team); + out.push({ + kind: 'team', label: team, sub: t.nav.teams, flags: [teamFlag(team)], + go: () => void navigate({ to: '/team/$name', params: { name: team } }), + }); + } + } + } + for (const f of snapshot.fixtures) { + const label = `${f.home.label} – ${f.away.label}`; + if (label.toLowerCase().includes(needle)) { + out.push({ + kind: 'match', label, sub: kickoffDay(f.kickoff), + flags: [f.home.team ? teamFlag(f.home.team) : '', f.away.team ? teamFlag(f.away.team) : ''], + go: () => void navigate({ to: '/match/$num', params: { num: String(f.num) } }), + }); + } + if (out.length > 14) break; + } + return out.slice(0, 12); + }, [snapshot, q, navigate, t, kickoffDay]); + + useEffect(() => setSel(0), [q]); + + if (!open) return null; + + const pick = (h: Hit | undefined): void => { + if (!h) return; + h.go(); + onClose(); + }; + + return ( +
+
e.stopPropagation()} + role="dialog" + aria-label={t.search.title} + > +
+ + setQ(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Escape') onClose(); + else if (e.key === 'ArrowDown') { e.preventDefault(); setSel((s) => Math.min(hits.length - 1, s + 1)); } + else if (e.key === 'ArrowUp') { e.preventDefault(); setSel((s) => Math.max(0, s - 1)); } + else if (e.key === 'Enter') pick(hits[sel]); + }} + placeholder={t.search.placeholder} + className="w-full bg-transparent py-3 text-sm text-ink outline-none placeholder:text-faint" + /> + esc +
+
    + {hits.map((h, i) => ( +
  • + +
  • + ))} + {q.trim().length >= 2 && hits.length === 0 && ( +
  • {t.search.noResults}
  • + )} + {q.trim().length < 2 && ( +
  • {t.search.hint}
  • + )} +
+
+
+ ); +} diff --git a/src/lib/i18n/de.ts b/src/lib/i18n/de.ts index 850f837..691f809 100644 --- a/src/lib/i18n/de.ts +++ b/src/lib/i18n/de.ts @@ -102,6 +102,12 @@ export const de: Dict = { lineups: "Aufstellungen", keyPlayers: "Schlüsselspieler (beste Torschützen aller Zeiten)", }, + search: { + title: "Suche", + placeholder: "Team oder Spiel…", + hint: "Mindestens zwei Buchstaben tippen — Teams und Spiele.", + noResults: "Nichts gefunden.", + }, notFound: { message: "Ball im Aus — diese Seite gibt es nicht.", backToLive: "Zurück zu Live", diff --git a/src/lib/i18n/en.ts b/src/lib/i18n/en.ts index a06b548..cd1e826 100644 --- a/src/lib/i18n/en.ts +++ b/src/lib/i18n/en.ts @@ -101,6 +101,12 @@ export const en = { lineups: "Lineups", keyPlayers: "Key players (all-time top scorers)", }, + search: { + title: "Search", + placeholder: "Team or match…", + hint: "Type at least two letters — teams and matches.", + noResults: "Nothing found.", + }, notFound: { message: "Off the pitch — this page doesn't exist.", backToLive: "Back to Live",