1"use client";
2
3
17import { useCallback, useEffect, useMemo, useRef, useState } from "react";
18import { useRouter, useSearchParams } from "next/navigation";
19import { useQuery } from "@tanstack/react-query";
20import {
21 CalendarRange,
22 Filter,
23 Group,
24 Loader2,
25 Search,
26 SlidersHorizontal,
27 X
28} from "lucide-react";
29import { cn } from "@/lib/utils";
30import { apiFetch } from "@/lib/api";
31import { useDatabaseStore } from "@/stores/database-store";
32import {
33 andFilters,
34 DatabaseProperty,
35 DatabaseView,
36 FilterTree,
37 GroupSpec,
38 searchFilter,
39 SortSpec
40} from "@/hooks/use-database";
41import { FilterBuilder } from "@/components/database/filter-builder";
42import { SortBuilder } from "@/components/database/sort-builder";
43import { GroupBuilder } from "@/components/database/group-builder";
44
45type Pop = "filter" | "sort" | "group" | null;
46
47export interface DatabaseToolbarProps {
48 databaseId: string;
49 view: DatabaseView;
50 properties: DatabaseProperty[];
51 className?: string;
52}
53
54function encode(v: unknown): string | null {
55 if (v == null) return null;
56 if (Array.isArray(v) && v.length === 0) return null;
57 return JSON.stringify(v);
58}
59function decode<T>(s: string | null): T | null {
60 if (!s) return null;
61 try {
62 return JSON.parse(s) as T;
63 } catch {
64 return null;
65 }
66}
67
68export function DatabaseToolbar({ databaseId, view, properties, className }: DatabaseToolbarProps) {
69 const router = useRouter();
70 const searchParams = useSearchParams();
71 const [pop, setPop] = useState<Pop>(null);
72 const rootRef = useRef<HTMLDivElement>(null);
73
74 const overrides = useDatabaseStore((s) => s.overrides[`${databaseId}:${view.id}`]);
75 const setOverrides = useDatabaseStore((s) => s.setOverrides);
76 const clearOverrides = useDatabaseStore((s) => s.clearOverrides);
77
78
79 const hydrated = useRef(false);
80 useEffect(() => {
81 if (hydrated.current) return;
82 hydrated.current = true;
83 const patch: Record<string, unknown> = {};
84 const f = decode<FilterTree>(searchParams.get("filter"));
85 const so = decode<SortSpec[]>(searchParams.get("sort"));
86 const g = decode<GroupSpec[]>(searchParams.get("group"));
87 const q = searchParams.get("q");
88 if (f) patch.filter = f;
89 if (so) patch.sort = so;
90 if (g) patch.group = g;
91 if (q != null) patch.searchText = q;
92 if (Object.keys(patch).length > 0) setOverrides(databaseId, view.id, patch);
93
94 }, [databaseId, view.id]);
95
96
97 const effectiveFilter = useMemo<FilterTree | null>(
98 () => andFilters(view.filter, overrides?.filter ?? null, searchFilter(properties, overrides?.searchText ?? "")),
99 [view.filter, overrides?.filter, overrides?.searchText, properties]
100 );
101 const effectiveSort = useMemo<SortSpec[] | null>(
102 () => overrides?.sort ?? view.sort ?? null,
103 [overrides?.sort, view.sort]
104 );
105 const effectiveGroup = useMemo<GroupSpec[] | null>(
106 () => overrides?.group ?? view.group ?? null,
107 [overrides?.group, view.group]
108 );
109
110
111 useEffect(() => {
112 const params = new URLSearchParams(searchParams.toString());
113 const setOrDelete = (key: string, value: string | null) => {
114 if (value == null) params.delete(key);
115 else params.set(key, value);
116 };
117 setOrDelete("filter", overrides?.filter ? encode(overrides.filter) : null);
118 setOrDelete("sort", overrides?.sort ? encode(overrides.sort) : null);
119 setOrDelete("group", overrides?.group ? encode(overrides.group) : null);
120 setOrDelete("q", overrides?.searchText || null);
121 const qs = params.toString();
122 const next = qs ? `${window.location.pathname}?${qs}` : window.location.pathname;
123 if (window.location.pathname + (qs ? `?${qs}` : "") !== window.location.pathname + (searchParams.toString() ? `?${searchParams.toString()}` : "")) {
124 router.replace(next, { scroll: false });
125 }
126
127 }, [overrides?.filter, overrides?.sort, overrides?.group, overrides?.searchText]);
128
129
130 useEffect(() => {
131 if (!pop) return;
132 const onDown = (e: MouseEvent) => {
133 if (rootRef.current && !rootRef.current.contains(e.target as Node)) setPop(null);
134 };
135 document.addEventListener("mousedown", onDown);
136 return () => document.removeEventListener("mousedown", onDown);
137 }, [pop]);
138
139
140 const [preview, setPreview] = useState<{ filter: FilterTree | null; count: number | null } | null>(null);
141 const editingFilter = pop === "filter" && preview?.filter === effectiveFilter ? preview.count : null;
142 const countQuery = useQuery({
143 queryKey: ["db-count", databaseId, view.id, JSON.stringify(effectiveFilter ?? null)],
144 enabled: false,
145 queryFn: async () => {
146 const b = await apiFetch(`/databases/${databaseId}/query`, {
147 method: "POST",
148 body: JSON.stringify({ filter: effectiveFilter ?? undefined, pageSize: 1 })
149 });
150 const d = (b as { data?: { totalCount?: number } }).data ?? b;
151 return (d as { totalCount?: number }).totalCount ?? null;
152 }
153 });
154 const refreshCount = useCallback(() => {
155 setPreview({ filter: effectiveFilter, count: null });
156 void countQuery.refetch();
157 }, [effectiveFilter, countQuery]);
158 useEffect(() => {
159 if (pop === "filter" && countQuery.data != null) {
160 setPreview((p) => (p ? { ...p, count: countQuery.data } : p));
161 }
162
163 }, [countQuery.data, pop]);
164
165
166 const conditionCount = useMemo(() => {
167 let n = 0;
168 const walk = (node: FilterTree | null | undefined) => {
169 if (!node) return;
170 if (typeof (node as { propertyId?: string }).propertyId === "string") n += 1;
171 else for (const c of (node as { conditions: FilterTree[] }).conditions) walk(c);
172 };
173 walk(effectiveFilter);
174 return n;
175 }, [effectiveFilter]);
176
177 const chipCls =
178 "inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs hover:bg-muted";
179
180 return (
181 <div ref={rootRef} className={cn("flex flex-wrap items-center gap-1", className)}>
182 {}
183 {view.filter && (
184 <span className={chipCls} title="Saved view filter">
185 <Filter className="size-3" aria-hidden />
186 {conditionCount} saved
187 </span>
188 )}
189 {overrides?.filter && (
190 <button
191 type="button"
192 className={cn(chipCls, "bg-primary/10 text-primary")}
193 onClick={() => setOverrides(databaseId, view.id, { filter: undefined })}
194 title="Remove ad-hoc filter"
195 >
196 <Filter className="size-3" aria-hidden />
197 filter
198 <X className="size-3" />
199 </button>
200 )}
201 {overrides?.searchText && (
202 <button
203 type="button"
204 className={cn(chipCls, "bg-primary/10 text-primary")}
205 onClick={() => setOverrides(databaseId, view.id, { searchText: undefined })}
206 title="Clear in-view search"
207 >
208 “{overrides.searchText}”
209 <X className="size-3" />
210 </button>
211 )}
212
213 {}
214 <div className="relative">
215 <Search className="pointer-events-none absolute left-1.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" aria-hidden />
216 <input
217 className="h-7 w-40 rounded-md border border-input bg-background pl-7 pr-6 text-xs outline-none focus-visible:ring-1 focus-visible:ring-ring"
218 placeholder="Search in view…"
219 value={overrides?.searchText ?? ""}
220 onChange={(e) => setOverrides(databaseId, view.id, { searchText: e.target.value || undefined })}
221 aria-label="Search within this view"
222 />
223 {overrides?.searchText && (
224 <button
225 type="button"
226 className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
227 onClick={() => setOverrides(databaseId, view.id, { searchText: undefined })}
228 aria-label="Clear search"
229 >
230 <X className="size-3" />
231 </button>
232 )}
233 </div>
234
235 {}
236 <div className="relative">
237 <button
238 type="button"
239 className={cn(
240 "inline-flex h-7 items-center gap-1 rounded-md border px-2 text-xs hover:bg-muted",
241 (overrides?.filter || pop === "filter") && "border-primary/40 bg-primary/10 text-primary"
242 )}
243 onClick={() => {
244 const next = pop === "filter" ? null : "filter";
245 setPop(next);
246 if (next === "filter") refreshCount();
247 }}
248 aria-expanded={pop === "filter"}
249 >
250 <Filter className="size-3.5" aria-hidden />
251 Filter
252 </button>
253 {pop === "filter" && (
254 <div className="absolute left-0 top-8 z-40 w-[26rem] max-w-[85vw] rounded-lg border bg-popover p-2 shadow-lg">
255 <div className="mb-1 flex items-center justify-between">
256 <span className="text-xs font-medium">Filters</span>
257 <span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
258 {countQuery.isFetching && <Loader2 className="size-3 animate-spin" aria-hidden />}
259 {editingFilter != null ? `${editingFilter} records match` : countQuery.data != null ? `${countQuery.data} records match` : ""}
260 </span>
261 </div>
262 <FilterBuilder
263 value={overrides?.filter ?? null}
264 onChange={(tree) => {
265 setOverrides(databaseId, view.id, { filter: tree ?? undefined });
266 refreshCount();
267 }}
268 properties={properties}
269 />
270 </div>
271 )}
272 </div>
273
274 {}
275 <div className="relative">
276 <button
277 type="button"
278 className={cn(
279 "inline-flex h-7 items-center gap-1 rounded-md border px-2 text-xs hover:bg-muted",
280 effectiveSort && "border-primary/40 bg-primary/10 text-primary"
281 )}
282 onClick={() => setPop(pop === "sort" ? null : "sort")}
283 aria-expanded={pop === "sort"}
284 >
285 <SlidersHorizontal className="size-3.5" aria-hidden />
286 Sort
287 {(overrides?.sort?.length ?? 0) > 0 && ` (${overrides?.sort?.length})`}
288 </button>
289 {pop === "sort" && (
290 <div className="absolute left-0 top-8 z-40 w-80 max-w-[85vw] rounded-lg border bg-popover p-2 shadow-lg">
291 <p className="mb-1 text-xs font-medium">Sorts</p>
292 <SortBuilder
293 value={effectiveSort}
294 onChange={(sort) => setOverrides(databaseId, view.id, { sort: sort ?? undefined })}
295 properties={properties}
296 />
297 </div>
298 )}
299 </div>
300
301 {}
302 <div className="relative">
303 <button
304 type="button"
305 className={cn(
306 "inline-flex h-7 items-center gap-1 rounded-md border px-2 text-xs hover:bg-muted",
307 effectiveGroup && "border-primary/40 bg-primary/10 text-primary"
308 )}
309 onClick={() => setPop(pop === "group" ? null : "group")}
310 aria-expanded={pop === "group"}
311 >
312 <Group className="size-3.5" aria-hidden />
313 Group
314 </button>
315 {pop === "group" && (
316 <div className="absolute left-0 top-8 z-40 w-80 max-w-[85vw] rounded-lg border bg-popover p-2 shadow-lg">
317 <p className="mb-1 text-xs font-medium">Group by</p>
318 <GroupBuilder
319 value={effectiveGroup}
320 onChange={(group) => setOverrides(databaseId, view.id, { group: group ?? undefined })}
321 properties={properties}
322 viewType={view.type}
323 />
324 </div>
325 )}
326 </div>
327
328 {}
329 {(overrides?.filter || overrides?.sort || overrides?.group || overrides?.searchText) && (
330 <button
331 type="button"
332 className="inline-flex h-7 items-center gap-1 rounded-md px-2 text-xs text-muted-foreground hover:bg-muted hover:text-foreground"
333 onClick={() => clearOverrides(databaseId, view.id)}
334 title="Reset ad-hoc filters, sorts, groups and search"
335 >
336 <CalendarRange className="hidden" aria-hidden />
337 Reset
338 </button>
339 )}
340 </div>
341 );
342}
343