learning_ai_notes/web/src/app/(app)/search/page.tsx

187 lines
7.7 KiB
TypeScript

"use client";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { Suspense, useCallback, useEffect, useMemo, useState } from "react";
import { AppShell } from "@/components/AppShell";
import { searchNoteSummaries } from "@/lib/notes-client";
import { listSavedViews, createSavedView, deleteSavedView, type SavedView } from "@/lib/saved-views-client";
import { useDebounce } from "@/lib/use-debounce";
import type { NoteSummary } from "@/lib/types";
export default function SearchPage() {
return (
<Suspense fallback={<AppShell title="Search" description="Search notes"><p>Loading...</p></AppShell>}>
<SearchPageInner />
</Suspense>
);
}
function SearchPageInner() {
const searchParams = useSearchParams();
const [notes, setNotes] = useState<NoteSummary[]>([]);
const [query, setQuery] = useState(() => searchParams?.get("q") ?? "");
const debouncedQuery = useDebounce(query, 250);
const [savedViewsList, setSavedViewsList] = useState<SavedView[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setQuery(searchParams?.get("q") ?? "");
}, [searchParams]);
useEffect(() => {
void (async () => {
try {
setNotes(await searchNoteSummaries(debouncedQuery));
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Unable to load notes");
}
})();
}, [debouncedQuery]);
useEffect(() => {
void (async () => {
try {
setSavedViewsList(await listSavedViews("search"));
} catch {
// Saved views are best-effort
}
})();
}, []);
const handleSaveCurrentSearch = useCallback(async () => {
if (!query.trim()) return;
try {
const view = await createSavedView({
id: crypto.randomUUID(),
name: query.trim().slice(0, 60),
scope: "search",
query: query.trim(),
});
setSavedViewsList((current) => [...current, view]);
} catch {
// Best-effort
}
}, [query]);
const handleDeleteSavedView = useCallback(async (id: string) => {
try {
await deleteSavedView(id);
setSavedViewsList((current) => current.filter((v) => v.id !== id));
} catch {
// Best-effort
}
}, []);
const savedViews = useMemo(() => savedViewsList.map((view) => ({
id: view.id,
name: view.name,
query: view.query,
resultCount: 0,
})), [savedViewsList]);
return (
<AppShell
title="Search"
description="Lexical search, tag filtering, and retrieval entry points. Semantic ranking and explainability remain follow-up work."
actions={<div className="badge">Backend-backed note search</div>}
>
<section style={{ display: "grid", gridTemplateColumns: "minmax(260px, 320px) minmax(0, 1fr)", gap: "var(--nl-space-4)" }}>
<aside className="surface-card" style={{ padding: "var(--nl-space-5)", display: "grid", gap: "var(--nl-space-4)" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<div style={{ fontWeight: 700 }}>Saved searches</div>
{query.trim() ? (
<button
type="button"
className="badge"
onClick={() => void handleSaveCurrentSearch()}
style={{ cursor: "pointer" }}
>
+ Save current
</button>
) : null}
</div>
<div style={{ display: "grid", gap: "var(--nl-space-3)" }}>
{savedViews.map((view) => (
<div
key={view.id}
className="surface-muted"
style={{ padding: "var(--nl-space-4)", display: "grid", gap: "var(--nl-space-2)" }}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "start" }}>
<Link href={`/search?q=${encodeURIComponent(view.query)}`} style={{ display: "grid", gap: "var(--nl-space-2)" }}>
<strong>{view.name}</strong>
<span style={{ color: "var(--nl-text-secondary)" }}>{view.query}</span>
</Link>
<button
type="button"
onClick={() => void handleDeleteSavedView(view.id)}
style={{ color: "var(--nl-text-secondary)", background: "none", border: "none", cursor: "pointer", fontSize: "0.75rem" }}
>
Remove
</button>
</div>
</div>
))}
{savedViews.length === 0 ? (
<span style={{ color: "var(--nl-text-secondary)", fontSize: "0.875rem" }}>No saved searches yet.</span>
) : null}
</div>
<div style={{ display: "grid", gap: "var(--nl-space-2)" }}>
<strong>Retrieval filters</strong>
<span style={{ color: "var(--nl-text-secondary)" }}>workspace:any</span>
<span style={{ color: "var(--nl-text-secondary)" }}>status:active + draft</span>
<span style={{ color: "var(--nl-text-secondary)" }}>relationship scope: linked + cited</span>
<span style={{ color: "var(--nl-text-secondary)" }}>explainability: matched fields</span>
</div>
</aside>
<section className="surface-card" style={{ padding: "var(--nl-space-6)", display: "grid", gap: "var(--nl-space-4)" }}>
<input
aria-label="Search notes"
className="input-shell"
placeholder="Search notes, tags, tasks, and linked context"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
<div style={{ display: "flex", gap: "var(--nl-space-2)", flexWrap: "wrap" }}>
<span className="badge">workspace:all</span>
<span className="badge">status:active</span>
<span className="badge">source:manual+agent</span>
<span className="badge">matched:title+body</span>
</div>
{error ? <div style={{ color: "var(--nl-text-secondary)" }}>{error}</div> : null}
<div style={{ display: "grid", gap: "var(--nl-space-3)" }}>
{notes.map((note) => (
<div key={note.id} className="surface-muted" style={{ padding: "var(--nl-space-4)", display: "grid", gap: "var(--nl-space-2)" }}>
<div style={{ display: "grid", gridTemplateColumns: "minmax(0, 1.5fr) repeat(3, minmax(100px, auto))", gap: "var(--nl-space-3)", alignItems: "start" }}>
<Link href={`/notes/${note.id}`} style={{ display: "grid", gap: "var(--nl-space-2)" }}>
<strong>{note.title}</strong>
<span style={{ color: "var(--nl-text-secondary)" }}>{note.excerpt}</span>
</Link>
<Link href={`/search?q=${encodeURIComponent(note.status)}`} style={{ color: "var(--nl-text-secondary)" }}>
{note.status}
</Link>
<span style={{ color: "var(--nl-text-secondary)" }}>{note.updatedBy}</span>
<Link href={`/search?q=${encodeURIComponent(note.workspaceId.replace("workspace-", ""))}`} style={{ color: "var(--nl-text-secondary)" }}>
{note.workspaceId.replace("workspace-", "")}
</Link>
</div>
<div style={{ display: "flex", gap: "var(--nl-space-2)", flexWrap: "wrap" }}>
{note.tags.map((tag) => (
<Link key={tag} href={`/search?q=${encodeURIComponent(tag)}`} className="badge">
#{tag}
</Link>
))}
</div>
</div>
))}
</div>
</section>
</section>
</AppShell>
);
}