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",