diff --git a/prisma/migrations/20260726090000_note_fulltext/migration.sql b/prisma/migrations/20260726090000_note_fulltext/migration.sql new file mode 100644 index 0000000..4435db8 --- /dev/null +++ b/prisma/migrations/20260726090000_note_fulltext/migration.sql @@ -0,0 +1,16 @@ +-- Volltextsuche ueber Notizen. +-- +-- Dies ist ein Ausdrucksindex. Prisma kann so etwas im Schema nicht abbilden +-- und kennt ihn deshalb nicht. Nachgeprueft: "prisma migrate diff" laesst ihn +-- in Ruhe und schlaegt kein DROP vor - Ausdrucksindizes werden schlicht +-- ignoriert. Trotzdem beim Erzeugen kuenftiger Migrationen kurz hinsehen. +-- +-- Faellt er doch einmal weg, funktioniert die Suche weiter, nur langsamer: +-- Postgres berechnet to_tsvector dann je Zeile. +-- +-- Der Ausdruck muss mit dem in src/app/api/search/route.ts uebereinstimmen, +-- sonst greift der Index nicht. Die zweistellige Form von to_tsvector mit +-- fester Konfiguration ist IMMUTABLE und damit indizierbar. + +CREATE INDEX "Note_fulltext_idx" ON "Note" + USING GIN (to_tsvector('german', coalesce("title", '') || ' ' || "content")); diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts new file mode 100644 index 0000000..960c636 --- /dev/null +++ b/src/app/api/search/route.ts @@ -0,0 +1,67 @@ +import { NextResponse } from "next/server"; +import { requireUserApi } from "@/lib/auth/guard"; +import { prisma } from "@/lib/db"; + +export const dynamic = "force-dynamic"; + +interface Hit { + projectId: string; + noteId: string; + title: string | null; + /** Textausschnitt mit hervorgehobener Fundstelle. */ + snippet: string; +} + +/** + * Volltextsuche ueber Notizen. + * + * Die Suche im Board arbeitet auf den bereits geladenen Projekten und deckt + * Name, Kurzbeschreibung und Tags ab. Notizen liegen dort nicht vor – die + * mitzuladen wuerde die Seite unnoetig aufblaehen. Deshalb dieser Endpunkt: + * Postgres sucht mit seiner deutschen Textsuche direkt in der Datenbank. + * + * ts_headline liefert gleich den passenden Ausschnitt mit; die Hervorhebung + * kommt als [[…]] zurueck, damit die Oberflaeche kein HTML einbetten muss. + */ +export async function GET(request: Request) { + const auth = await requireUserApi(); + if (!auth.ok) return auth.response; + + const query = (new URL(request.url).searchParams.get("q") ?? "").trim(); + if (query.length < 2) { + return NextResponse.json({ query, hits: [], projectIds: [] }); + } + + // websearch_to_tsquery versteht Eingaben wie "docker -compose" oder + // Anfuehrungszeichen und wirft bei Unsinn keinen Fehler. + const hits = await prisma.$queryRaw` + SELECT + n."projectId" AS "projectId", + n."id" AS "noteId", + n."title" AS "title", + ts_headline( + 'german', + n."content", + websearch_to_tsquery('german', ${query}), + 'StartSel=[[, StopSel=]], MaxWords=18, MinWords=6, ShortWord=2, MaxFragments=1' + ) AS "snippet" + FROM "Note" n + JOIN "Project" p ON p."id" = n."projectId" + WHERE p."ownerId" = ${auth.user.id} + AND to_tsvector('german', coalesce(n."title", '') || ' ' || n."content") + @@ websearch_to_tsquery('german', ${query}) + ORDER BY ts_rank( + to_tsvector('german', coalesce(n."title", '') || ' ' || n."content"), + websearch_to_tsquery('german', ${query}) + ) DESC + LIMIT 50 + `; + + return NextResponse.json({ + query, + hits, + // Die Oberflaeche braucht vor allem die betroffenen Projekte, um sie im + // Board einzublenden. + projectIds: Array.from(new Set(hits.map((hit) => hit.projectId))), + }); +} diff --git a/src/components/ProjectBoard.tsx b/src/components/ProjectBoard.tsx index b6029c7..74d4c48 100644 --- a/src/components/ProjectBoard.tsx +++ b/src/components/ProjectBoard.tsx @@ -1,11 +1,14 @@ "use client"; -import { useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import Link from "next/link"; import { + Columns3, Database, Download, LayoutGrid, ListFilter, + NotebookPen, Plus, Rows3, Search, @@ -14,6 +17,7 @@ import { } 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"; @@ -21,8 +25,34 @@ 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"; +type ViewMode = "grid" | "grouped" | "kanban"; const SORT_LABEL: Record = { updated: "Zuletzt geaendert", @@ -48,6 +78,45 @@ export function ProjectBoard({ 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(); @@ -71,7 +140,9 @@ export function ProjectBoard({ (project.summary ?? "").toLowerCase().includes(needle) || (project.description ?? "").toLowerCase().includes(needle) || project.tags.some((tag) => tag.includes(needle)) || - (project.snapshot?.fullName ?? "").toLowerCase().includes(needle) + (project.snapshot?.fullName ?? "").toLowerCase().includes(needle) || + // Treffer in Notizen kommen vom Server dazu. + noteHitProjectIds.has(project.id) ); }); @@ -92,7 +163,7 @@ export function ProjectBoard({ }); return sorted; - }, [projects, query, statusFilter, sort, showArchived]); + }, [projects, query, statusFilter, sort, showArchived, noteHitProjectIds]); const grouped = useMemo(() => { return STATUS_ORDER.map((status) => ({ @@ -178,6 +249,18 @@ export function ProjectBoard({ > +