Global search: Cmd/Ctrl-K palette for teams and matches
A keyboard-first palette (plus a header button) that jumps to any team page or match page — arrow keys, enter, esc; flags and kickoff days in the results. Pure client, searches the snapshot already in memory. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+24
-1
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link, Outlet, useRouterState } from '@tanstack/react-router';
|
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 type { LucideIcon } from 'lucide-react';
|
||||||
import { useUiStore } from '@/stores/uiStore';
|
import { useUiStore } from '@/stores/uiStore';
|
||||||
import { useLocaleStore } from '@/stores/localeStore';
|
import { useLocaleStore } from '@/stores/localeStore';
|
||||||
@@ -160,12 +161,26 @@ function MobileNav() {
|
|||||||
|
|
||||||
export function RootLayout() {
|
export function RootLayout() {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
|
const [searchOpen, setSearchOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
useTournamentStore.getState().init();
|
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 (
|
return (
|
||||||
<div className="flex min-h-full flex-col">
|
<div className="flex min-h-full flex-col">
|
||||||
|
<SearchPalette open={searchOpen} onClose={() => setSearchOpen(false)} />
|
||||||
<header className="sticky top-0 z-30 border-b border-line bg-surface/85 backdrop-blur">
|
<header className="sticky top-0 z-30 border-b border-line bg-surface/85 backdrop-blur">
|
||||||
<div className="mx-auto flex h-14 max-w-6xl items-center gap-4 px-4">
|
<div className="mx-auto flex h-14 max-w-6xl items-center gap-4 px-4">
|
||||||
<Link to="/" className="flex items-center gap-2 font-display text-lg font-extrabold tracking-tight text-ink">
|
<Link to="/" className="flex items-center gap-2 font-display text-lg font-extrabold tracking-tight text-ink">
|
||||||
@@ -191,6 +206,14 @@ export function RootLayout() {
|
|||||||
</nav>
|
</nav>
|
||||||
<div className="ml-auto flex items-center gap-1.5">
|
<div className="ml-auto flex items-center gap-1.5">
|
||||||
<ConnectionStatus />
|
<ConnectionStatus />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSearchOpen(true)}
|
||||||
|
aria-label={t.search.title}
|
||||||
|
className="grid h-9 w-9 place-items-center rounded-md text-muted hover:bg-elevated hover:text-ink transition-colors"
|
||||||
|
>
|
||||||
|
<Search size={17} />
|
||||||
|
</button>
|
||||||
<LanguageToggle />
|
<LanguageToggle />
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setQ('');
|
||||||
|
setSel(0);
|
||||||
|
setTimeout(() => inputRef.current?.focus(), 30);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const hits = useMemo<Hit[]>(() => {
|
||||||
|
if (!snapshot || q.trim().length < 2) return [];
|
||||||
|
const needle = q.trim().toLowerCase();
|
||||||
|
const out: Hit[] = [];
|
||||||
|
const teams = new Set<string>();
|
||||||
|
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 (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 p-4 pt-[12vh]" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-lg overflow-hidden rounded-xl border border-line bg-surface shadow-2xl"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
role="dialog"
|
||||||
|
aria-label={t.search.title}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 border-b border-line px-3">
|
||||||
|
<Search size={16} className="shrink-0 text-faint" />
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<kbd className="rounded border border-line px-1.5 py-0.5 text-[10px] text-faint">esc</kbd>
|
||||||
|
</div>
|
||||||
|
<ul className="max-h-80 overflow-y-auto py-1">
|
||||||
|
{hits.map((h, i) => (
|
||||||
|
<li key={`${h.kind}:${h.label}`}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => pick(h)}
|
||||||
|
onMouseEnter={() => setSel(i)}
|
||||||
|
className={`flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm ${i === sel ? 'bg-accent-glow text-accent' : 'text-ink'}`}
|
||||||
|
>
|
||||||
|
<span aria-hidden className="w-9 shrink-0">{h.flags.join(' ')}</span>
|
||||||
|
<span className="truncate font-medium">{h.label}</span>
|
||||||
|
<span className="ml-auto shrink-0 text-[11px] text-faint">{h.sub}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{q.trim().length >= 2 && hits.length === 0 && (
|
||||||
|
<li className="px-3 py-6 text-center text-sm text-faint">{t.search.noResults}</li>
|
||||||
|
)}
|
||||||
|
{q.trim().length < 2 && (
|
||||||
|
<li className="px-3 py-6 text-center text-sm text-faint">{t.search.hint}</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -102,6 +102,12 @@ export const de: Dict = {
|
|||||||
lineups: "Aufstellungen",
|
lineups: "Aufstellungen",
|
||||||
keyPlayers: "Schlüsselspieler (beste Torschützen aller Zeiten)",
|
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: {
|
notFound: {
|
||||||
message: "Ball im Aus — diese Seite gibt es nicht.",
|
message: "Ball im Aus — diese Seite gibt es nicht.",
|
||||||
backToLive: "Zurück zu Live",
|
backToLive: "Zurück zu Live",
|
||||||
|
|||||||
@@ -101,6 +101,12 @@ export const en = {
|
|||||||
lineups: "Lineups",
|
lineups: "Lineups",
|
||||||
keyPlayers: "Key players (all-time top scorers)",
|
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: {
|
notFound: {
|
||||||
message: "Off the pitch — this page doesn't exist.",
|
message: "Off the pitch — this page doesn't exist.",
|
||||||
backToLive: "Back to Live",
|
backToLive: "Back to Live",
|
||||||
|
|||||||
Reference in New Issue
Block a user