From be37e3ffe309c36b267325249868c9e317d84a3c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 31 Mar 2026 18:15:10 +0000 Subject: [PATCH 01/12] Initial plan From c8f20dae7ffd54591819fbeeaffc90d1a077f095 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 31 Mar 2026 18:35:56 +0000 Subject: [PATCH 02/12] Refactor page builder: @dnd-kit DnD, decomposed components, improved block registry and renderer - Replace native HTML5 DnD with @dnd-kit/core + @dnd-kit/sortable for smooth drag animations, proper drag handles, visual feedback, and keyboard accessibility - Decompose monolithic AdminPageBuilderPage (459 lines) into focused sub-components: - BuilderCanvas: @dnd-kit sortable canvas with drag handles, selection state, empty state - BlockPalette: categorized block palette with variant counts and category icons - BlockEditor: section editor with pill-based variant picker (replaces raw text input) - FeatureListEditor: controlled feature editing with expandable fields (description, icon, link) - ActionListEditor: controlled action editing with variant dropdown, icon field, external toggle - builder-types: shared types for builder components - AdminPageBuilderPage is now a thin orchestrator (~250 lines) composing sub-components - Fix direct data mutation anti-pattern in feature/action editing (was: feature.title = e.target.value) - Add 'about' block type to registry (matches Next.js homepage slots) - Add registerCustomBlock() API for apps to extend the block registry at runtime - Improve MarketingPageRenderer with slot-aware visual rendering (hero gradient, feature cards/grid/list, CTA banner variant, navbar, footer) instead of generic bordered sections - Add vitest include pattern for worker/ test directory - Update and expand tests: 4 tests covering registry, custom registration, core slots, variants Agent-Logs-Url: https://github.com/thinkdj/ottabase/sessions/1935a94b-0dd9-49a6-8696-3a0791c05a64 --- .../package.json | 3 + .../pages/admin/pages/ActionListEditor.tsx | 143 +++++ .../admin/pages/AdminPageBuilderPage.tsx | 544 +++++++----------- .../src/pages/admin/pages/BlockEditor.tsx | 175 ++++++ .../src/pages/admin/pages/BlockPalette.tsx | 65 +++ .../src/pages/admin/pages/BuilderCanvas.tsx | 152 +++++ .../pages/admin/pages/FeatureListEditor.tsx | 126 ++++ .../src/pages/admin/pages/builder-types.ts | 25 + .../pages/marketing/MarketingPageRenderer.tsx | 216 +++++-- .../vitest.config.ts | 1 + .../routes/__tests__/marketing-pages.test.ts | 44 +- .../worker/routes/marketing-pages.ts | 42 +- pnpm-lock.yaml | 278 ++++----- 13 files changed, 1255 insertions(+), 559 deletions(-) create mode 100644 apps/ottabase-template-app-tanstack/src/pages/admin/pages/ActionListEditor.tsx create mode 100644 apps/ottabase-template-app-tanstack/src/pages/admin/pages/BlockEditor.tsx create mode 100644 apps/ottabase-template-app-tanstack/src/pages/admin/pages/BlockPalette.tsx create mode 100644 apps/ottabase-template-app-tanstack/src/pages/admin/pages/BuilderCanvas.tsx create mode 100644 apps/ottabase-template-app-tanstack/src/pages/admin/pages/FeatureListEditor.tsx create mode 100644 apps/ottabase-template-app-tanstack/src/pages/admin/pages/builder-types.ts diff --git a/apps/ottabase-template-app-tanstack/package.json b/apps/ottabase-template-app-tanstack/package.json index e87809193..84ddbcf19 100644 --- a/apps/ottabase-template-app-tanstack/package.json +++ b/apps/ottabase-template-app-tanstack/package.json @@ -21,6 +21,9 @@ }, "dependencies": { "@auth/core": "catalog:", + "@dnd-kit/core": "6.3.1", + "@dnd-kit/sortable": "10.0.0", + "@dnd-kit/utilities": "3.2.2", "@hookform/resolvers": "catalog:", "@mantine/carousel": "catalog:", "@mantine/core": "catalog:", diff --git a/apps/ottabase-template-app-tanstack/src/pages/admin/pages/ActionListEditor.tsx b/apps/ottabase-template-app-tanstack/src/pages/admin/pages/ActionListEditor.tsx new file mode 100644 index 000000000..46cffaeb4 --- /dev/null +++ b/apps/ottabase-template-app-tanstack/src/pages/admin/pages/ActionListEditor.tsx @@ -0,0 +1,143 @@ +import { Button, Input, Label, Switch } from '@ottabase/ui-shadcn'; +import { Plus, Trash2 } from 'lucide-react'; +import { useCallback, useState } from 'react'; + +interface ActionItem { + id: string; + label: string; + href: string; + variant: string; + icon?: string; + external: boolean; + sortOrder: number; +} + +interface ActionListEditorProps { + actions: ActionItem[]; + onAdd: () => void; + onUpdate: (id: string, data: Partial) => void; + onDelete: (id: string) => void; + isPending?: boolean; +} + +const ACTION_VARIANTS = ['primary', 'secondary', 'outline', 'ghost', 'link'] as const; + +/** Single action row with controlled state. */ +function ActionRow({ + action, + onUpdate, + onDelete, +}: { + action: ActionItem; + onUpdate: (id: string, data: Partial) => void; + onDelete: (id: string) => void; +}) { + const [local, setLocal] = useState(action); + const [expanded, setExpanded] = useState(false); + + const commit = useCallback(() => { + const changed: Partial = {}; + if (local.label !== action.label) changed.label = local.label; + if (local.href !== action.href) changed.href = local.href; + if (local.variant !== action.variant) changed.variant = local.variant; + if (local.icon !== action.icon) changed.icon = local.icon; + if (local.external !== action.external) changed.external = local.external; + if (Object.keys(changed).length > 0) { + onUpdate(action.id, changed); + } + }, [local, action, onUpdate]); + + return ( +
+
+ setLocal({ ...local, label: e.target.value })} + onBlur={commit} + /> + setLocal({ ...local, href: e.target.value })} + onBlur={commit} + /> + + +
+ {expanded && ( +
+
+
+ + +
+
+ + setLocal({ ...local, icon: e.target.value })} + onBlur={commit} + /> +
+
+
+ + { + setLocal({ ...local, external: val }); + onUpdate(action.id, { external: val }); + }} + /> +
+
+ )} +
+ ); +} + +/** Managed list of actions for a section. */ +export function ActionListEditor({ actions, onAdd, onUpdate, onDelete, isPending }: ActionListEditorProps) { + return ( +
+
+

Actions

+ +
+ {actions.length === 0 && ( +

No actions. Add one above.

+ )} + {actions.map((action) => ( + + ))} +
+ ); +} diff --git a/apps/ottabase-template-app-tanstack/src/pages/admin/pages/AdminPageBuilderPage.tsx b/apps/ottabase-template-app-tanstack/src/pages/admin/pages/AdminPageBuilderPage.tsx index 3d74525c5..eb7320af4 100644 --- a/apps/ottabase-template-app-tanstack/src/pages/admin/pages/AdminPageBuilderPage.tsx +++ b/apps/ottabase-template-app-tanstack/src/pages/admin/pages/AdminPageBuilderPage.tsx @@ -1,42 +1,24 @@ -import { actionHooks, pageHooks, sectionHooks, useBlocksRegistry, featureHooks } from '@/hooks/marketingPageHooks'; +import { actionHooks, featureHooks, pageHooks, sectionHooks, useBlocksRegistry } from '@/hooks/marketingPageHooks'; import { globalStore, organizationIdAtom, userAtom } from '@/ottabase/state/appState'; -import { - Badge, - Button, - Card, - CardContent, - CardHeader, - CardTitle, - Input, - Label, - Switch, - Textarea, -} from '@ottabase/ui-shadcn'; +import { Badge, Button, Card, CardContent, Input, Label } from '@ottabase/ui-shadcn'; import { Link, useParams } from '@tanstack/react-router'; -import { GripVertical, Plus, Save, Trash2 } from 'lucide-react'; -import { useEffect, useMemo, useState } from 'react'; +import { Save } from 'lucide-react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { toast } from 'sonner'; - -type EditableBlock = { - id: string; - title?: string; - subtitle?: string; - body?: string; - variant?: string; - enabled?: boolean; -}; +import { BlockEditor } from './BlockEditor'; +import { BlockPalette } from './BlockPalette'; +import { BuilderCanvas } from './BuilderCanvas'; +import type { BlockDefinition, EditableBlock, PageDraft } from './builder-types'; export function AdminPageBuilderPage() { const { pageId } = useParams({ from: '/admin/pages/$pageId' }); const [selectedId, setSelectedId] = useState(null); - const [draft, setDraft] = useState(null); - const [pageDraft, setPageDraft] = useState<{ id: string; title: string; slug: string; status: string } | null>( - null, - ); + const [pageDraft, setPageDraft] = useState(null); const organizationId = globalStore.get(organizationIdAtom) || null; const userId = globalStore.get(userAtom)?.id || null; + // --- Data queries --- const pageQuery = pageHooks.useDetail(pageId); const sectionList = sectionHooks.useList({ filters: { pageId } as any }); const registry = useBlocksRegistry(); @@ -57,71 +39,161 @@ export function AdminPageBuilderPage() { const updatePage = pageHooks.useUpdate(); + // --- Derived data --- const sections = useMemo(() => { const rows = (sectionList.data?.data ?? []) as any[]; return [...rows].sort((a, b) => Number(a.sortOrder) - Number(b.sortOrder)); }, [sectionList.data?.data]); - const selected = sections.find((section) => section.id === selectedId) ?? null; + const selected = sections.find((s) => s.id === selectedId) ?? null; + const selectedFeatures = useMemo(() => { const rows = (featureList.data?.data ?? []) as any[]; return [...rows].sort((a, b) => Number(a.sortOrder) - Number(b.sortOrder)); }, [featureList.data?.data]); + const selectedActions = useMemo(() => { const rows = (actionList.data?.data ?? []) as any[]; return [...rows].sort((a, b) => Number(a.sortOrder) - Number(b.sortOrder)); }, [actionList.data?.data]); - useEffect(() => { - if (!selected) { - setDraft(null); - return; - } - setDraft({ - id: selected.id, - title: selected.title, - subtitle: selected.subtitle, - body: selected.body, - variant: selected.variant, - enabled: selected.enabled, - }); - }, [selected?.id]); + const registryBlocks: BlockDefinition[] = registry.data?.blocks ?? []; + // --- Sync page draft --- useEffect(() => { const page = (pageQuery.data as any)?.data; if (!page) return; - setPageDraft({ - id: page.id, - title: page.title || '', - slug: page.slug || '', - status: page.status || 'draft', - }); + setPageDraft({ id: page.id, title: page.title || '', slug: page.slug || '', status: page.status || 'draft' }); }, [(pageQuery.data as any)?.data?.id, (pageQuery.data as any)?.data?.updatedAt]); - const reorder = async (dragId: string, dropId: string) => { - if (dragId === dropId) return; - const ordered = [...sections]; - const from = ordered.findIndex((row) => row.id === dragId); - const to = ordered.findIndex((row) => row.id === dropId); - if (from < 0 || to < 0) return; + // --- Handlers --- + const handleAddBlock = useCallback( + async (block: BlockDefinition) => { + await createSection.mutateAsync({ + pageId, + appId: 'ottabase-template-app', + organizationId, + userId, + slot: block.id, + variant: block.variants[0]?.id || 'default', + title: block.label, + enabled: true, + sortOrder: sections.length, + }); + toast.success(`${block.label} added`); + await sectionList.refetch(); + }, + [pageId, organizationId, userId, sections.length, createSection, sectionList], + ); + + const handleReorder = useCallback( + async (activeId: string, overId: string) => { + const ordered = [...sections]; + const from = ordered.findIndex((r) => r.id === activeId); + const to = ordered.findIndex((r) => r.id === overId); + if (from < 0 || to < 0) return; + const [moved] = ordered.splice(from, 1); + ordered.splice(to, 0, moved); + await Promise.all( + ordered.map((section, index) => updateSection.mutateAsync({ id: section.id, sortOrder: index })), + ); + toast.success('Blocks reordered'); + await sectionList.refetch(); + }, + [sections, updateSection, sectionList], + ); + + const handleSaveBlock = useCallback( + async (draft: EditableBlock) => { + await updateSection.mutateAsync({ + id: draft.id, + title: draft.title, + subtitle: draft.subtitle, + body: draft.body, + variant: draft.variant, + enabled: draft.enabled, + }); + toast.success('Block saved'); + await sectionList.refetch(); + }, + [updateSection, sectionList], + ); + + const handleDeleteBlock = useCallback( + async (id: string) => { + await deleteSection.mutateAsync(id); + setSelectedId(null); + toast.success('Block deleted'); + await sectionList.refetch(); + }, + [deleteSection, sectionList], + ); + + const handleAddFeature = useCallback(async () => { + if (!selected) return; + await createFeature.mutateAsync({ + sectionId: selected.id, + appId: 'ottabase-template-app', + organizationId, + userId, + title: `Feature ${selectedFeatures.length + 1}`, + description: '', + sortOrder: selectedFeatures.length, + }); + await featureList.refetch(); + }, [selected, organizationId, userId, selectedFeatures.length, createFeature, featureList]); + + const handleUpdateFeature = useCallback( + async (id: string, data: Record) => { + await updateFeature.mutateAsync({ id, ...data }); + await featureList.refetch(); + }, + [updateFeature, featureList], + ); + + const handleDeleteFeature = useCallback( + async (id: string) => { + await deleteFeature.mutateAsync(id); + await featureList.refetch(); + }, + [deleteFeature, featureList], + ); - const [moved] = ordered.splice(from, 1); - ordered.splice(to, 0, moved); + const handleAddAction = useCallback(async () => { + if (!selected) return; + await createAction.mutateAsync({ + sectionId: selected.id, + appId: 'ottabase-template-app', + organizationId, + userId, + label: `Action ${selectedActions.length + 1}`, + href: '/signup', + variant: 'primary', + external: false, + sortOrder: selectedActions.length, + }); + await actionList.refetch(); + }, [selected, organizationId, userId, selectedActions.length, createAction, actionList]); - await Promise.all( - ordered.map((section, index) => - updateSection.mutateAsync({ - id: section.id, - sortOrder: index, - }), - ), - ); - toast.success('Blocks reordered'); - await sectionList.refetch(); - }; + const handleUpdateAction = useCallback( + async (id: string, data: Record) => { + await updateAction.mutateAsync({ id, ...data }); + await actionList.refetch(); + }, + [updateAction, actionList], + ); + + const handleDeleteAction = useCallback( + async (id: string) => { + await deleteAction.mutateAsync(id); + await actionList.refetch(); + }, + [deleteAction, actionList], + ); return (
+ {/* Header */}
@@ -129,17 +201,16 @@ export function AdminPageBuilderPage() {

{pageDraft?.title || 'Page Builder'}

- End-to-end builder with sortable blocks, features and actions. + Drag-and-drop builder with sortable blocks, features and actions.

+ {/* Page settings bar */}
- setPageDraft((prev) => (prev ? { ...prev, title: event.target.value } : prev)) - } + onChange={(e) => setPageDraft((p) => (p ? { ...p, title: e.target.value } : p))} />
- setPageDraft((prev) => (prev ? { ...prev, slug: event.target.value } : prev)) - } + onChange={(e) => setPageDraft((p) => (p ? { ...p, slug: e.target.value } : p))} />
- ))} -
-
- - - - Canvas - - - {sections.map((section) => ( -
event.dataTransfer.setData('text/plain', section.id)} - onDragOver={(event) => event.preventDefault()} - onDrop={async (event) => { - event.preventDefault(); - const dragId = event.dataTransfer.getData('text/plain'); - await reorder(dragId, section.id); - }} - onClick={() => setSelectedId(section.id)} - className={`cursor-pointer rounded-md border p-3 ${selectedId === section.id ? 'border-primary' : ''}`} - > -
-
-

{section.slot}

-

- {section.variant} • {section.enabled ? 'enabled' : 'disabled'} -

-
- -
-
- ))} -
-
- - - - Block Editor - - - {!selected || !draft ? ( -

Select a block from the canvas to edit.

- ) : ( - <> -
- - setDraft({ ...draft, title: event.target.value })} - /> -
-
- - setDraft({ ...draft, subtitle: event.target.value })} - /> -
-
- -