1"use client";
2
3
25import { useCallback, useEffect, useRef, useState } from "react";
26import { useRouter } from "next/navigation";
27import {
28 Bookmark,
29 BookmarkPlus,
30 Clock,
31 Database as DatabaseIcon,
32 FileText,
33 Filter,
34 History,
35 ListTodo,
36 Loader2,
37 Search,
38 SearchX,
39 ShieldAlert,
40 Square,
41 X
42} from "lucide-react";
43import { searchApi, meApi, pageApi, databaseApi, isApiError } from "@/lib/api";
44import {
45 SEARCH_DEBOUNCE_MS,
46 SEARCH_TYPES,
47 SEARCH_TYPE_LABELS,
48 addRecent,
49 isRecordableQuery,
50 deepLinkFor,
51 RECENT_VIEWED_CAP,
52 recentItemHref,
53 savedSearchLabel,
54 type RecentViewItem,
55 type SavedSearch,
56 type SavedSearchQuery
57} from "@/lib/search";
58import { useWorkspace } from "@/hooks/use-workspace";
59import { useUiStore } from "@/stores/ui-store";
60import { ResultsList, SEARCH_TYPE_ICON } from "./results-list";
61import type { SearchResult, SearchSuggestion, SearchType } from "@/lib/types";
62
63type FilterValue = "all" | SearchType;
64
65const FILTER_OPTIONS: { value: FilterValue; label: string }[] = [
66 { value: "all", label: "All" },
67 ...SEARCH_TYPES.map((t) => ({ value: t as FilterValue, label: SEARCH_TYPE_LABELS[t] }))
68];
69
70export function SearchDialog() {
71 const open = useUiStore((s) => s.searchOpen);
72 const setOpen = useUiStore((s) => s.setSearchDialog);
73 const router = useRouter();
74 const workspaceSlug = useUiStore((s) => s.searchWorkspaceSlug);
75 const { data: workspace, isError: workspaceError } = useWorkspace(workspaceSlug);
76
77 const [input, setInput] = useState("");
78 const [q, setQ] = useState("");
79 const [filter, setFilter] = useState<FilterValue>("all");
80 const [filterMenuOpen, setFilterMenuOpen] = useState(false);
81 const [results, setResults] = useState<SearchResult[]>([]);
82 const [loading, setLoading] = useState(false);
83 const [error, setError] = useState<ErrorKind>(null);
84 const [suggestions, setSuggestions] = useState<SearchSuggestion[]>([]);
85 const [recent, setRecent] = useState<string[]>([]);
86
87
88 const [recentViewed, setRecentViewed] = useState<RecentViewItem[]>([]);
89 const [viewTitles, setViewTitles] = useState<Record<string, string>>({});
90 const [savedSearches, setSavedSearches] = useState<SavedSearch[]>([]);
91
92 const [retryTick, setRetryTick] = useState(0);
93
94 const inputRef = useRef<HTMLInputElement>(null);
95 const dialogRef = useRef<HTMLDivElement>(null);
96 const filterMenuRef = useRef<HTMLDivElement>(null);
97 const lastRecordAt = useRef(0);
98 const reqRef = useRef(0);
99 const retryRef = useRef<{ key: string; count: number } | null>(null);
100
101
102
103
104 useEffect(() => {
105 if (open) {
106 setInput("");
107 setQ("");
108 setFilter("all");
109 setFilterMenuOpen(false);
110 setResults([]);
111 setSuggestions([]);
112 setError(null);
113 setLoading(false);
114 setViewTitles({});
115 requestAnimationFrame(() => inputRef.current?.focus());
116 }
117 }, [open]);
118
119
120
121 useEffect(() => {
122 if (!open) return;
123 let cancelled = false;
124
125 searchApi
126 .recent()
127 .then((list) => {
128 if (!cancelled) setRecent(list);
129 })
130 .catch(() => {
131
132 });
133
134 meApi
135 .recentViewed()
136 .then((items) => {
137 if (!cancelled) setRecentViewed(items.slice(0, RECENT_VIEWED_CAP));
138 })
139 .catch(() => {
140
141 });
142
143 meApi
144 .listSavedSearches()
145 .then((list) => {
146 if (!cancelled) setSavedSearches(list);
147 })
148 .catch(() => {
149
150 });
151 return () => {
152 cancelled = true;
153 };
154 }, [open]);
155
156
157
158
159 useEffect(() => {
160 if (recentViewed.length === 0) return;
161 let cancelled = false;
162 void Promise.all(
163 recentViewed.map(async (it): Promise<[string, string]> => {
164 try {
165 let t: string;
166 if (it.itemType === "database") {
167
168
169
170
171 const db = (await databaseApi.get(it.refId)) as {
172 title?: string;
173 database?: { title?: string };
174 };
175 t = db.title ?? db.database?.title ?? "";
176 } else {
177 t = (await pageApi.get(it.refId)).title;
178 }
179 return [it.refId, t];
180 } catch {
181 return [it.refId, ""];
182 }
183 })
184 ).then((pairs) => {
185 if (!cancelled) setViewTitles(Object.fromEntries(pairs));
186 });
187 return () => {
188 cancelled = true;
189 };
190 }, [recentViewed]);
191
192
193
194 useEffect(() => {
195 const t = setTimeout(() => setQ(input.trim()), SEARCH_DEBOUNCE_MS);
196 return () => clearTimeout(t);
197 }, [input]);
198
199
200
201 useEffect(() => {
202 if (!open) return;
203 if (!q) {
204 setResults([]);
205 setSuggestions([]);
206 setLoading(false);
207 setError(null);
208 return;
209 }
210 const my = (reqRef.current += 1);
211 const types = filter === "all" ? undefined : [filter];
212 const controller = new AbortController();
213 setLoading(true);
214 setError(null);
215
216
217
218
219
220 const retryKey = `${q}|${filter}|${workspace?.id ?? ""}`;
221 const retries = retryRef.current?.key === retryKey ? retryRef.current.count : 0;
222
223 searchApi
224 .query(q, { workspaceId: workspace?.id, limit: 20, types }, controller.signal)
225 .then((items) => {
226 if (my !== reqRef.current) return;
227
228
229
230
231 const scoped = types ? items.filter((i) => types.includes(i.type)) : items;
232 setResults(scoped);
233 if (items.length === 0 && retries === 0) {
234 retryRef.current = { key: retryKey, count: retries + 1 };
235 setTimeout(() => {
236 if (reqRef.current === my) setRetryTick((t) => t + 1);
237 }, 800);
238 }
239
240
241
242
243 if (isRecordableQuery(q)) {
244 const now = Date.now();
245 if (now - lastRecordAt.current >= 1000) {
246 lastRecordAt.current = now;
247 setRecent((r) => addRecent(r, q));
248 void searchApi.recordRecent(q).catch(() => undefined);
249 }
250 }
251 })
252 .catch((e: unknown) => {
253 if (my !== reqRef.current) return;
254 if (e instanceof DOMException && e.name === "AbortError") return;
255 setError(toErrorKind(e));
256 })
257 .finally(() => {
258 if (my === reqRef.current) setLoading(false);
259 });
260
261
262
263 searchApi
264 .suggest(q, 8, controller.signal)
265 .then((items) => {
266 if (my === reqRef.current) setSuggestions(items);
267 })
268 .catch(() => undefined);
269
270 return () => {
271 controller.abort();
272 };
273
274 }, [q, filter, open, workspace?.id, retryTick]);
275
276
277
278 const close = useCallback(() => {
279 setOpen(false);
280 setFilterMenuOpen(false);
281
282 requestAnimationFrame(() => {
283 document.querySelector<HTMLButtonElement>('[data-testid="search-button"]')?.focus();
284 });
285 }, [setOpen]);
286
287
288
289
290
291
292 useEffect(() => {
293 if (!open) return;
294 const onKey = (e: KeyboardEvent) => {
295 if (e.key !== "Escape") return;
296 e.stopPropagation();
297 if (filterMenuOpen) setFilterMenuOpen(false);
298 else close();
299 };
300 window.addEventListener("keydown", onKey);
301 return () => window.removeEventListener("keydown", onKey);
302 }, [open, close, filterMenuOpen]);
303
304
305 useEffect(() => {
306 if (!filterMenuOpen) return;
307 const onClick = (e: MouseEvent) => {
308 if (filterMenuRef.current && !filterMenuRef.current.contains(e.target as Node)) {
309 setFilterMenuOpen(false);
310 }
311 };
312 document.addEventListener("mousedown", onClick);
313 return () => document.removeEventListener("mousedown", onClick);
314 }, [filterMenuOpen]);
315
316
317
318 useEffect(() => {
319 if (!open) return;
320 const onKey = (e: KeyboardEvent) => {
321 if (e.key !== "Tab") return;
322 const panel = dialogRef.current;
323 if (!panel) return;
324 const focusables = panel.querySelectorAll<HTMLElement>(
325 'button, input, [tabindex]:not([tabindex="-1"])'
326 );
327 if (focusables.length === 0) return;
328 const first = focusables[0];
329 const last = focusables[focusables.length - 1];
330 if (!first || !last) return;
331 if (e.shiftKey && document.activeElement === first) {
332 e.preventDefault();
333 last.focus();
334 } else if (!e.shiftKey && document.activeElement === last) {
335 e.preventDefault();
336 first.focus();
337 }
338 };
339 window.addEventListener("keydown", onKey);
340 return () => window.removeEventListener("keydown", onKey);
341 }, [open]);
342
343
344
345 const activate = useCallback(
346 (item: SearchResult) => {
347 const slug = workspace?.slug ?? workspaceSlug;
348 const link = deepLinkFor(item, slug);
349 if (!link) return;
350 close();
351 router.push(link);
352 },
353 [workspace, workspaceSlug, close, router]
354 );
355
356 const activateSuggestion = useCallback(
357 (s: SearchSuggestion) => {
358 setInput(s.title);
359 setQ(s.title.trim());
360 setSuggestions([]);
361 inputRef.current?.focus();
362 },
363 []
364 );
365
366 const activateRecent = useCallback(
367 (query: string) => {
368 setInput(query);
369 setQ(query.trim());
370 setSuggestions([]);
371 inputRef.current?.focus();
372 },
373 []
374 );
375
376
377
378 const activateRecentViewed = useCallback(
379 (item: RecentViewItem) => {
380 const slug = workspace?.slug ?? workspaceSlug;
381 const link = recentItemHref(item, slug);
382 if (!link) return;
383 close();
384 router.push(link);
385 },
386 [workspace, workspaceSlug, close, router]
387 );
388
389
390
391
392
393 const applySavedSearch = useCallback((s: SavedSearch) => {
394 const q = (s.queryJson?.q as string) ?? "";
395 setInput(q);
396 setQ(q.trim());
397 setSuggestions([]);
398 inputRef.current?.focus();
399 }, []);
400
401 const saveCurrentSearch = useCallback(async () => {
402 const query = q.trim();
403 if (!isRecordableQuery(query)) return;
404 try {
405 await meApi.createSavedSearch({
406 name: savedSearchLabel({ q: query }),
407 workspaceId: workspace?.id ?? null,
408 query: {
409 q: query,
410 ...(filter !== "all" ? { types: [filter] } : {}),
411 ...(workspace?.id ? { workspaceId: workspace.id } : {})
412 } satisfies SavedSearchQuery
413 });
414
415 const fresh = await meApi.listSavedSearches().catch(() => null);
416 if (fresh) setSavedSearches(fresh);
417 } catch {
418
419 }
420 }, [q, filter, workspace?.id]);
421
422 const deleteSavedSearch = useCallback(async (id: string) => {
423 try {
424 await meApi.deleteSavedSearch(id);
425 setSavedSearches((list) => list.filter((s) => s.id !== id));
426 } catch {
427
428 }
429 }, []);
430
431 const showRecent = open && q.length === 0 && !loading && error === null;
432
433
434 const noWorkspace = !workspace?.id && (workspaceSlug.length === 0 || workspaceError);
435
436
437
438 const homeHasContent = recentViewed.length > 0 || savedSearches.length > 0 || recent.length > 0;
439
440
441
442 if (!open) return null;
443
444 return (
445 <div className="fixed inset-0 z-[85] flex items-start justify-center p-4 pt-[10vh]">
446 <div className="absolute inset-0 bg-black/40" onClick={close} aria-hidden />
447 <div
448 ref={dialogRef}
449 data-testid="search-dialog"
450 role="dialog"
451 aria-modal="true"
452 aria-label="Search"
453 className="relative flex max-h-[75vh] w-full max-w-xl flex-col overflow-hidden rounded-lg border border-border bg-background shadow-2xl"
454 >
455 {}
456 <div className="flex items-center gap-2 border-b border-border px-3">
457 <Search className="size-4 shrink-0 text-muted-foreground" aria-hidden />
458 <input
459 ref={inputRef}
460 data-testid="search-input"
461 value={input}
462 onChange={(e) => {
463 setInput(e.target.value);
464 setFilterMenuOpen(false);
465 }}
466 placeholder="Search pages, databases, records…"
467 className="h-11 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
468 role="combobox"
469 aria-expanded="true"
470 aria-controls="search-dialog-results"
471 aria-label="Search"
472 />
473 {}
475 {q.trim() ? (
476 <button
477 type="button"
478 data-testid="search-save-current"
479 aria-label="Save this search"
480 title="Save this search"
481 onClick={() => void saveCurrentSearch()}
482 className="flex h-7 shrink-0 items-center gap-1 rounded-md px-2 text-xs text-foreground/70 hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:outline-none"
483 >
484 <BookmarkPlus className="size-3.5" aria-hidden />
485 Save
486 </button>
487 ) : null}
488 {}
489 <div className="relative shrink-0" ref={filterMenuRef}>
490 <button
491 type="button"
492 data-testid="search-filter-type"
493 aria-label="Filter by type"
494 aria-haspopup="listbox"
495 aria-expanded={filterMenuOpen}
496 onClick={() => setFilterMenuOpen((v) => !v)}
497 className={
498 "flex h-7 items-center gap-1 rounded-md px-2 text-xs hover:bg-accent " +
499 (filter !== "all" ? "bg-accent text-foreground" : "text-foreground/70")
500 }
501 >
502 <Filter className="size-3.5" aria-hidden />
503 {filter === "all" ? "All" : SEARCH_TYPE_LABELS[filter]}
504 </button>
505 {filterMenuOpen ? (
506 <div
507 role="listbox"
508 aria-label="Result type filter"
509 className="absolute right-0 z-40 mt-1 w-36 rounded-md border border-border bg-popover p-1 shadow-lg"
510 >
511 {FILTER_OPTIONS.map((opt) => (
512 <button
513 key={opt.value}
514 type="button"
515 role="option"
516 aria-selected={filter === opt.value}
517 data-testid={`search-filter-option-${opt.value === "all" ? "all" : opt.value}`}
518 onClick={() => {
519 setFilter(opt.value);
520 setFilterMenuOpen(false);
521 inputRef.current?.focus();
522 }}
523 className={
524 "flex w-full items-center justify-between rounded px-2 py-1.5 text-left text-sm hover:bg-accent " +
525 (filter === opt.value ? "font-medium" : "text-foreground/90")
526 }
527 >
528 <span>{opt.label}</span>
529 {filter === opt.value ? <span className="text-xs">✓</span> : null}
530 </button>
531 ))}
532 </div>
533 ) : null}
534 </div>
535 </div>
536
537 {}
538 <div id="search-dialog-results" className="min-h-0 flex-1 overflow-y-auto p-2">
539 {noWorkspace ? (
540 <p className="mb-2 px-2 py-1 text-xs text-muted-foreground" role="status">
541 No workspace resolved yet — results may be limited.
542 </p>
543 ) : null}
544 {error !== null ? (
545 <SearchErrorState kind={error} />
546 ) : showRecent ? (
547 <>
548 {}
549 {recentViewed.length > 0 ? (
550 <section data-testid="search-recent-viewed" aria-label="Recently viewed" className="p-1 pb-2">
551 <h3 className="mb-1.5 px-2 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
552 Recently viewed
553 </h3>
554 <ul className="space-y-0.5">
555 {recentViewed.map((item) => {
556 const Icon =
557 item.itemType === "database"
558 ? DatabaseIcon
559 : item.itemType === "whiteboard"
560 ? Square
561 : item.itemType === "task"
562 ? ListTodo
563 : FileText;
564 return (
565 <li key={`${item.itemType}-${item.refId}`}>
566 <button
567 type="button"
568 data-testid="search-recent-viewed-item"
569 onClick={() => activateRecentViewed(item)}
570 className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-accent focus-visible:bg-accent focus-visible:outline-none"
571 >
572 <Icon className="size-4 shrink-0 text-muted-foreground" aria-hidden />
573 <span className="min-w-0 flex-1 truncate">{viewTitles[item.refId] || "Untitled"}</span>
574 <span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-[10px] uppercase text-muted-foreground">
575 {item.itemType}
576 </span>
577 </button>
578 </li>
579 );
580 })}
581 </ul>
582 </section>
583 ) : null}
584
585 {}
586 {savedSearches.length > 0 ? (
587 <section data-testid="search-saved" aria-label="Saved searches" className="p-1 pb-2">
588 <h3 className="mb-1.5 px-2 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
589 Saved searches
590 </h3>
591 <ul className="flex flex-wrap gap-1.5">
592 {savedSearches.map((s) => (
593 <li key={s.id} className="group flex items-center">
594 <button
595 type="button"
596 data-testid="search-saved-item"
597 title={s.name}
598 onClick={() => applySavedSearch(s)}
599 className="flex items-center gap-1.5 rounded-full border border-border bg-muted/40 px-2.5 py-1 text-xs text-foreground/90 hover:bg-accent focus-visible:bg-accent focus-visible:outline-none"
600 >
601 <Bookmark className="size-3" aria-hidden />
602 {s.name}
603 </button>
604 <button
605 type="button"
606 data-testid="search-saved-delete"
607 aria-label={`Delete saved search ${s.name}`}
608 onClick={() => void deleteSavedSearch(s.id)}
609 className="ml-0.5 rounded-full p-1 text-muted-foreground opacity-0 hover:bg-accent hover:text-foreground group-hover:opacity-100 focus-visible:opacity-100 focus-visible:outline-none"
610 >
611 <X className="size-3" aria-hidden />
612 </button>
613 </li>
614 ))}
615 </ul>
616 </section>
617 ) : null}
618
619 {}
620 <RecentSection items={recent} onSelect={activateRecent} />
621 </>
622 ) : (
623 <>
624 {}
625 {suggestions.length > 0 ? (
626 <section aria-label="Suggestions" className="mb-2">
627 <h3 className="mb-1 px-2 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
628 Suggestions
629 </h3>
630 <ul className="space-y-0.5">
631 {suggestions.map((s) => {
632 const SIcon = SEARCH_TYPE_ICON[s.type] ?? Search;
633 return (
634 <li key={`${s.type}-${s.id}`}>
635 <button
636 type="button"
637 data-testid="search-suggestion"
638 onClick={() => activateSuggestion(s)}
639 className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-accent focus-visible:bg-accent focus-visible:outline-none"
640 >
641 <SIcon className="size-4 shrink-0 text-muted-foreground" aria-hidden />
642 <span className="min-w-0 flex-1 truncate">{s.title}</span>
643 <span
644 data-testid={`search-suggestion-type-${s.type}`}
645 aria-hidden
646 className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-[10px] uppercase text-muted-foreground"
647 >
648 {s.type}
649 </span>
650 </button>
651 </li>
652 );
653 })}
654 </ul>
655 </section>
656 ) : null}
657
658 {}
659 {loading ? (
660 <p className="flex items-center gap-2 px-2 py-3 text-sm text-muted-foreground" role="status">
661 <Loader2 className="size-4 animate-spin" aria-hidden /> Searching…
662 </p>
663 ) : null}
664
665 {}
666 {!loading && results.length === 0 && (
667 <p className="flex items-center gap-2 px-2 py-4 text-sm text-muted-foreground">
668 <SearchX className="size-4" aria-hidden />
669 No results for “{q}”.
670 </p>
671 )}
672
673 {}
674 {results.length > 0 ? (
675 <ResultsList items={results} workspaceSlug={workspace?.slug ?? workspaceSlug} onActivate={activate} compact />
676 ) : null}
677 </>
678 )}
679 </div>
680
681 {}
682 <div className="flex items-center gap-3 border-t border-border px-3 py-1.5 text-[11px] text-muted-foreground">
683 <span className="flex items-center gap-1"><kbd className="rounded border border-border px-1">esc</kbd> to close</span>
684 <span className="flex items-center gap-1"><kbd className="rounded border border-border px-1">↵</kbd> to open</span>
685 </div>
686 </div>
687 </div>
688 );
689}
690
691
692
693type ErrorKind = null | "unavailable" | "generic";
694
695function toErrorKind(e: unknown): ErrorKind {
696 if (isApiError(e)) {
697 if (e.code === "SEARCH_UNAVAILABLE" || e.status === 503) return "unavailable";
698 return "generic";
699 }
700 return "generic";
701}
702
703function SearchErrorState({ kind }: { kind: Exclude<ErrorKind, null> }) {
704 if (kind === "unavailable") {
705 return (
706 <div className="flex flex-col items-center gap-2 px-4 py-8 text-center" role="alert">
707 <ShieldAlert className="size-6 text-muted-foreground" aria-hidden />
708 <p className="text-sm text-foreground">Search is temporarily unavailable — try again.</p>
709 <p className="text-xs text-muted-foreground">
710 Keep typing to retry — your query is preserved.
711 </p>
712 </div>
713 );
714 }
715 return (
716 <div className="flex flex-col items-center gap-2 px-4 py-8 text-center" role="alert">
717 <SearchX className="size-6 text-muted-foreground" aria-hidden />
718 <p className="text-sm text-foreground">Search failed.</p>
719 <p className="text-xs text-muted-foreground">
720 Check your connection and keep typing to retry.
721 </p>
722 </div>
723 );
724}
725
726
727
728function RecentSection({ items, onSelect }: { items: string[]; onSelect: (q: string) => void }) {
729 if (items.length === 0) {
730 return (
731 <p className="px-2 py-6 text-center text-sm text-muted-foreground">
732 No recent searches yet — start typing to find pages, databases and more.
733 </p>
734 );
735 }
736 return (
737 <section data-testid="search-recent" aria-label="Recent searches" className="p-1">
738 <h3 className="mb-1.5 px-2 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
739 Recent searches
740 </h3>
741 <ul className="flex flex-wrap gap-1.5">
742 {items.map((item) => (
743 <li key={item}>
744 <button
745 type="button"
746 data-testid="search-recent-item"
747 onClick={() => onSelect(item)}
748 className="flex items-center gap-1.5 rounded-full border border-border bg-muted/40 px-2.5 py-1 text-xs text-foreground/90 hover:bg-accent focus-visible:bg-accent focus-visible:outline-none"
749 >
750 <Clock className="size-3" aria-hidden />
751 {item}
752 </button>
753 </li>
754 ))}
755 </ul>
756 </section>
757 );
758}
759