1"use client";
2
3
19import { useCallback, useEffect, useMemo, useRef, useState } from "react";
20import { useSearchParams, useRouter } from "next/navigation";
21import {
22 BarChart3,
23 CalendarDays,
24 ChevronDown,
25 ClipboardList,
26 Columns3,
27 Download,
28 Flag,
29 GanttChart,
30 LayoutDashboard,
31 LayoutGrid,
32 ListPlus,
33 MoreHorizontal,
34 Pencil,
35 Plus,
36 Rows3,
37 Sigma,
38 Sparkles,
39 Table as TableIcon,
40 TextCursorInput,
41 Trash2,
42 X,
43 Zap,
44} from "lucide-react";
45import { cn, sortByPosition } from "@/lib/utils";
46import { toast, useUiStore } from "@/stores/ui-store";
47import { useDatabaseStore } from "@/stores/database-store";
48import { useAuthStore } from "@/lib/auth-store";
49import { useDatabasePresence } from "@/hooks/use-presence";
50import { PresenceBar } from "@/components/collaboration/PresenceBar";
51import { meApi } from "@/lib/api";
52import {
53 andFilters,
54 DatabaseDto,
55 DatabaseView,
56 DatabaseProperty,
57 FilterTree,
58 GroupSpec,
59 RecordDto,
60 searchFilter,
61 SortSpec,
62 useDatabase,
63 useDatabaseRecord,
64 useDatabaseRecords,
65 useCreateView,
66 useDeleteView,
67 useExportCsv,
68 useUpdateDatabase,
69 useUpdateView,
70 useReorderView,
71} from "@/hooks/use-database";
72import { VIEW_TYPES } from "@/lib/constants";
73import { DatabaseToolbar } from "@/components/database/database-toolbar";
74import { ViewRouter } from "@/components/database/database-view-renderer";
75import { RecordPanel } from "@/components/database/record-panel";
76import { PropertyEditor } from "@/components/database/property-editor";
77import { DatabaseButtons, ButtonsEditor } from "@/components/database/buttons-panel";
78import { AiGenerateDialog } from "@/components/database/AiGenerateDialog";
79import { AiExtractDialog } from "@/components/database/AiExtractDialog";
80import { AiFormulaDialog } from "@/components/database/AiFormulaDialog";
81
82const VIEW_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
83 table: TableIcon,
84 list: Rows3,
85 board: Columns3,
86 gallery: LayoutGrid,
87 calendar: CalendarDays,
88 timeline: GanttChart,
89 chart: BarChart3,
90 dashboard: LayoutDashboard,
91 form: ClipboardList,
92};
93
94const VIEW_LABELS: Record<string, string> = {
95 table: "Table",
96 list: "List",
97 board: "Board",
98 gallery: "Gallery",
99 calendar: "Calendar",
100 timeline: "Timeline",
101 chart: "Chart",
102 dashboard: "Dashboard",
103 form: "Form",
104};
105
106export interface DatabasePageViewProps {
107 databaseId: string;
108
109 workspaceId?: string;
110
111 workspaceSlug?: string;
112
113 readOnly?: boolean;
114}
115
116export function DatabasePageView({
117 databaseId,
118 workspaceId,
119 workspaceSlug,
120 readOnly,
121}: DatabasePageViewProps) {
122 const { data: database, isLoading, error } = useDatabase(databaseId);
123 const updateDatabase = useUpdateDatabase(databaseId);
124 const createView = useCreateView(databaseId);
125 const updateView = useUpdateView(databaseId);
126 const deleteView = useDeleteView(databaseId);
127 const reorderView = useReorderView(databaseId);
128 const exportCsv = useExportCsv(databaseId, database?.properties ?? []);
129
130 const searchParams = useSearchParams();
131 const router = useRouter();
132
133
134 const storedActive = useDatabaseStore((s) => s.activeViews[databaseId]);
135 const setActiveView = useDatabaseStore((s) => s.setActiveView);
136 const setOverrides = useDatabaseStore((s) => s.setOverrides);
137 const panelOpen = useDatabaseStore((s) => s.panelOpen);
138 const setAiPanel = useUiStore((s) => s.setAiPanel);
139 const panelRecordId = useDatabaseStore((s) => s.panelRecordId);
140 const openPanel = useDatabaseStore((s) => s.openPanel);
141 const closePanel = useDatabaseStore((s) => s.closePanel);
142
143
144
145
146 useEffect(() => {
147 if (!databaseId) return;
148 void meApi.recordView("database", databaseId).catch(() => undefined);
149 }, [databaseId]);
150
151 const views = useMemo(() => sortByPosition(database?.views ?? []), [database?.views]);
152
153
154
155
156 const dbSelfUserId = useAuthStore((s) => s.user?.id ?? null);
157 const { presence: dbPresence } = useDatabasePresence(databaseId || null);
158 const dbOthers = useMemo(
159 () => (dbSelfUserId ? dbPresence.filter((p) => p.userId !== dbSelfUserId) : dbPresence),
160 [dbPresence, dbSelfUserId],
161 );
162
163 const urlViewId = searchParams.get("view");
164 const activeViewId = useMemo(() => {
165 const candidates = [urlViewId, storedActive] as (string | null)[];
166 for (const id of candidates) {
167 if (id && views.some((v) => v.id === id)) return id;
168 }
169 return views[0]?.id ?? null;
170 }, [urlViewId, storedActive, views]);
171
172 const activeView: DatabaseView | undefined = views.find((v) => v.id === activeViewId) ?? views[0];
173
174
175 const overrides = useDatabaseStore((s) =>
176 activeViewId ? s.overrides[`${databaseId}:${activeViewId}`] : undefined,
177 );
178
179
180
181
182
183
184
185
186
187
188
189
190 const hydrated = useRef(false);
191 useEffect(() => {
192 if (!hydrated.current) {
193 hydrated.current = true;
194 if (activeViewId) setActiveView(databaseId, activeViewId);
195 return;
196 }
197 if (!activeViewId) return;
198 const params = new URLSearchParams(searchParams.toString());
199 if (params.get("view") !== activeViewId) params.set("view", activeViewId);
200 const qs = params.toString();
201 router.replace(qs ? `${window.location.pathname}?${qs}` : window.location.pathname, {
202 scroll: false,
203 });
204
205 }, [activeViewId, databaseId]);
206
207 const selectView = (viewId: string) => {
208 setActiveView(databaseId, viewId);
209 };
210
211
212 const properties = database?.properties ?? [];
213 const effectiveFilter = useMemo<FilterTree | null>(
214 () =>
215 activeView
216 ? andFilters(
217 activeView.filter,
218 overrides?.filter ?? null,
219 searchFilter(properties, overrides?.searchText ?? ""),
220 )
221 : null,
222 [activeView, overrides?.filter, overrides?.searchText, properties],
223 );
224 const effectiveSort = useMemo<SortSpec[] | null>(
225 () => overrides?.sort ?? activeView?.sort ?? null,
226 [overrides?.sort, activeView?.sort],
227 );
228 const effectiveGroup = useMemo<GroupSpec[] | null>(
229 () => overrides?.group ?? activeView?.group ?? null,
230 [overrides?.group, activeView?.group],
231 );
232
233
234
235 const effectivePageSize = activeView?.pageSize ?? 50;
236
237 const records = useDatabaseRecords(databaseId, {
238 viewId: activeView?.id ?? null,
239 filter: effectiveFilter,
240 sort: effectiveSort,
241 group: effectiveGroup,
242 pageSize: effectivePageSize,
243 enabled: !!activeView,
244 });
245
246
247 const openRecord = useCallback(
248 (record: RecordDto) => {
249 openPanel(databaseId, record.id);
250 },
251 [databaseId, openPanel],
252 );
253 const { data: fetchedRecord } = useDatabaseRecord(
254 panelOpen ? databaseId : undefined,
255 panelOpen ? panelRecordId : undefined,
256 );
257 const panelRecord: RecordDto | undefined =
258 (panelRecordId ? records.data.records.find((r) => r.id === panelRecordId) : undefined) ??
259 (panelOpen ? fetchedRecord : undefined);
260
261 const onOpenRecordPage = useCallback(
262 (record: RecordDto) => {
263 if (record.pageId && workspaceSlug) {
264
265 router.push(`/w/${workspaceSlug}/page/${record.pageId}`);
266 }
267 },
268 [workspaceSlug, router],
269 );
270
271
272 const [createViewOpen, setCreateViewOpen] = useState(false);
273 const [editViewId, setEditViewId] = useState<string | null>(null);
274 const [menuViewId, setMenuViewId] = useState<string | null>(null);
275 const [addPropertyOpen, setAddPropertyOpen] = useState(false);
276 const [buttonsOpen, setButtonsOpen] = useState(false);
277
278 const [dbAiOpen, setDbAiOpen] = useState(false);
279 const [dbAiDialog, setDbAiDialog] = useState<null | "generate" | "extract" | "formula">(null);
280 const dbAiRef = useRef<HTMLDivElement>(null);
281
282
283 useEffect(() => {
284 if (!dbAiOpen) return;
285 const onDown = (e: MouseEvent) => {
286 if (dbAiRef.current && !dbAiRef.current.contains(e.target as Node)) setDbAiOpen(false);
287 };
288 const onKey = (e: KeyboardEvent) => {
289 if (e.key === "Escape") setDbAiOpen(false);
290 };
291 document.addEventListener("mousedown", onDown);
292 document.addEventListener("keydown", onKey);
293 return () => {
294 document.removeEventListener("mousedown", onDown);
295 document.removeEventListener("keydown", onKey);
296 };
297 }, [dbAiOpen]);
298
299
300 if (isLoading) {
301 return (
302 <div className="flex h-full flex-col gap-3 p-6" aria-busy="true">
303 <div className="h-10 w-64 animate-pulse rounded bg-muted" />
304 <div className="h-8 w-80 animate-pulse rounded bg-muted" />
305 <div className="flex-1 animate-pulse rounded-lg bg-muted/50" />
306 </div>
307 );
308 }
309
310 if (error || !database) {
311 return (
312 <div className="flex h-full items-center justify-center p-6">
313 <div className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
314 This database is unavailable.
315 </div>
316 </div>
317 );
318 }
319
320 const editingView = views.find((v) => v.id === editViewId);
321
322 return (
323 <div className="flex h-full flex-col">
324 {}
325 <div className="px-6 pt-5">
326 <div className="flex items-start gap-3">
327 <button
328 type="button"
329 className="text-4xl leading-none"
330 title="Change icon"
331 onClick={() => {
332 const icon = window.prompt("Icon (emoji)", database.icon ?? "📊");
333 if (icon != null) updateDatabase.mutate({ icon });
334 }}
335 aria-label="Database icon"
336 >
337 {database.icon ?? "📊"}
338 </button>
339 <div className="min-w-0 flex-1">
340 <input
341 className="w-full rounded-sm bg-transparent px-1 text-3xl font-bold tracking-tight outline-none placeholder:text-muted-foreground"
342 value={database.title}
343 placeholder="Untitled database"
344 disabled={!!readOnly}
345 onChange={(e) => updateDatabase.mutate({ title: e.target.value })}
346 aria-label="Database title"
347 />
348 {database.description && (
349 <p className="mt-1 px-1 text-sm text-muted-foreground">{database.description}</p>
350 )}
351 <div className="mt-1 flex items-center gap-2 px-1 text-xs text-muted-foreground">
352 <span>{records.data.totalCount ?? records.data.records.length} records</span>
353 <span aria-hidden>·</span>
354 <span>{database.properties.length} properties</span>
355 <span aria-hidden>·</span>
356 <span>{views.length} views</span>
357 </div>
358 </div>
359 {}
361 <PresenceBar peers={dbOthers} selfUserId={dbSelfUserId} className="mt-2 shrink-0" />
362 </div>
363 </div>
364
365 {}
366 <div className="sticky top-0 z-20 mt-3 flex flex-wrap items-center gap-2 border-b border-border bg-background/95 px-4 py-2 backdrop-blur">
367 <div className="flex items-center gap-1 overflow-x-auto">
368 {views.map((v) => {
369 const Icon = VIEW_ICONS[v.type] ?? TableIcon;
370 const active = v.id === activeViewId;
371 return (
372 <div key={v.id} className="relative">
373 <button
374 type="button"
375 className={cn(
376 "inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-sm",
377 active
378 ? "bg-muted font-medium text-foreground"
379 : "text-muted-foreground hover:bg-muted/50",
380 )}
381 onClick={() => selectView(v.id)}
382 aria-current={active ? "page" : undefined}
383 >
384 <Icon className="size-4" aria-hidden />
385 {v.name}
386 </button>
387 {!readOnly && (
388 <button
389 type="button"
390 className="rounded p-0.5 text-muted-foreground hover:bg-muted"
391 onClick={() => setMenuViewId(menuViewId === v.id ? null : v.id)}
392 aria-label={`View menu for ${v.name}`}
393 >
394 <MoreHorizontal className="size-3.5" />
395 </button>
396 )}
397 {menuViewId === v.id && (
398 <div className="absolute left-0 top-7 z-30 w-40 rounded-md border bg-popover p-1 shadow-lg">
399 <button
400 type="button"
401 className="flex w-full items-center gap-1.5 rounded px-2 py-1 text-left text-xs hover:bg-muted"
402 onClick={() => {
403 setEditViewId(v.id);
404 setMenuViewId(null);
405 }}
406 >
407 <Pencil className="size-3" /> Rename
408 </button>
409 {}
413 {views[0]?.id !== v.id && (
414 <button
415 type="button"
416 className="flex w-full items-center gap-1.5 rounded px-2 py-1 text-left text-xs hover:bg-muted"
417 disabled={reorderView.isPending}
418 onClick={() => {
419 const id = v.id;
420 setMenuViewId(null);
421 reorderView.mutate(
422 { viewId: id, before: views[0]?.id },
423 {
424 onSuccess: () => toast("success", `${v.name} is now the default view`),
425 onError: (e) => toast("error", "Set default failed", e.message),
426 },
427 );
428 }}
429 >
430 <Flag className="size-3" /> Set as default
431 </button>
432 )}
433 <button
434 type="button"
435 className="flex w-full items-center gap-1.5 rounded px-2 py-1 text-left text-xs text-destructive hover:bg-muted"
436 onClick={() => {
437 setMenuViewId(null);
438 deleteView.mutate(v.id, {
439 onSuccess: () => toast("success", "View deleted"),
440 onError: (e) => toast("error", "Delete failed", e.message),
441 });
442 }}
443 >
444 <Trash2 className="size-3" /> Delete
445 </button>
446 </div>
447 )}
448 </div>
449 );
450 })}
451 {!readOnly && (
452 <button
453 type="button"
454 className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-sm text-muted-foreground hover:bg-muted"
455 onClick={() => setCreateViewOpen(true)}
456 aria-label="Add view"
457 >
458 <ListPlus className="size-4" />
459 Add view
460 </button>
461 )}
462 </div>
463
464 <div className="ml-auto flex items-center gap-2">
465 {activeView && (
466 <DatabaseToolbar databaseId={databaseId} view={activeView} properties={properties} />
467 )}
468 {activeView?.config.buttons?.length ? (
469 <DatabaseButtons
470 databaseId={databaseId}
471 view={activeView}
472 properties={properties}
473 record={panelRecord ?? null}
474 onOpenEditor={() => setButtonsOpen(true)}
475 />
476 ) : (
477 !readOnly && (
478 <button
479 type="button"
480 className="inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs text-muted-foreground hover:bg-muted"
481 onClick={() => setButtonsOpen(true)}
482 title="View action buttons"
483 >
484 <Zap className="size-3.5" /> Buttons
485 </button>
486 )
487 )}
488 <div className="relative" ref={dbAiRef}>
489 <button
490 type="button"
491 data-testid="database-ask-ai"
492 title="Ask AI about this database"
493 className="inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs text-muted-foreground hover:bg-muted"
494 onClick={() => setDbAiOpen((v) => !v)}
495 aria-haspopup="menu"
496 aria-expanded={dbAiOpen}
497 >
498 <Sparkles className="size-3.5" /> AI
499 <ChevronDown className="size-3" aria-hidden />
500 </button>
501 {dbAiOpen ? (
502 <div
503 className="absolute right-0 top-full z-50 mt-1 w-52 rounded-md border border-border bg-popover p-1 shadow-lg"
504 role="menu"
505 aria-label="AI actions"
506 >
507 <button
508 type="button"
509 role="menuitem"
510 data-testid="db-ai-menu-ask"
511 onClick={() => {
512 setDbAiOpen(false);
513 setAiPanel(true, { databaseId, ...(database?.title ? { label: database.title } : {}) });
514 }}
515 className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm hover:bg-accent"
516 >
517 <Sparkles className="size-4 shrink-0 text-muted-foreground" aria-hidden /> Ask AI about this database
518 </button>
519 {!readOnly && (
520 <>
521 <button
522 type="button"
523 role="menuitem"
524 data-testid="db-ai-menu-generate"
525 onClick={() => {
526 setDbAiOpen(false);
527 setDbAiDialog("generate");
528 }}
529 className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm hover:bg-accent"
530 >
531 <Plus className="size-4 shrink-0 text-muted-foreground" aria-hidden /> Generate database
532 </button>
533 <button
534 type="button"
535 role="menuitem"
536 data-testid="db-ai-menu-extract"
537 onClick={() => {
538 setDbAiOpen(false);
539 setDbAiDialog("extract");
540 }}
541 className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm hover:bg-accent"
542 >
543 <TextCursorInput className="size-4 shrink-0 text-muted-foreground" aria-hidden /> Extract from text
544 </button>
545 <button
546 type="button"
547 role="menuitem"
548 data-testid="db-ai-menu-formula"
549 onClick={() => {
550 setDbAiOpen(false);
551 setDbAiDialog("formula");
552 }}
553 className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm hover:bg-accent"
554 >
555 <Sigma className="size-4 shrink-0 text-muted-foreground" aria-hidden /> Generate column with formula
556 </button>
557 </>
558 )}
559 </div>
560 ) : null}
561 </div>
562 <button
563 type="button"
564 className="inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs text-muted-foreground hover:bg-muted"
565 onClick={() => setAddPropertyOpen(true)}
566 title="Add or manage properties"
567 disabled={!!readOnly}
568 >
569 <Plus className="size-3.5" /> Property
570 </button>
571 <button
572 type="button"
573 className="inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs text-muted-foreground hover:bg-muted"
574 onClick={() => {
575 exportCsv(records.data.records);
576 toast(
577 "success",
578 "Exported CSV",
579 `${records.data.records.length} records (currently loaded)`,
580 );
581 }}
582 title="Export the loaded records as CSV"
583 >
584 <Download className="size-3.5" /> CSV
585 </button>
586 </div>
587 </div>
588
589 {}
590 <div className="min-h-0 flex-1">
591 {activeView ? (
592 <ViewRouter
593 database={database}
594 view={activeView}
595 records={records}
596 readOnly={readOnly}
597 callbacks={{
598 onOpenRecord: openRecord,
599 onOpenRecordPage,
600 onSortChange: (sort) =>
601 setOverrides(databaseId, activeView.id, { sort: sort ?? undefined }),
602 onFilterChange: (filter) =>
603 setOverrides(databaseId, activeView.id, {
604 filter: (filter as FilterTree) ?? undefined,
605 }),
606
607
608 onPageSizeChange: (pageSize) => {
609 const id = activeView.id;
610 updateView.mutate(
611 { viewId: id, pageSize },
612 { onError: (e) => toast("error", "Update failed", e.message) },
613 );
614 },
615 }}
616 />
617 ) : (
618 <div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
619 <TableIcon className="size-8 text-muted-foreground" aria-hidden />
620 <p className="text-sm text-muted-foreground">
621 No views yet — create one to start working with records.
622 </p>
623 {!readOnly && (
624 <button
625 type="button"
626 className="inline-flex items-center gap-1 rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground hover:bg-primary/90"
627 onClick={() => setCreateViewOpen(true)}
628 >
629 <Plus className="size-4" /> Create view
630 </button>
631 )}
632 </div>
633 )}
634 </div>
635
636 {}
637 {panelOpen && panelRecord && (
638 <RecordPanel
639 databaseId={databaseId}
640 record={panelRecord}
641 properties={database.properties}
642 onClose={closePanel}
643 readOnly={readOnly}
644 />
645 )}
646
647 {}
648 {createViewOpen && (
649 <ViewDialog
650 title="Create view"
651 submitLabel="Create"
652 busy={createView.isPending}
653 onClose={() => setCreateViewOpen(false)}
654 onSubmit={(name, type) => {
655 createView.mutate(
656 { name, type },
657 {
658 onSuccess: (v) => {
659 setCreateViewOpen(false);
660 selectView(v.id);
661 toast("success", "View created", name);
662 },
663 onError: (e) => toast("error", "Create failed", e.message),
664 },
665 );
666 }}
667 />
668 )}
669
670 {}
671 {editingView && (
672 <ViewDialog
673 title="Rename view"
674 initialName={editingView.name}
675 submitLabel="Save"
676 busy={updateView.isPending}
677 onClose={() => setEditViewId(null)}
678 onSubmit={(name) => {
679 updateView.mutate(
680 { viewId: editingView.id, name },
681 {
682 onSuccess: () => {
683 setEditViewId(null);
684 toast("success", "View renamed");
685 },
686 onError: (e) => toast("error", "Rename failed", e.message),
687 },
688 );
689 }}
690 />
691 )}
692
693 {}
694 {addPropertyOpen && (
695 <PropertyEditor
696 databaseId={databaseId}
697 properties={database.properties}
698 mode="add"
699 workspaceId={workspaceId}
700 onClose={() => setAddPropertyOpen(false)}
701 />
702 )}
703
704 {}
705 {buttonsOpen && activeView && (
706 <ButtonsEditor
707 databaseId={databaseId}
708 view={activeView}
709 properties={database.properties}
710 onClose={() => setButtonsOpen(false)}
711 />
712 )}
713
714 {}
716 {workspaceId && dbAiDialog === "generate" ? (
717 <AiGenerateDialog
718 open
719 workspaceId={workspaceId}
720 workspaceSlug={workspaceSlug}
721 onClose={() => setDbAiDialog(null)}
722 />
723 ) : null}
724 {workspaceId && dbAiDialog === "extract" ? (
725 <AiExtractDialog
726 open
727 workspaceId={workspaceId}
728 databaseId={databaseId}
729 properties={database.properties}
730 onClose={() => setDbAiDialog(null)}
731 />
732 ) : null}
733 {workspaceId && dbAiDialog === "formula" ? (
734 <AiFormulaDialog
735 open
736 workspaceId={workspaceId}
737 databaseId={databaseId}
738 properties={database.properties}
739 records={records.data.records}
740 onClose={() => setDbAiDialog(null)}
741 />
742 ) : null}
743 </div>
744 );
745}
746
747
748
749
750
751function ViewDialog({
752 title,
753 initialName,
754 submitLabel,
755 busy,
756 onClose,
757 onSubmit,
758}: {
759 title: string;
760 initialName?: string;
761 submitLabel: string;
762 busy: boolean;
763 onClose: () => void;
764 onSubmit: (name: string, type: string) => void;
765}) {
766 const [name, setName] = useState(initialName ?? "");
767 const [type, setType] = useState("table");
768 const fixedType = initialName !== undefined;
769
770 return (
771 <div
772 className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
773 role="dialog"
774 aria-modal="true"
775 aria-label={title}
776 >
777 <div className="w-full max-w-sm rounded-lg border bg-background shadow-xl">
778 <div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
779 <span className="text-sm font-medium">{title}</span>
780 <button
781 type="button"
782 className="ml-auto rounded p-1 text-muted-foreground hover:bg-muted"
783 onClick={onClose}
784 aria-label="Close"
785 >
786 <X className="size-4" />
787 </button>
788 </div>
789 <div className="space-y-3 p-4">
790 <div>
791 <label className="mb-1 block text-xs text-muted-foreground">Name</label>
792 <input
793 className="w-full rounded-md border border-input bg-background px-2.5 py-1.5 text-sm outline-none focus-visible:ring-1 focus-visible:ring-ring"
794 value={name}
795 placeholder="e.g. All records"
796 autoFocus
797 onChange={(e) => setName(e.target.value)}
798 onKeyDown={(e) => e.key === "Enter" && name.trim() && onSubmit(name.trim(), type)}
799 />
800 </div>
801 {!fixedType && (
802 <div>
803 <label className="mb-1 block text-xs text-muted-foreground">Type</label>
804 <div className="grid grid-cols-3 gap-1">
805 {VIEW_TYPES.map((t) => {
806 const Icon = VIEW_ICONS[t] ?? TableIcon;
807 return (
808 <button
809 key={t}
810 type="button"
811 className={cn(
812 "flex items-center gap-1.5 rounded-md border px-2 py-1.5 text-xs hover:bg-muted",
813 type === t && "border-primary bg-primary/10 text-primary",
814 )}
815 onClick={() => setType(t)}
816 >
817 <Icon className="size-3.5" aria-hidden />
818 {VIEW_LABELS[t]}
819 </button>
820 );
821 })}
822 </div>
823 </div>
824 )}
825 </div>
826 <div className="flex justify-end gap-2 border-t border-border px-4 py-2.5">
827 <button
828 type="button"
829 className="rounded-md border px-3 py-1.5 text-sm hover:bg-muted"
830 onClick={onClose}
831 >
832 Cancel
833 </button>
834 <button
835 type="button"
836 className="rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-40"
837 disabled={busy || !name.trim()}
838 onClick={() => onSubmit(name.trim(), type)}
839 >
840 {submitLabel}
841 </button>
842 </div>
843 </div>
844 </div>
845 );
846}
847