Phase 1: Command palette (⌘K), editor autosave with quiet auto-saves, dashboard saved views from API + quick links + onboarding seed CTA, explicit task scan panel. Phase 2: Context pack formatter with YAML frontmatter, copy on note + workspace .md export. Phase 3: ADR for hybrid search without embeddings; POST /notes/search (lexical + ranked hybrid); search UI mode toggle. Phase 4: POST copilot + suggest-title; in-editor copilot actions; /chat retrieval answers with citations (backend chat.rag_enabled). Phase 5: Settings MCP snippet, offline queue note, API token deferral; DEEP_LINKS.md. Phase 6: Note shares + public GET; share page; POST onboarding-seed. Phase 7: note_versions on PATCH; version panel; create-note templates; PWA manifest. Flags: search.hybrid_enabled, copilot.enabled, chat.rag_enabled, onboarding.seed_enabled. Made-with: Cursor
194 lines
7.4 KiB
TypeScript
194 lines
7.4 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 { searchNotesRanked, type SearchRankedHit } from "@/lib/notes-client";
|
|
import { listSavedViews, createSavedView, deleteSavedView, type SavedView } from "@/lib/saved-views-client";
|
|
import { useDebounce } from "@/lib/use-debounce";
|
|
|
|
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 [hits, setHits] = useState<SearchRankedHit[]>([]);
|
|
const [query, setQuery] = useState(() => searchParams?.get("q") ?? "");
|
|
const debouncedQuery = useDebounce(query, 250);
|
|
const [mode, setMode] = useState<"lexical" | "hybrid">("hybrid");
|
|
const [savedViewsList, setSavedViewsList] = useState<SavedView[]>([]);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
setQuery(searchParams?.get("q") ?? "");
|
|
}, [searchParams]);
|
|
|
|
useEffect(() => {
|
|
void (async () => {
|
|
try {
|
|
const res = await searchNotesRanked(debouncedQuery, mode);
|
|
setHits(res.items);
|
|
setError(null);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Unable to load notes");
|
|
}
|
|
})();
|
|
}, [debouncedQuery, mode]);
|
|
|
|
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 and hybrid ranked search with match hints (title, body, tag). Toggle modes to compare behavior."
|
|
actions={<div className="badge">POST /notes/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={() => {
|
|
if (window.confirm("Remove this saved view?")) 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>Search mode</strong>
|
|
<label style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
<input type="radio" name="smode" checked={mode === "hybrid"} onChange={() => setMode("hybrid")} />
|
|
Hybrid ranked (default)
|
|
</label>
|
|
<label style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
<input type="radio" name="smode" checked={mode === "lexical"} onChange={() => setMode("lexical")} />
|
|
Lexical only
|
|
</label>
|
|
</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, and body text"
|
|
value={query}
|
|
onChange={(event) => setQuery(event.target.value)}
|
|
/>
|
|
<div style={{ display: "flex", gap: "var(--nl-space-2)", flexWrap: "wrap" }}>
|
|
<span className="badge">mode:{mode}</span>
|
|
<span className="badge">{hits.length} hits</span>
|
|
</div>
|
|
{error ? <div style={{ color: "var(--nl-text-secondary)" }}>{error}</div> : null}
|
|
<div style={{ display: "grid", gap: "var(--nl-space-3)" }}>
|
|
{hits.map((hit) => (
|
|
<div key={hit.noteId} className="surface-muted" style={{ padding: "var(--nl-space-4)", display: "grid", gap: "var(--nl-space-2)" }}>
|
|
<div style={{ display: "flex", justifyContent: "space-between", gap: "var(--nl-space-3)", flexWrap: "wrap" }}>
|
|
<Link href={`/notes/${hit.noteId}`} style={{ fontWeight: 700 }}>
|
|
{hit.title}
|
|
</Link>
|
|
<span className="badge">
|
|
{hit.matchKind} · score {hit.score}
|
|
</span>
|
|
</div>
|
|
<div style={{ color: "var(--nl-text-secondary)", fontSize: "var(--nl-fs-sm)" }}>{hit.snippet}</div>
|
|
<Link href={`/search?q=${encodeURIComponent(hit.workspaceId)}`} style={{ color: "var(--nl-text-secondary)", fontSize: "var(--nl-fs-sm)" }}>
|
|
workspace: {hit.workspaceId}
|
|
</Link>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
</section>
|
|
</AppShell>
|
|
);
|
|
}
|