1"use client";
2
3
12import { FileText, Database, Rows3, Square, MessageSquare, File, User, type LucideIcon } from "lucide-react";
13import { deepLinkFor, groupResultsByType, SEARCH_TYPE_LABELS } from "@/lib/search";
14import { formatRelativeTime } from "@/lib/utils";
15import type { SearchResult, SearchType } from "@/lib/types";
16import type { SearchGroup } from "@/lib/search";
17
18export const SEARCH_TYPE_ICON: Record<SearchType, LucideIcon> = {
19 page: FileText,
20 database: Database,
21 record: Rows3,
22 block: Square,
23 comment: MessageSquare,
24 file: File,
25 user: User
26};
27
28const BADGE_LABEL: Record<SearchType, string> = {
29 page: "page",
30 database: "database",
31 record: "record",
32 block: "block",
33 comment: "comment",
34 file: "file",
35 user: "user"
36};
37
38
44export function ResultRow({
45 item,
46 workspaceSlug,
47 onActivate,
48 compact = false
49}: {
50 item: SearchResult;
51
52 workspaceSlug: string | null;
53
54 onActivate?: (item: SearchResult) => void;
55
56 compact?: boolean;
57}) {
58 const link = deepLinkFor(item, workspaceSlug);
59 const Icon = SEARCH_TYPE_ICON[item.type];
60 return (
61 <button
62 type="button"
63 data-testid="search-result"
64 title={link ? item.title : "No deep link for this result"}
65 aria-label={`${SEARCH_TYPE_LABELS[item.type]}: ${item.title}`}
66 onClick={() => onActivate?.(item)}
67 className={cnRow(compact)}
68 >
69 <Icon className="size-4 shrink-0 text-muted-foreground" aria-hidden />
70 <span
71 data-testid={`search-result-type-${item.type}`}
72 aria-hidden
73 className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground"
74 >
75 {BADGE_LABEL[item.type]}
76 </span>
77 <span className="min-w-0 flex-1 text-left">
78 <span className="block truncate text-sm text-foreground">{item.title || "Untitled"}</span>
79 {item.snippet ? (
80 <span className="mt-0.5 block truncate text-xs text-muted-foreground">{item.snippet}</span>
81 ) : null}
82 {!compact && (item.createdByName || item.updatedAt) ? (
83 <span className="mt-0.5 block truncate text-[11px] text-muted-foreground">
84 {item.createdByName ? `${item.createdByName} · ` : ""}
85 {formatRelativeTime(item.updatedAt) || "—"}
86 </span>
87 ) : null}
88 </span>
89 </button>
90 );
91}
92
93function cnRow(compact: boolean): string {
94 return compact
95 ? "flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left hover:bg-accent focus-visible:bg-accent focus-visible:outline-none"
96 : "flex w-full items-start gap-2.5 px-3 py-2.5 text-left hover:bg-muted/50 focus-visible:bg-muted/50 focus-visible:outline-none";
97}
98
99
103export function ResultsList({
104 items,
105 workspaceSlug,
106 onActivate,
107 compact = false
108}: {
109 items: readonly SearchResult[];
110 workspaceSlug: string | null;
111 onActivate?: (item: SearchResult) => void;
112 compact?: boolean;
113}) {
114 const groups: SearchGroup[] = groupResultsByType(items);
115 if (groups.length === 0) return null;
116 return (
117 <div aria-label="Search results" className="space-y-3">
118 {groups.map((group) => (
119 <section key={group.type} aria-label={`${group.label} results`}>
120 <h3 className="mb-1 px-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
121 {group.label} <span className="text-muted-foreground/60">({group.items.length})</span>
122 </h3>
123 <ul className={compact ? "space-y-0.5" : "divide-y divide-border rounded-md border border-border"}>
124 {group.items.map((item) => (
125 <li key={`${item.type}-${item.id}-${item.docId}`}>
126 <ResultRow item={item} workspaceSlug={workspaceSlug} onActivate={onActivate} compact={compact} />
127 </li>
128 ))}
129 </ul>
130 </section>
131 ))}
132 </div>
133 );
134}
135