"use client"; import { useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; import { Columns3, Database, Download, LayoutGrid, ListFilter, NotebookPen, Plus, Rows3, Search, Upload, X, } from "lucide-react"; import type { ProjectStatus } from "@prisma/client"; import { ImportDialog } from "@/components/ImportDialog"; import { KanbanBoard } from "@/components/KanbanBoard"; import { ProjectCard } from "@/components/ProjectCard"; import { ProjectDialog } from "@/components/ProjectDialog"; import { Popover } from "@/components/ui/Popover"; import { STATUS_META, STATUS_ORDER } from "@/lib/status"; import { cn } from "@/lib/utils"; import type { ConnectionSafe, ProjectListItem } from "@/types"; interface NoteHit { projectId: string; noteId: string; title: string | null; /** Ausschnitt mit [[…]] um die Fundstelle – siehe /api/search. */ snippet: string; } /** * Wandelt die [[…]]-Markierungen von ts_headline in hervorgehobene Stellen. * Bewusst kein HTML aus der Datenbank einbetten – der Text stammt aus einer * Notiz und damit vom Benutzer. */ function highlight(snippet: string): React.ReactNode[] { return snippet.split(/(\[\[.*?\]\])/g).map((part, index) => { if (part.startsWith("[[") && part.endsWith("]]")) { return ( {part.slice(2, -2)} ); } return {part}; }); } type SortKey = "updated" | "created" | "name" | "progress" | "priority"; type ViewMode = "grid" | "grouped" | "kanban"; const SORT_LABEL: Record = { updated: "Zuletzt geaendert", created: "Neueste zuerst", name: "Name (A–Z)", progress: "Fortschritt", priority: "Prioritaet", }; export function ProjectBoard({ projects, connections, }: { projects: ProjectListItem[]; connections: ConnectionSafe[]; }) { const [query, setQuery] = useState(""); const [statusFilter, setStatusFilter] = useState([]); const [sort, setSort] = useState("updated"); const [view, setView] = useState("grid"); const [showArchived, setShowArchived] = useState(false); const [dialogOpen, setDialogOpen] = useState(false); const [importOpen, setImportOpen] = useState(false); const [dataMenuOpen, setDataMenuOpen] = useState(false); const dataButtonRef = useRef(null); const [noteHits, setNoteHits] = useState([]); /** * Notizen liegen im Board nicht vor – sie mitzuladen wuerde die Seite * unnoetig aufblaehen. Stattdessen sucht Postgres serverseitig, sobald der * Begriff lang genug ist. Entprellt, damit nicht jeder Tastendruck eine * Abfrage ausloest. */ useEffect(() => { const needle = query.trim(); if (needle.length < 2) { setNoteHits([]); 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[] }; setNoteHits(body.hits ?? []); } catch { // Abgebrochene Anfragen sind der Normalfall beim Tippen. } }, 300); return () => { clearTimeout(timer); controller.abort(); }; }, [query]); const noteHitProjectIds = useMemo( () => new Set(noteHits.map((hit) => hit.projectId)), [noteHits], ); const counts = useMemo(() => { const map = new Map(); for (const project of projects) { map.set(project.status, (map.get(project.status) ?? 0) + 1); } return map; }, [projects]); const visible = useMemo(() => { const needle = query.toLowerCase().trim(); const filtered = projects.filter((project) => { if (!showArchived && project.status === "ARCHIVED" && !statusFilter.includes("ARCHIVED")) { return false; } if (statusFilter.length > 0 && !statusFilter.includes(project.status)) return false; if (!needle) return true; return ( project.name.toLowerCase().includes(needle) || (project.summary ?? "").toLowerCase().includes(needle) || (project.description ?? "").toLowerCase().includes(needle) || project.tags.some((tag) => tag.includes(needle)) || (project.snapshot?.fullName ?? "").toLowerCase().includes(needle) || // Treffer in Notizen kommen vom Server dazu. noteHitProjectIds.has(project.id) ); }); const sorted = [...filtered].sort((a, b) => { if (a.favorite !== b.favorite) return a.favorite ? -1 : 1; switch (sort) { case "name": return a.name.localeCompare(b.name, "de"); case "progress": return b.progress - a.progress; case "priority": return b.priority - a.priority; case "created": return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); default: return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(); } }); return sorted; }, [projects, query, statusFilter, sort, showArchived, noteHitProjectIds]); const grouped = useMemo(() => { return STATUS_ORDER.map((status) => ({ status, items: visible.filter((project) => project.status === status), })).filter((group) => group.items.length > 0); }, [visible]); function toggleStatus(status: ProjectStatus) { setStatusFilter((current) => current.includes(status) ? current.filter((entry) => entry !== status) : [...current, status], ); } const hasFilters = query.length > 0 || statusFilter.length > 0; return (
{/* Steuerleiste */}
setQuery(event.target.value)} /> {query ? ( ) : null}
Status {STATUS_ORDER.map((status) => { const meta = STATUS_META[status]; const active = statusFilter.includes(status); const count = counts.get(status) ?? 0; return ( ); })} {hasFilters ? ( ) : null}
{/* Treffer in Notizen */} {noteHits.length > 0 ? (

Treffer in Notizen {noteHits.length}

    {noteHits.slice(0, 8).map((hit) => { const project = projects.find((entry) => entry.id === hit.projectId); return (
  • {project?.name ?? "Projekt"} {hit.title ? ( · {hit.title} ) : null} {highlight(hit.snippet)}
  • ); })}
{noteHits.length > 8 ? (

… und {noteHits.length - 8} weitere

) : null}
) : null} {/* Inhalt */} {visible.length === 0 ? ( setDialogOpen(true)} /> ) : view === "kanban" ? ( ) : view === "grid" ? (
{visible.map((project, index) => ( ))}
) : (
{grouped.map((group) => { const meta = STATUS_META[group.status]; return (

{meta.label}

{group.items.length}
{group.items.map((project, index) => ( ))}
); })}
)} setDataMenuOpen(false)} width={260} > setDataMenuOpen(false)} className="flex items-start gap-2.5 rounded-lg px-3 py-2 text-sm text-slate-300 transition-colors hover:bg-white/6 hover:text-white" > Alle Projekte exportieren JSON mit Notizen, ohne Access Tokens setDialogOpen(false)} connections={connections} /> setImportOpen(false)} />
); } function EmptyState({ hasFilters, onCreate, }: { hasFilters: boolean; onCreate: () => void; }) { return (

{hasFilters ? "Keine Treffer" : "Noch keine Projekte"}

{hasFilters ? "Kein Projekt passt zu Suche und Filtern. Filter anpassen oder zuruecksetzen." : "Leg deine erste Idee an – Beschreibung, Repo und Status kannst du jederzeit nachziehen."}

{!hasFilters ? ( ) : null}
); }