From 3bad4ace1047c3d5b898155251190fa081db214b Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sun, 26 Jul 2026 11:36:44 +0200 Subject: [PATCH] feat(ui): Schnellsuche mit Strg+K Palette ueber die ganze Anwendung: Projekte nach Name, Kurzbeschreibung und Tags, Notizen ueber die serverseitige Volltextsuche, dazu die Bereiche. Ohne Eingabe stehen die zuletzt bearbeiteten Projekte bereit - danach sucht man ueblicherweise. Bedienung ueber Tastatur, wie man es erwartet: Pfeile waehlen, Enter oeffnet, Escape schliesst, der ausgewaehlte Eintrag bleibt im Blick. Die Projektliste wird beim ersten Oeffnen geholt und dann behalten. Sie bei jedem Tastendruck neu zu laden waere Verschwendung, und veraltet ist sie hoechstens um die Dauer einer Sitzung. Notizen sucht dagegen der Server, entprellt ab zwei Zeichen - sie mitzuladen wuerde die Seite aufblaehen. Wie Menue und Dialog haengt die Palette per Portal am body, aus denselben Gruenden: overflow-hidden und backdrop-filter der Karten. Co-Authored-By: Claude Opus 5 (1M context) --- src/app/layout.tsx | 4 + src/components/CommandPalette.tsx | 380 ++++++++++++++++++++++++++++++ 2 files changed, 384 insertions(+) create mode 100644 src/components/CommandPalette.tsx diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 2e3b3c9..ca5ae48 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,4 +1,5 @@ import type { Metadata, Viewport } from "next"; +import { CommandPalette } from "@/components/CommandPalette"; import { TopNav } from "@/components/TopNav"; import { getSessionUser } from "@/lib/auth/session"; import { APP_NAME, APP_TAGLINE, APP_VERSION } from "@/lib/config"; @@ -45,6 +46,9 @@ export default async function RootLayout({ + {/* Nur fuer Angemeldete – auf der Anmeldeseite gibt es nichts zu suchen. */} + {user ? : null} +
{children}
diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx new file mode 100644 index 0000000..91acd79 --- /dev/null +++ b/src/components/CommandPalette.tsx @@ -0,0 +1,380 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { useRouter } from "next/navigation"; +import { + Bell, + CornerDownLeft, + GitBranch, + LayoutDashboard, + Loader2, + NotebookPen, + Plus, + Search, + Settings, + UserRound, +} from "lucide-react"; +import type { ProjectStatus } from "@prisma/client"; +import { statusOf } from "@/lib/status"; +import { cn } from "@/lib/utils"; + +/** + * Schnellsuche, zu erreichen mit Strg+K beziehungsweise Cmd+K. + * + * Springt zu einem Projekt, zu einer Notiz oder direkt in einen Bereich. + * Die Projektliste wird beim ersten Oeffnen geholt und dann behalten – sie + * bei jedem Tastendruck neu zu laden waere Verschwendung, und veraltet ist + * sie hoechstens um die Dauer einer Sitzung. + */ + +interface ProjectHit { + id: string; + name: string; + slug: string; + summary: string | null; + status: ProjectStatus; + tags: string[]; +} + +interface NoteHit { + projectId: string; + noteId: string; + title: string | null; + snippet: string; +} + +interface Action { + id: string; + label: string; + hint: string; + icon: typeof LayoutDashboard; + href: string; + adminOnly?: boolean; +} + +const ACTIONS: Action[] = [ + { id: "dashboard", label: "Dashboard", hint: "Alle Projekte", icon: LayoutDashboard, href: "/" }, + { + id: "connections", + label: "Git-Verbindungen", + hint: "GitHub, GitLab, Gitea", + icon: GitBranch, + href: "/connections", + }, + { id: "account", label: "Mein Konto", hint: "Profil, Passkeys", icon: UserRound, href: "/account" }, + { + id: "notify", + label: "Benachrichtigungen", + hint: "Ziele verwalten", + icon: Bell, + href: "/account", + }, + { + id: "admin", + label: "Admin-Einstellungen", + hint: "Betriebsart, Abgleich, Konten", + icon: Settings, + href: "/admin", + adminOnly: true, + }, +]; + +type Entry = + | { kind: "action"; key: string; action: Action } + | { kind: "project"; key: string; project: ProjectHit } + | { kind: "note"; key: string; note: NoteHit; projectName: string }; + +export function CommandPalette({ isAdmin }: { isAdmin: boolean }) { + const router = useRouter(); + const [mounted, setMounted] = useState(false); + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const [projects, setProjects] = useState(null); + const [notes, setNotes] = useState([]); + const [active, setActive] = useState(0); + const [loading, setLoading] = useState(false); + const listRef = useRef(null); + + useEffect(() => setMounted(true), []); + + // Strg+K oeffnet, Escape schliesst. In Eingabefeldern nicht stoeren – + // ausser beim Kuerzel selbst, das soll ueberall greifen. + useEffect(() => { + function onKey(event: KeyboardEvent) { + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") { + event.preventDefault(); + setOpen((value) => !value); + } else if (event.key === "Escape") { + setOpen(false); + } + } + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, []); + + // Projektliste beim ersten Oeffnen holen. + useEffect(() => { + if (!open || projects !== null) return; + setLoading(true); + fetch("/api/projects") + .then((response) => (response.ok ? response.json() : { projects: [] })) + .then((body) => setProjects(body.projects ?? [])) + .catch(() => setProjects([])) + .finally(() => setLoading(false)); + }, [open, projects]); + + // Notizen sucht der Server – entprellt, wie im Board. + useEffect(() => { + if (!open) return; + const needle = query.trim(); + if (needle.length < 2) { + setNotes([]); + return; + } + const controller = new AbortController(); + const timer = setTimeout(async () => { + try { + const response = await fetch(`/api/search?q=${encodeURIComponent(needle)}`, { + signal: controller.signal, + }); + if (!response.ok) return; + const body = (await response.json()) as { hits: NoteHit[] }; + setNotes(body.hits ?? []); + } catch { + // Abbruch beim Weitertippen ist der Normalfall. + } + }, 250); + return () => { + clearTimeout(timer); + controller.abort(); + }; + }, [open, query]); + + useEffect(() => { + if (!open) { + setQuery(""); + setNotes([]); + setActive(0); + } + }, [open]); + + const entries = useMemo(() => { + const needle = query.toLowerCase().trim(); + const result: Entry[] = []; + + const passendeAktionen = ACTIONS.filter( + (action) => + (!action.adminOnly || isAdmin) && + (!needle || + action.label.toLowerCase().includes(needle) || + action.hint.toLowerCase().includes(needle)), + ); + + const passendeProjekte = (projects ?? []).filter( + (project) => + !needle || + project.name.toLowerCase().includes(needle) || + (project.summary ?? "").toLowerCase().includes(needle) || + project.tags.some((tag) => tag.includes(needle)), + ); + + // Bei leerer Eingabe stehen die Projekte oben – danach sucht man + // normalerweise. + if (!needle) { + for (const project of passendeProjekte.slice(0, 6)) { + result.push({ kind: "project", key: `p-${project.id}`, project }); + } + for (const action of passendeAktionen) { + result.push({ kind: "action", key: `a-${action.id}`, action }); + } + return result; + } + + for (const project of passendeProjekte.slice(0, 8)) { + result.push({ kind: "project", key: `p-${project.id}`, project }); + } + for (const hit of notes.slice(0, 5)) { + result.push({ + kind: "note", + key: `n-${hit.noteId}`, + note: hit, + projectName: + (projects ?? []).find((project) => project.id === hit.projectId)?.name ?? "Projekt", + }); + } + for (const action of passendeAktionen) { + result.push({ kind: "action", key: `a-${action.id}`, action }); + } + return result; + }, [query, projects, notes, isAdmin]); + + useEffect(() => { + setActive((current) => Math.min(current, Math.max(0, entries.length - 1))); + }, [entries.length]); + + const go = useCallback( + (entry: Entry) => { + setOpen(false); + if (entry.kind === "action") router.push(entry.action.href); + else if (entry.kind === "project") router.push(`/projects/${entry.project.id}`); + else router.push(`/projects/${entry.note.projectId}`); + }, + [router], + ); + + function onInputKey(event: React.KeyboardEvent) { + if (event.key === "ArrowDown") { + event.preventDefault(); + setActive((current) => (current + 1) % Math.max(1, entries.length)); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + setActive((current) => (current - 1 + entries.length) % Math.max(1, entries.length)); + } else if (event.key === "Enter") { + event.preventDefault(); + const entry = entries[active]; + if (entry) go(entry); + } + } + + // Ausgewaehlten Eintrag im Blick behalten. + useEffect(() => { + listRef.current + ?.querySelector(`[data-index="${active}"]`) + ?.scrollIntoView({ block: "nearest" }); + }, [active]); + + if (!mounted || !open) return null; + + return createPortal( +
+ + )) + )} +
+ +
+ + ↑ + ↓ + waehlen + + + + + + oeffnen + + + Esc + schliessen + + + + Strg + K + +
+ + , + document.body, + ); +} + +function Kbd({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +}