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} + + ); +}