"use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { useRouter } from "next/navigation"; import { Bell, CalendarRange, CheckSquare, 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: "rueckblick", label: "Wochenrückblick", hint: "Was diese Woche passiert ist", icon: CalendarRange, href: "/rueckblick", }, { id: "tasks", label: "Aufgaben", hint: "Über alle Projekte, nach Fälligkeit", icon: CheckSquare, href: "/tasks", }, { 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} ); }