feat(search): Volltextsuche ueber Notizen
Die Suche im Board arbeitet auf den bereits geladenen Projekten und deckt Name, Kurzbeschreibung, Tags und Repo-Pfad ab. Notizen liegen dort nicht vor - sie mitzuladen wuerde die Seite unnoetig aufblaehen. Deshalb ein eigener Endpunkt, der Postgres mit seiner deutschen Textsuche arbeiten laesst. - websearch_to_tsquery versteht Eingaben wie "docker -compose" und wirft bei Unsinn keinen Fehler - ts_headline liefert den Ausschnitt gleich mit; die Hervorhebung kommt als [[…]] zurueck, damit die Oberflaeche kein HTML aus der Datenbank einbetten muss - deutsche Stammformen greifen: "Webhook" findet auch "Webhooks" - entprellt ab zwei Zeichen, laufende Abfragen werden abgebrochen Dazu ein GIN-Index auf dem Suchausdruck. Prisma kann Ausdrucksindizes im Schema nicht abbilden - nachgeprueft: "migrate diff" laesst ihn in Ruhe und schlaegt kein DROP vor. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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"));
|
||||||
@@ -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<Hit[]>`
|
||||||
|
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))),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
import {
|
import {
|
||||||
|
Columns3,
|
||||||
Database,
|
Database,
|
||||||
Download,
|
Download,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
ListFilter,
|
ListFilter,
|
||||||
|
NotebookPen,
|
||||||
Plus,
|
Plus,
|
||||||
Rows3,
|
Rows3,
|
||||||
Search,
|
Search,
|
||||||
@@ -14,6 +17,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { ProjectStatus } from "@prisma/client";
|
import type { ProjectStatus } from "@prisma/client";
|
||||||
import { ImportDialog } from "@/components/ImportDialog";
|
import { ImportDialog } from "@/components/ImportDialog";
|
||||||
|
import { KanbanBoard } from "@/components/KanbanBoard";
|
||||||
import { ProjectCard } from "@/components/ProjectCard";
|
import { ProjectCard } from "@/components/ProjectCard";
|
||||||
import { ProjectDialog } from "@/components/ProjectDialog";
|
import { ProjectDialog } from "@/components/ProjectDialog";
|
||||||
import { Popover } from "@/components/ui/Popover";
|
import { Popover } from "@/components/ui/Popover";
|
||||||
@@ -21,8 +25,34 @@ import { STATUS_META, STATUS_ORDER } from "@/lib/status";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ConnectionSafe, ProjectListItem } from "@/types";
|
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 (
|
||||||
|
<mark key={index} className="rounded bg-violet-500/25 px-0.5 text-violet-100">
|
||||||
|
{part.slice(2, -2)}
|
||||||
|
</mark>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <span key={index}>{part}</span>;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
type SortKey = "updated" | "created" | "name" | "progress" | "priority";
|
type SortKey = "updated" | "created" | "name" | "progress" | "priority";
|
||||||
type ViewMode = "grid" | "grouped";
|
type ViewMode = "grid" | "grouped" | "kanban";
|
||||||
|
|
||||||
const SORT_LABEL: Record<SortKey, string> = {
|
const SORT_LABEL: Record<SortKey, string> = {
|
||||||
updated: "Zuletzt geaendert",
|
updated: "Zuletzt geaendert",
|
||||||
@@ -48,6 +78,45 @@ export function ProjectBoard({
|
|||||||
const [importOpen, setImportOpen] = useState(false);
|
const [importOpen, setImportOpen] = useState(false);
|
||||||
const [dataMenuOpen, setDataMenuOpen] = useState(false);
|
const [dataMenuOpen, setDataMenuOpen] = useState(false);
|
||||||
const dataButtonRef = useRef<HTMLButtonElement>(null);
|
const dataButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const [noteHits, setNoteHits] = useState<NoteHit[]>([]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 counts = useMemo(() => {
|
||||||
const map = new Map<ProjectStatus, number>();
|
const map = new Map<ProjectStatus, number>();
|
||||||
@@ -71,7 +140,9 @@ export function ProjectBoard({
|
|||||||
(project.summary ?? "").toLowerCase().includes(needle) ||
|
(project.summary ?? "").toLowerCase().includes(needle) ||
|
||||||
(project.description ?? "").toLowerCase().includes(needle) ||
|
(project.description ?? "").toLowerCase().includes(needle) ||
|
||||||
project.tags.some((tag) => tag.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;
|
return sorted;
|
||||||
}, [projects, query, statusFilter, sort, showArchived]);
|
}, [projects, query, statusFilter, sort, showArchived, noteHitProjectIds]);
|
||||||
|
|
||||||
const grouped = useMemo(() => {
|
const grouped = useMemo(() => {
|
||||||
return STATUS_ORDER.map((status) => ({
|
return STATUS_ORDER.map((status) => ({
|
||||||
@@ -178,6 +249,18 @@ export function ProjectBoard({
|
|||||||
>
|
>
|
||||||
<Rows3 className="h-4 w-4" />
|
<Rows3 className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setView("kanban")}
|
||||||
|
className={cn(
|
||||||
|
"p-2 transition-colors",
|
||||||
|
view === "kanban" ? "bg-white/10 text-white" : "text-slate-500 hover:text-white",
|
||||||
|
)}
|
||||||
|
aria-label="Kanban-Spalten"
|
||||||
|
title="Kanban – Status per Ziehen aendern"
|
||||||
|
>
|
||||||
|
<Columns3 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -256,9 +339,54 @@ export function ProjectBoard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Treffer in Notizen */}
|
||||||
|
{noteHits.length > 0 ? (
|
||||||
|
<div className="glass fade-up p-4">
|
||||||
|
<h2 className="mb-2.5 flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wider text-slate-400">
|
||||||
|
<NotebookPen className="h-3.5 w-3.5 text-violet-300" />
|
||||||
|
Treffer in Notizen
|
||||||
|
<span className="rounded-full bg-white/6 px-2 py-0.5 tabular-nums text-slate-400">
|
||||||
|
{noteHits.length}
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{noteHits.slice(0, 8).map((hit) => {
|
||||||
|
const project = projects.find((entry) => entry.id === hit.projectId);
|
||||||
|
return (
|
||||||
|
<li key={hit.noteId}>
|
||||||
|
<Link
|
||||||
|
href={`/projects/${hit.projectId}`}
|
||||||
|
className="block rounded-lg px-2.5 py-2 transition-colors hover:bg-white/5"
|
||||||
|
>
|
||||||
|
<span className="flex flex-wrap items-baseline gap-x-2">
|
||||||
|
<span className="text-sm text-slate-200">
|
||||||
|
{project?.name ?? "Projekt"}
|
||||||
|
</span>
|
||||||
|
{hit.title ? (
|
||||||
|
<span className="text-[11px] text-slate-500">· {hit.title}</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<span className="mt-0.5 block text-xs leading-snug text-slate-400">
|
||||||
|
{highlight(hit.snippet)}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
{noteHits.length > 8 ? (
|
||||||
|
<p className="mt-2 px-2.5 text-[11px] text-slate-600">
|
||||||
|
… und {noteHits.length - 8} weitere
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* Inhalt */}
|
{/* Inhalt */}
|
||||||
{visible.length === 0 ? (
|
{visible.length === 0 ? (
|
||||||
<EmptyState hasFilters={hasFilters} onCreate={() => setDialogOpen(true)} />
|
<EmptyState hasFilters={hasFilters} onCreate={() => setDialogOpen(true)} />
|
||||||
|
) : view === "kanban" ? (
|
||||||
|
<KanbanBoard projects={visible} showArchived={showArchived} />
|
||||||
) : view === "grid" ? (
|
) : view === "grid" ? (
|
||||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||||
{visible.map((project, index) => (
|
{visible.map((project, index) => (
|
||||||
|
|||||||
Reference in New Issue
Block a user