"use client"; import { useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; import { CheckSquare, Columns3, Database, Download, LayoutGrid, List, ListFilter, NotebookPen, Plus, Rows3, Search, Upload, X, } from "lucide-react"; import type { ProjectStatus, TaskStatus } from "@prisma/client"; import { BulkBar } from "@/components/BulkBar"; import { ImportDialog } from "@/components/ImportDialog"; import { KanbanBoard } from "@/components/KanbanBoard"; import { ProjectCard } from "@/components/ProjectCard"; import { ProjectDialog } from "@/components/ProjectDialog"; import { ProjectList } from "@/components/ProjectList"; import { Popover } from "@/components/ui/Popover"; import { STATUS_META, STATUS_ORDER } from "@/lib/status"; import { TASK_STATUS_META } from "@/lib/tasks"; import { cn } from "@/lib/utils"; import type { ConnectionSafe, ProjectListItem } from "@/types"; interface TaskHit { projectId: string; taskId: string; title: string; status: string; snippet: string; } 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" | "list" | "grouped" | "kanban"; const VIEWS: ViewMode[] = ["grid", "list", "grouped", "kanban"]; /** Reihenfolge der Umschalter – zugleich die Beschriftung der Knoepfe. */ const VIEW_META: Record = { grid: { label: "Raster", title: "Rasteransicht", icon: LayoutGrid }, list: { label: "Liste", title: "Kompakte Liste", icon: List }, grouped: { label: "Gruppiert", title: "Nach Status gruppiert", icon: Rows3 }, kanban: { label: "Brett", title: "Kanban – Status per Ziehen aendern", icon: Columns3 }, }; /** Wo Ansicht, Sortierung und Archivschalter im Browser liegen. */ const ANSICHT_SCHLUESSEL = "nebula.board"; 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); /** * Ansicht und Sortierung ueberdauern das Neuladen. * * Wer sein Brett von Hand anordnet, will es beim naechsten Aufruf auch * sehen und nicht wieder das Raster. Bewusst erst nach dem ersten Rendern * gelesen: der Server kennt den gespeicherten Wert nicht, und ein * Unterschied zwischen beiden waere ein Hydrationsfehler. * * Der Suchbegriff bleibt aussen vor – eine Suche von gestern beim Oeffnen * wiederherzustellen waere eher verwirrend als hilfreich. */ useEffect(() => { try { const gespeichert = window.localStorage.getItem(ANSICHT_SCHLUESSEL); if (!gespeichert) return; const wert = JSON.parse(gespeichert) as Partial<{ view: ViewMode; sort: SortKey; showArchived: boolean; }>; if (wert.view && VIEWS.includes(wert.view)) setView(wert.view); if (wert.sort && wert.sort in SORT_LABEL) setSort(wert.sort); if (typeof wert.showArchived === "boolean") setShowArchived(wert.showArchived); } catch { // Kaputter oder gesperrter Speicher: dann eben die Voreinstellung. } }, []); useEffect(() => { try { window.localStorage.setItem( ANSICHT_SCHLUESSEL, JSON.stringify({ view, sort, showArchived }), ); } catch { // Im privaten Modus mancher Browser nicht beschreibbar – kein Grund, // die Seite scheitern zu lassen. } }, [view, sort, showArchived]); const [dialogOpen, setDialogOpen] = useState(false); const [importOpen, setImportOpen] = useState(false); const [dataMenuOpen, setDataMenuOpen] = useState(false); const dataButtonRef = useRef(null); const [noteHits, setNoteHits] = useState([]); const [taskHits, setTaskHits] = useState([]); const [selection, setSelection] = useState>(new Set()); /** * 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([]); setTaskHits([]); 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[]; tasks: TaskHit[] }; setNoteHits(body.hits ?? []); setTaskHits(body.tasks ?? []); } 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), ...taskHits.map((hit) => hit.projectId), ]), [noteHits, taskHits], ); 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.repositories.some((repo) => (repo.snapshot?.fullName ?? "").toLowerCase().includes(needle), ) || // Treffer in Notizen und Aufgaben 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]); /** * Was der Filter ausblendet, bleibt nicht ausgewaehlt. Sonst traefe eine * Massenaenderung Projekte, die man gar nicht mehr vor Augen hat. */ useEffect(() => { setSelection((current) => { if (current.size === 0) return current; const sichtbar = new Set(visible.map((project) => project.id)); const next = new Set([...current].filter((id) => sichtbar.has(id))); return next.size === current.size ? current : next; }); }, [visible]); function toggleSelect(id: string, selected: boolean) { setSelection((current) => { const next = new Set(current); if (selected) next.add(id); else next.delete(id); return next; }); } 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. Auf dem Telefon steht die Suche allein in der ersten Zeile und die Werkzeuge darunter. Frueher lagen beide in einer Zeile, die nicht umbrechen konnte – der Knopf "Neues Projekt" wurde dabei aus dem Bild geschoben und die ganze Seite seitlich scrollbar. */}
setQuery(event.target.value)} /> {query ? ( ) : null}
{/* min-w-0 ist hier entscheidend: ohne das weigert sich ein Flex-Kind, unter seine Inhaltsbreite zu schrumpfen, und drueckt stattdessen die Nachbarn hinaus. */}
{/* Ansichtsumschalter: auf schmalen Anzeigen ueber die volle Breite, damit die vier Knoepfe gross genug zum Treffen bleiben. */}
{VIEWS.map((mode) => { const { label, title, icon: Icon } = VIEW_META[mode]; const active = view === mode; return ( ); })}
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 Aufgaben */} {taskHits.length > 0 ? (

Treffer in Aufgaben {taskHits.length}

    {taskHits.slice(0, 8).map((hit) => { const project = projects.find((entry) => entry.id === hit.projectId); const status = TASK_STATUS_META[hit.status as TaskStatus]; return (
  • {hit.title} · {project?.name ?? "Projekt"} {status ? ( {status.label} ) : null} {/* Nur wenn die Beschreibung etwas beitraegt – passte allein der Titel, ist der Ausschnitt leer. */} {hit.snippet.trim() ? ( {highlight(hit.snippet)} ) : null}
  • ); })}
{taskHits.length > 8 ? (

… und {taskHits.length - 8} weitere

) : null}
) : 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 === "list" ? ( ) : 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) => ( ))}
); })}
)} {selection.size > 0 ? ( setSelection(new Set())} onClear={() => setSelection(new Set())} /> ) : null} 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}
); }