187 lines
7.7 KiB
TypeScript
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>
|
|
);
|
|
}
|