"use client"; /** * DatabasePageView — the full database home (the `kind: "database"` shape of * the page canvas, PRODUCT-SPEC: a database IS a page). * * - Header: icon + inline title (PATCH database) + record count + Ask AI * (TASK-0122) + CSV export (client-side, from loaded records). * - View tabs: fractional-position ordered; "+" creates a view (9 types); * per-tab menu = rename / delete. The active view id lives in the URL * (?view=) so a lens is shareable; the store holds the last active view * per database for fast returns. * - DatabaseToolbar: ad-hoc filter/sort/group/in-view search (URL-mirrored). * - ViewRouter: renders the active view (table/list/board/gallery/calendar/ * timeline/chart/form/dashboard). * - RecordPanel: right-hand inspector for the selected record. * - ButtonsEditor + DatabaseButtons: view-level action buttons. */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useSearchParams, useRouter } from "next/navigation"; import { BarChart3, CalendarDays, ChevronDown, ClipboardList, Columns3, Download, Flag, GanttChart, LayoutDashboard, LayoutGrid, ListPlus, MoreHorizontal, Pencil, Plus, Rows3, Sigma, Sparkles, Table as TableIcon, TextCursorInput, Trash2, X, Zap, } from "lucide-react"; import { cn, sortByPosition } from "@/lib/utils"; import { toast, useUiStore } from "@/stores/ui-store"; import { useDatabaseStore } from "@/stores/database-store"; import { useAuthStore } from "@/lib/auth-store"; import { useDatabasePresence } from "@/hooks/use-presence"; import { PresenceBar } from "@/components/collaboration/PresenceBar"; import { meApi } from "@/lib/api"; import { andFilters, DatabaseDto, DatabaseView, DatabaseProperty, FilterTree, GroupSpec, RecordDto, searchFilter, SortSpec, useDatabase, useDatabaseRecord, useDatabaseRecords, useCreateView, useDeleteView, useExportCsv, useUpdateDatabase, useUpdateView, useReorderView, } from "@/hooks/use-database"; import { VIEW_TYPES } from "@/lib/constants"; import { DatabaseToolbar } from "@/components/database/database-toolbar"; import { ViewRouter } from "@/components/database/database-view-renderer"; import { RecordPanel } from "@/components/database/record-panel"; import { PropertyEditor } from "@/components/database/property-editor"; import { DatabaseButtons, ButtonsEditor } from "@/components/database/buttons-panel"; import { AiGenerateDialog } from "@/components/database/AiGenerateDialog"; import { AiExtractDialog } from "@/components/database/AiExtractDialog"; import { AiFormulaDialog } from "@/components/database/AiFormulaDialog"; const VIEW_ICONS: Record> = { table: TableIcon, list: Rows3, board: Columns3, gallery: LayoutGrid, calendar: CalendarDays, timeline: GanttChart, chart: BarChart3, dashboard: LayoutDashboard, form: ClipboardList, }; const VIEW_LABELS: Record = { table: "Table", list: "List", board: "Board", gallery: "Gallery", calendar: "Calendar", timeline: "Timeline", chart: "Chart", dashboard: "Dashboard", form: "Form", }; export interface DatabasePageViewProps { databaseId: string; /** Workspace id (API calls, relation target picker). */ workspaceId?: string; /** Workspace slug (URL navigation — /w/:slug/…). */ workspaceSlug?: string; /** Permission level on this database — VIEW/COMMENT render read-only. */ readOnly?: boolean; } export function DatabasePageView({ databaseId, workspaceId, workspaceSlug, readOnly, }: DatabasePageViewProps) { const { data: database, isLoading, error } = useDatabase(databaseId); const updateDatabase = useUpdateDatabase(databaseId); const createView = useCreateView(databaseId); const updateView = useUpdateView(databaseId); const deleteView = useDeleteView(databaseId); const reorderView = useReorderView(databaseId); const exportCsv = useExportCsv(databaseId, database?.properties ?? []); const searchParams = useSearchParams(); const router = useRouter(); // ---- active view (URL ?view= wins; store keeps last choice) ---------------- const storedActive = useDatabaseStore((s) => s.activeViews[databaseId]); const setActiveView = useDatabaseStore((s) => s.setActiveView); const setOverrides = useDatabaseStore((s) => s.setOverrides); const panelOpen = useDatabaseStore((s) => s.panelOpen); const setAiPanel = useUiStore((s) => s.setAiPanel); const panelRecordId = useDatabaseStore((s) => s.panelRecordId); const openPanel = useDatabaseStore((s) => s.openPanel); const closePanel = useDatabaseStore((s) => s.closePanel); // T-153 — record the open as a recently-VIEWED database (covers BOTH the // PageCanvas database branch and the dedicated /databases/:id route, which // renders this component directly). Fire-and-forget. useEffect(() => { if (!databaseId) return; void meApi.recordView("database", databaseId).catch(() => undefined); }, [databaseId]); const views = useMemo(() => sortByPosition(database?.views ?? []), [database?.views]); // Presence (TASK-0093 R6): the database's OTHER peers (the avatars-only // bar — the database has no per-block cursors; the machinery is identical // to the page, over the `database:` channel). const dbSelfUserId = useAuthStore((s) => s.user?.id ?? null); const { presence: dbPresence } = useDatabasePresence(databaseId || null); const dbOthers = useMemo( () => (dbSelfUserId ? dbPresence.filter((p) => p.userId !== dbSelfUserId) : dbPresence), [dbPresence, dbSelfUserId], ); const urlViewId = searchParams.get("view"); const activeViewId = useMemo(() => { const candidates = [urlViewId, storedActive] as (string | null)[]; for (const id of candidates) { if (id && views.some((v) => v.id === id)) return id; } return views[0]?.id ?? null; }, [urlViewId, storedActive, views]); const activeView: DatabaseView | undefined = views.find((v) => v.id === activeViewId) ?? views[0]; // Ad-hoc overrides for the active view. const overrides = useDatabaseStore((s) => activeViewId ? s.overrides[`${databaseId}:${activeViewId}`] : undefined, ); // Sync the URL when the active view changes (shareable lens). // // CRITICAL: never write the URL while no active view is resolved yet // (`activeViewId === null` — the views are still loading, or there are none). // In dev (React StrictMode) this effect runs twice on mount; if the second // run hits `else params.delete("view")` while `views` is empty, it wipes the // `?view=` deep link BEFORE the views fetch resolves. When the views then // load, `activeViewId` falls back to `views[0]` (the seeded table) and this // same effect rewrites `?view=` to it — so deep links to any non-first view // (e.g. a FORM view) silently land on the table. Skipping the write while // unresolved lets the deep link survive to the moment the views load. const hydrated = useRef(false); useEffect(() => { if (!hydrated.current) { hydrated.current = true; if (activeViewId) setActiveView(databaseId, activeViewId); return; } if (!activeViewId) return; // views still loading / none resolved — keep the URL const params = new URLSearchParams(searchParams.toString()); if (params.get("view") !== activeViewId) params.set("view", activeViewId); const qs = params.toString(); router.replace(qs ? `${window.location.pathname}?${qs}` : window.location.pathname, { scroll: false, }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeViewId, databaseId]); const selectView = (viewId: string) => { setActiveView(databaseId, viewId); }; // ---- composed effective state (same composition as DatabaseToolbar) -------- const properties = database?.properties ?? []; const effectiveFilter = useMemo( () => activeView ? andFilters( activeView.filter, overrides?.filter ?? null, searchFilter(properties, overrides?.searchText ?? ""), ) : null, [activeView, overrides?.filter, overrides?.searchText, properties], ); const effectiveSort = useMemo( () => overrides?.sort ?? activeView?.sort ?? null, [overrides?.sort, activeView?.sort], ); const effectiveGroup = useMemo( () => overrides?.group ?? activeView?.group ?? null, [overrides?.group, activeView?.group], ); // T-166: the view's stored rows-per-page is the listing default (the §6 // query reads `view.pageSize`); the hook default (50) is the fallback when // the view never set one. const effectivePageSize = activeView?.pageSize ?? 50; const records = useDatabaseRecords(databaseId, { viewId: activeView?.id ?? null, filter: effectiveFilter, sort: effectiveSort, group: effectiveGroup, pageSize: effectivePageSize, enabled: !!activeView, }); // ---- record panel (store-driven: survives view switches) -------------------- const openRecord = useCallback( (record: RecordDto) => { openPanel(databaseId, record.id); }, [databaseId, openPanel], ); const { data: fetchedRecord } = useDatabaseRecord( panelOpen ? databaseId : undefined, panelOpen ? panelRecordId : undefined, ); const panelRecord: RecordDto | undefined = (panelRecordId ? records.data.records.find((r) => r.id === panelRecordId) : undefined) ?? (panelOpen ? fetchedRecord : undefined); const onOpenRecordPage = useCallback( (record: RecordDto) => { if (record.pageId && workspaceSlug) { // Core app layout owns /w/:workspace/page/:id. router.push(`/w/${workspaceSlug}/page/${record.pageId}`); } }, [workspaceSlug, router], ); // ---- dialogs ------------------------------------------------------------------ const [createViewOpen, setCreateViewOpen] = useState(false); const [editViewId, setEditViewId] = useState(null); const [menuViewId, setMenuViewId] = useState(null); const [addPropertyOpen, setAddPropertyOpen] = useState(false); const [buttonsOpen, setButtonsOpen] = useState(false); // TASK-0124 — Database AI (the header "AI" dropdown + the three dialogs). const [dbAiOpen, setDbAiOpen] = useState(false); const [dbAiDialog, setDbAiDialog] = useState(null); const dbAiRef = useRef(null); // Close the AI dropdown on outside click / Escape. useEffect(() => { if (!dbAiOpen) return; const onDown = (e: MouseEvent) => { if (dbAiRef.current && !dbAiRef.current.contains(e.target as Node)) setDbAiOpen(false); }; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setDbAiOpen(false); }; document.addEventListener("mousedown", onDown); document.addEventListener("keydown", onKey); return () => { document.removeEventListener("mousedown", onDown); document.removeEventListener("keydown", onKey); }; }, [dbAiOpen]); // ---- loading / error ------------------------------------------------------------ if (isLoading) { return (
); } if (error || !database) { return (
This database is unavailable.
); } const editingView = views.find((v) => v.id === editViewId); return (
{/* header */}
updateDatabase.mutate({ title: e.target.value })} aria-label="Database title" /> {database.description && (

{database.description}

)}
{records.data.totalCount ?? records.data.records.length} records · {database.properties.length} properties · {views.length} views
{/* Presence (TASK-0093 R6): the OTHERS' avatar bar (avatars-only — no per-block cursors in a database). Absent at 0 others. */}
{/* view tabs + toolbar row */}
{views.map((v) => { const Icon = VIEW_ICONS[v.type] ?? TableIcon; const active = v.id === activeViewId; return (
{!readOnly && ( )} {menuViewId === v.id && (
{/* T-166: the default view is the first in position order (the active-view fallback is `views[0]`) — move it to the front via the reorder endpoint. Hidden for the view that is already first. */} {views[0]?.id !== v.id && ( )}
)}
); })} {!readOnly && ( )}
{activeView && ( )} {activeView?.config.buttons?.length ? ( setButtonsOpen(true)} /> ) : ( !readOnly && ( ) )}
{dbAiOpen ? (
{!readOnly && ( <> )}
) : null}
{/* active view body */}
{activeView ? ( setOverrides(databaseId, activeView.id, { sort: sort ?? undefined }), onFilterChange: (filter) => setOverrides(databaseId, activeView.id, { filter: (filter as FilterTree) ?? undefined, }), // T-166: rows-per-page is saved ON THE VIEW (`view.pageSize`, // the §6 query's listing default) — not a session override. onPageSizeChange: (pageSize) => { const id = activeView.id; updateView.mutate( { viewId: id, pageSize }, { onError: (e) => toast("error", "Update failed", e.message) }, ); }, }} /> ) : (

No views yet — create one to start working with records.

{!readOnly && ( )}
)}
{/* record panel */} {panelOpen && panelRecord && ( )} {/* create view dialog */} {createViewOpen && ( setCreateViewOpen(false)} onSubmit={(name, type) => { createView.mutate( { name, type }, { onSuccess: (v) => { setCreateViewOpen(false); selectView(v.id); toast("success", "View created", name); }, onError: (e) => toast("error", "Create failed", e.message), }, ); }} /> )} {/* rename view dialog */} {editingView && ( setEditViewId(null)} onSubmit={(name) => { updateView.mutate( { viewId: editingView.id, name }, { onSuccess: () => { setEditViewId(null); toast("success", "View renamed"); }, onError: (e) => toast("error", "Rename failed", e.message), }, ); }} /> )} {/* add property */} {addPropertyOpen && ( setAddPropertyOpen(false)} /> )} {/* buttons editor */} {buttonsOpen && activeView && ( setButtonsOpen(false)} /> )} {/* TASK-0124 — Database AI dialogs (gated by workspaceId; the write actions are already hidden for non-EDIT roles in the header menu). */} {workspaceId && dbAiDialog === "generate" ? ( setDbAiDialog(null)} /> ) : null} {workspaceId && dbAiDialog === "extract" ? ( setDbAiDialog(null)} /> ) : null} {workspaceId && dbAiDialog === "formula" ? ( setDbAiDialog(null)} /> ) : null}
); } // --------------------------------------------------------------------------- // View create/rename dialog // --------------------------------------------------------------------------- function ViewDialog({ title, initialName, submitLabel, busy, onClose, onSubmit, }: { title: string; initialName?: string; submitLabel: string; busy: boolean; onClose: () => void; onSubmit: (name: string, type: string) => void; }) { const [name, setName] = useState(initialName ?? ""); const [type, setType] = useState("table"); const fixedType = initialName !== undefined; return (
{title}
setName(e.target.value)} onKeyDown={(e) => e.key === "Enter" && name.trim() && onSubmit(name.trim(), type)} />
{!fixedType && (
{VIEW_TYPES.map((t) => { const Icon = VIEW_ICONS[t] ?? TableIcon; return ( ); })}
)}
); }