@actuate-media/cms-admin 0.67.0 → 0.68.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/AdminRoot.d.ts.map +1 -1
  3. package/dist/AdminRoot.js +29 -5
  4. package/dist/AdminRoot.js.map +1 -1
  5. package/dist/__tests__/hooks/useIdleLogout.test.d.ts +2 -0
  6. package/dist/__tests__/hooks/useIdleLogout.test.d.ts.map +1 -0
  7. package/dist/__tests__/hooks/useIdleLogout.test.js +64 -0
  8. package/dist/__tests__/hooks/useIdleLogout.test.js.map +1 -0
  9. package/dist/__tests__/lib/post-types-service.test.d.ts +2 -0
  10. package/dist/__tests__/lib/post-types-service.test.d.ts.map +1 -0
  11. package/dist/__tests__/lib/post-types-service.test.js +88 -0
  12. package/dist/__tests__/lib/post-types-service.test.js.map +1 -0
  13. package/dist/__tests__/views/collection-list-nav.render.test.js +43 -5
  14. package/dist/__tests__/views/collection-list-nav.render.test.js.map +1 -1
  15. package/dist/__tests__/views/forms-list.render.test.js +25 -6
  16. package/dist/__tests__/views/forms-list.render.test.js.map +1 -1
  17. package/dist/__tests__/views/page-builder-sections-panel.render.test.d.ts +2 -0
  18. package/dist/__tests__/views/page-builder-sections-panel.render.test.d.ts.map +1 -0
  19. package/dist/__tests__/views/page-builder-sections-panel.render.test.js +121 -0
  20. package/dist/__tests__/views/page-builder-sections-panel.render.test.js.map +1 -0
  21. package/dist/__tests__/views/page-templates.render.test.d.ts +2 -0
  22. package/dist/__tests__/views/page-templates.render.test.d.ts.map +1 -0
  23. package/dist/__tests__/views/page-templates.render.test.js +105 -0
  24. package/dist/__tests__/views/page-templates.render.test.js.map +1 -0
  25. package/dist/__tests__/views/post-type-editor.render.test.d.ts +2 -0
  26. package/dist/__tests__/views/post-type-editor.render.test.d.ts.map +1 -0
  27. package/dist/__tests__/views/post-type-editor.render.test.js +102 -0
  28. package/dist/__tests__/views/post-type-editor.render.test.js.map +1 -0
  29. package/dist/__tests__/views/security-settings.render.test.js +85 -4
  30. package/dist/__tests__/views/security-settings.render.test.js.map +1 -1
  31. package/dist/__tests__/views/seo-content-pane.render.test.d.ts +2 -0
  32. package/dist/__tests__/views/seo-content-pane.render.test.d.ts.map +1 -0
  33. package/dist/__tests__/views/seo-content-pane.render.test.js +117 -0
  34. package/dist/__tests__/views/seo-content-pane.render.test.js.map +1 -0
  35. package/dist/actuate-admin.css +1 -1
  36. package/dist/components/collections/EntryDetailPane.d.ts +10 -0
  37. package/dist/components/collections/EntryDetailPane.d.ts.map +1 -0
  38. package/dist/components/collections/EntryDetailPane.js +92 -0
  39. package/dist/components/collections/EntryDetailPane.js.map +1 -0
  40. package/dist/components/forms/FieldsPanel.js +1 -1
  41. package/dist/components/forms/FieldsPanel.js.map +1 -1
  42. package/dist/components/forms/FormSchemaPane.d.ts +8 -0
  43. package/dist/components/forms/FormSchemaPane.d.ts.map +1 -0
  44. package/dist/components/forms/FormSchemaPane.js +95 -0
  45. package/dist/components/forms/FormSchemaPane.js.map +1 -0
  46. package/dist/components/seo/SeoEditorPane.d.ts +9 -0
  47. package/dist/components/seo/SeoEditorPane.d.ts.map +1 -0
  48. package/dist/components/seo/SeoEditorPane.js +175 -0
  49. package/dist/components/seo/SeoEditorPane.js.map +1 -0
  50. package/dist/components/ui/Badge.d.ts +1 -1
  51. package/dist/components/ui/Button.d.ts +1 -1
  52. package/dist/components/ui/StatusBadge.d.ts +1 -1
  53. package/dist/hooks/useIdleLogout.d.ts +18 -0
  54. package/dist/hooks/useIdleLogout.d.ts.map +1 -0
  55. package/dist/hooks/useIdleLogout.js +66 -0
  56. package/dist/hooks/useIdleLogout.js.map +1 -0
  57. package/dist/lib/api.d.ts +1 -0
  58. package/dist/lib/api.d.ts.map +1 -1
  59. package/dist/lib/api.js +7 -1
  60. package/dist/lib/api.js.map +1 -1
  61. package/dist/lib/post-types-service.d.ts +44 -0
  62. package/dist/lib/post-types-service.d.ts.map +1 -0
  63. package/dist/lib/post-types-service.js +83 -0
  64. package/dist/lib/post-types-service.js.map +1 -0
  65. package/dist/views/CollectionList.d.ts.map +1 -1
  66. package/dist/views/CollectionList.js +23 -4
  67. package/dist/views/CollectionList.js.map +1 -1
  68. package/dist/views/Forms.d.ts.map +1 -1
  69. package/dist/views/Forms.js +25 -21
  70. package/dist/views/Forms.js.map +1 -1
  71. package/dist/views/Posts/PostTypeEditor.d.ts +16 -0
  72. package/dist/views/Posts/PostTypeEditor.d.ts.map +1 -0
  73. package/dist/views/Posts/PostTypeEditor.js +159 -0
  74. package/dist/views/Posts/PostTypeEditor.js.map +1 -0
  75. package/dist/views/Posts/PostTypesView.d.ts +3 -2
  76. package/dist/views/Posts/PostTypesView.d.ts.map +1 -1
  77. package/dist/views/Posts/PostTypesView.js +1 -1
  78. package/dist/views/Posts/PostTypesView.js.map +1 -1
  79. package/dist/views/page-builder/BuilderSectionsPanel.d.ts +20 -0
  80. package/dist/views/page-builder/BuilderSectionsPanel.d.ts.map +1 -0
  81. package/dist/views/page-builder/BuilderSectionsPanel.js +105 -0
  82. package/dist/views/page-builder/BuilderSectionsPanel.js.map +1 -0
  83. package/dist/views/page-builder/PageBuilder.d.ts.map +1 -1
  84. package/dist/views/page-builder/PageBuilder.js +51 -2
  85. package/dist/views/page-builder/PageBuilder.js.map +1 -1
  86. package/dist/views/page-builder/PageTemplates.d.ts.map +1 -1
  87. package/dist/views/page-builder/PageTemplates.js +113 -6
  88. package/dist/views/page-builder/PageTemplates.js.map +1 -1
  89. package/dist/views/seo/ContentTab.d.ts.map +1 -1
  90. package/dist/views/seo/ContentTab.js +22 -6
  91. package/dist/views/seo/ContentTab.js.map +1 -1
  92. package/dist/views/settings/AccessProtectionCard.d.ts +5 -1
  93. package/dist/views/settings/AccessProtectionCard.d.ts.map +1 -1
  94. package/dist/views/settings/AccessProtectionCard.js +13 -10
  95. package/dist/views/settings/AccessProtectionCard.js.map +1 -1
  96. package/dist/views/settings/SecurityTab.d.ts.map +1 -1
  97. package/dist/views/settings/SecurityTab.js +3 -3
  98. package/dist/views/settings/SecurityTab.js.map +1 -1
  99. package/dist/views/settings/SessionPolicyCard.d.ts +1 -2
  100. package/dist/views/settings/SessionPolicyCard.d.ts.map +1 -1
  101. package/dist/views/settings/SessionPolicyCard.js +12 -5
  102. package/dist/views/settings/SessionPolicyCard.js.map +1 -1
  103. package/dist/views/settings/useSecuritySettings.d.ts +13 -1
  104. package/dist/views/settings/useSecuritySettings.d.ts.map +1 -1
  105. package/dist/views/settings/useSecuritySettings.js +47 -4
  106. package/dist/views/settings/useSecuritySettings.js.map +1 -1
  107. package/package.json +2 -2
  108. package/src/AdminRoot.tsx +36 -7
  109. package/src/__tests__/hooks/useIdleLogout.test.tsx +86 -0
  110. package/src/__tests__/lib/post-types-service.test.ts +112 -0
  111. package/src/__tests__/views/collection-list-nav.render.test.tsx +51 -5
  112. package/src/__tests__/views/forms-list.render.test.tsx +34 -6
  113. package/src/__tests__/views/page-builder-sections-panel.render.test.tsx +138 -0
  114. package/src/__tests__/views/page-templates.render.test.tsx +142 -0
  115. package/src/__tests__/views/post-type-editor.render.test.tsx +116 -0
  116. package/src/__tests__/views/security-settings.render.test.tsx +111 -4
  117. package/src/__tests__/views/seo-content-pane.render.test.tsx +142 -0
  118. package/src/components/collections/EntryDetailPane.tsx +182 -0
  119. package/src/components/forms/FieldsPanel.tsx +3 -1
  120. package/src/components/forms/FormSchemaPane.tsx +192 -0
  121. package/src/components/seo/SeoEditorPane.tsx +533 -0
  122. package/src/hooks/useIdleLogout.ts +77 -0
  123. package/src/lib/api.ts +8 -2
  124. package/src/lib/post-types-service.ts +129 -0
  125. package/src/views/CollectionList.tsx +158 -110
  126. package/src/views/Forms.tsx +154 -126
  127. package/src/views/Posts/PostTypeEditor.tsx +568 -0
  128. package/src/views/Posts/PostTypesView.tsx +5 -4
  129. package/src/views/page-builder/BuilderSectionsPanel.tsx +304 -0
  130. package/src/views/page-builder/PageBuilder.tsx +69 -2
  131. package/src/views/page-builder/PageTemplates.tsx +340 -57
  132. package/src/views/seo/ContentTab.tsx +113 -80
  133. package/src/views/settings/AccessProtectionCard.tsx +83 -53
  134. package/src/views/settings/SecurityTab.tsx +23 -2
  135. package/src/views/settings/SessionPolicyCard.tsx +45 -9
  136. package/src/views/settings/useSecuritySettings.ts +78 -22
  137. package/dist/components/forms/FormSchemaDrawer.d.ts +0 -9
  138. package/dist/components/forms/FormSchemaDrawer.d.ts.map +0 -1
  139. package/dist/components/forms/FormSchemaDrawer.js +0 -99
  140. package/dist/components/forms/FormSchemaDrawer.js.map +0 -1
  141. package/dist/components/seo/SeoEditorDrawer.d.ts +0 -8
  142. package/dist/components/seo/SeoEditorDrawer.d.ts.map +0 -1
  143. package/dist/components/seo/SeoEditorDrawer.js +0 -172
  144. package/dist/components/seo/SeoEditorDrawer.js.map +0 -1
  145. package/src/components/forms/FormSchemaDrawer.tsx +0 -192
  146. package/src/components/seo/SeoEditorDrawer.tsx +0 -529
@@ -0,0 +1,304 @@
1
+ 'use client'
2
+
3
+ import { useMemo } from 'react'
4
+ import {
5
+ DndContext,
6
+ KeyboardSensor,
7
+ PointerSensor,
8
+ closestCenter,
9
+ useSensor,
10
+ useSensors,
11
+ type DragEndEvent,
12
+ } from '@dnd-kit/core'
13
+ import {
14
+ SortableContext,
15
+ sortableKeyboardCoordinates,
16
+ useSortable,
17
+ verticalListSortingStrategy,
18
+ } from '@dnd-kit/sortable'
19
+ import { CSS } from '@dnd-kit/utilities'
20
+ import {
21
+ Bookmark,
22
+ Code,
23
+ FileText,
24
+ Grid,
25
+ GripVertical,
26
+ HelpCircle,
27
+ Image,
28
+ LayoutGrid,
29
+ LayoutTemplate,
30
+ MousePointer,
31
+ PanelLeftClose,
32
+ PanelLeftOpen,
33
+ Play,
34
+ Plus,
35
+ Star,
36
+ Type,
37
+ type LucideIcon,
38
+ } from 'lucide-react'
39
+ import { BlockCatalog } from '@actuate-media/cms-core/page-builder'
40
+ import type { BuilderNode, PageNode } from '@actuate-media/cms-core'
41
+
42
+ export interface BuilderSectionsPanelProps {
43
+ tree: PageNode
44
+ selectedNodeId: string | null
45
+ /** Collapsed to a slim rail. State is owned by PageBuilder (persisted). */
46
+ collapsed: boolean
47
+ onToggleCollapsed: () => void
48
+ onSelect: (id: string) => void
49
+ /** Move a top-level section to `toIndex` within the page. */
50
+ onReorder: (id: string, toIndex: number) => void
51
+ onAddSection: () => void
52
+ }
53
+
54
+ interface SectionEntry {
55
+ id: string
56
+ name: string
57
+ meta: string
58
+ icon: LucideIcon
59
+ hidden: boolean
60
+ }
61
+
62
+ /** Same icon-name → component mapping the BlockPicker uses for the catalog. */
63
+ const BLOCK_ICONS: Record<string, LucideIcon> = {
64
+ Star,
65
+ Type,
66
+ Image,
67
+ LayoutGrid,
68
+ MousePointer,
69
+ Play,
70
+ Grid,
71
+ HelpCircle,
72
+ FileText,
73
+ Code,
74
+ }
75
+
76
+ function collectBlockTypes(node: BuilderNode, out: string[]): void {
77
+ if (node.type === 'block') {
78
+ out.push(node.settings.blockType)
79
+ return
80
+ }
81
+ if (node.type === 'savedSectionRef') return
82
+ for (const child of node.children) collectBlockTypes(child, out)
83
+ }
84
+
85
+ function humanize(type: string): string {
86
+ return type.charAt(0).toUpperCase() + type.slice(1)
87
+ }
88
+
89
+ /**
90
+ * Builder sections have no user-facing title, so each row derives its name
91
+ * from the section's first block (via the catalog label) and summarizes its
92
+ * contents as the kind label — enough for the editor to recognize and jump
93
+ * between sections without inventing data we don't store.
94
+ */
95
+ function buildEntries(tree: PageNode, catalog: BlockCatalog): SectionEntry[] {
96
+ return tree.children.map((node, index) => {
97
+ if (node.type === 'savedSectionRef') {
98
+ return {
99
+ id: node.id,
100
+ name: 'Saved section',
101
+ meta: 'From the section library',
102
+ icon: Bookmark,
103
+ hidden: false,
104
+ }
105
+ }
106
+ const blockTypes: string[] = []
107
+ collectBlockTypes(node, blockTypes)
108
+ const firstType = blockTypes[0]
109
+ const def = firstType ? catalog.get(firstType) : undefined
110
+ return {
111
+ id: node.id,
112
+ name: def?.label ?? (firstType ? humanize(firstType) : `Section ${index + 1}`),
113
+ meta:
114
+ blockTypes.length === 0
115
+ ? 'Empty section'
116
+ : `${blockTypes.length} block${blockTypes.length === 1 ? '' : 's'}`,
117
+ icon: (def && BLOCK_ICONS[def.icon]) ?? LayoutTemplate,
118
+ hidden: node.settings.visibility === 'hidden',
119
+ }
120
+ })
121
+ }
122
+
123
+ const ICON_BUTTON_CLASS =
124
+ 'flex h-8 w-8 items-center justify-center rounded-[7px] text-[var(--sub)] transition-colors duration-[var(--motion-fast)] hover:bg-[var(--acc-l)] hover:text-[var(--acc)] focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none'
125
+
126
+ /**
127
+ * Design-parity left rail for the Page Builder (PageBuilder.html): the page's
128
+ * sections listed in order with drag-to-reorder, click-to-select (which
129
+ * highlights the section on the canvas and opens its settings in the existing
130
+ * right-hand context panel), and an "Add Section" affordance at the bottom.
131
+ */
132
+ export function BuilderSectionsPanel({
133
+ tree,
134
+ selectedNodeId,
135
+ collapsed,
136
+ onToggleCollapsed,
137
+ onSelect,
138
+ onReorder,
139
+ onAddSection,
140
+ }: BuilderSectionsPanelProps) {
141
+ const catalog = useMemo(() => new BlockCatalog(), [])
142
+ const entries = useMemo(() => buildEntries(tree, catalog), [tree, catalog])
143
+
144
+ const sensors = useSensors(
145
+ useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
146
+ useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
147
+ )
148
+
149
+ function handleDragEnd(event: DragEndEvent) {
150
+ const { active, over } = event
151
+ if (!over || active.id === over.id) return
152
+ const ids = entries.map((e) => e.id)
153
+ const to = ids.indexOf(String(over.id))
154
+ if (to === -1 || !ids.includes(String(active.id))) return
155
+ onReorder(String(active.id), to)
156
+ }
157
+
158
+ if (collapsed) {
159
+ return (
160
+ <nav
161
+ aria-label="Page sections"
162
+ className="flex w-11 shrink-0 flex-col items-center border-r border-[var(--bdr)] bg-[var(--sb)] py-2"
163
+ >
164
+ <button
165
+ type="button"
166
+ onClick={onToggleCollapsed}
167
+ aria-expanded={false}
168
+ aria-label="Expand sections panel"
169
+ className={ICON_BUTTON_CLASS}
170
+ >
171
+ <PanelLeftOpen size={16} aria-hidden />
172
+ </button>
173
+ </nav>
174
+ )
175
+ }
176
+
177
+ return (
178
+ <nav
179
+ aria-label="Page sections"
180
+ className="flex w-[280px] shrink-0 flex-col overflow-hidden border-r border-[var(--bdr)] bg-[var(--sb)]"
181
+ >
182
+ <div className="flex items-center justify-between border-b border-[var(--bdr)] py-2 pr-2 pl-3.5">
183
+ <div className="flex items-center gap-2">
184
+ <h2 className="text-[13px] font-medium text-[var(--txt)]">Sections</h2>
185
+ <span className="rounded-[4px] bg-[var(--acc-l)] px-[7px] py-0.5 text-[11px] font-medium text-[var(--acc)] tabular-nums">
186
+ {entries.length}
187
+ </span>
188
+ </div>
189
+ <button
190
+ type="button"
191
+ onClick={onToggleCollapsed}
192
+ aria-expanded
193
+ aria-label="Collapse sections panel"
194
+ className={ICON_BUTTON_CLASS}
195
+ >
196
+ <PanelLeftClose size={16} aria-hidden />
197
+ </button>
198
+ </div>
199
+
200
+ <div className="flex-1 overflow-y-auto">
201
+ {entries.length === 0 ? (
202
+ <p className="px-4 py-6 text-center text-[12.5px] text-[var(--muted)]">
203
+ No sections yet. Add one below to start building.
204
+ </p>
205
+ ) : (
206
+ <DndContext
207
+ sensors={sensors}
208
+ collisionDetection={closestCenter}
209
+ onDragEnd={handleDragEnd}
210
+ >
211
+ <SortableContext
212
+ items={entries.map((e) => e.id)}
213
+ strategy={verticalListSortingStrategy}
214
+ >
215
+ <ul aria-label="Sections in page order">
216
+ {entries.map((entry) => (
217
+ <SortableSectionRow
218
+ key={entry.id}
219
+ entry={entry}
220
+ selected={entry.id === selectedNodeId}
221
+ onSelect={onSelect}
222
+ />
223
+ ))}
224
+ </ul>
225
+ </SortableContext>
226
+ </DndContext>
227
+ )}
228
+ </div>
229
+
230
+ <div className="border-t border-[var(--bdr)]">
231
+ <button
232
+ type="button"
233
+ onClick={onAddSection}
234
+ className="flex w-full items-center gap-2 px-3.5 py-3 text-[13px] font-medium text-[var(--acc)] transition-colors duration-[var(--motion-fast)] hover:bg-[var(--acc-l)] focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none focus-visible:ring-inset"
235
+ >
236
+ <Plus size={14} aria-hidden />
237
+ Add Section
238
+ </button>
239
+ </div>
240
+ </nav>
241
+ )
242
+ }
243
+
244
+ interface SortableSectionRowProps {
245
+ entry: SectionEntry
246
+ selected: boolean
247
+ onSelect: (id: string) => void
248
+ }
249
+
250
+ function SortableSectionRow({ entry, selected, onSelect }: SortableSectionRowProps) {
251
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
252
+ id: entry.id,
253
+ })
254
+ const Icon = entry.icon
255
+
256
+ const style = {
257
+ transform: CSS.Transform.toString(transform),
258
+ transition,
259
+ zIndex: isDragging ? 10 : undefined,
260
+ }
261
+
262
+ return (
263
+ <li ref={setNodeRef} style={style} className={isDragging ? 'shadow-[var(--shd2)]' : undefined}>
264
+ <div
265
+ className={`flex items-center gap-1.5 border-b border-[var(--bdr)] py-2 pr-2.5 pl-1.5 transition-colors duration-[var(--motion-fast)] ${
266
+ selected ? 'bg-[var(--acc-l)]' : 'hover:bg-[var(--acc-l)]'
267
+ }`}
268
+ >
269
+ <button
270
+ type="button"
271
+ aria-label={`Drag to reorder ${entry.name}`}
272
+ {...attributes}
273
+ {...listeners}
274
+ className="shrink-0 cursor-grab touch-none rounded p-1 text-[var(--muted)] transition-colors duration-[var(--motion-fast)] hover:text-[var(--acc)] focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none active:cursor-grabbing"
275
+ >
276
+ <GripVertical size={15} aria-hidden />
277
+ </button>
278
+ <button
279
+ type="button"
280
+ onClick={() => onSelect(entry.id)}
281
+ aria-current={selected ? 'true' : undefined}
282
+ className="flex min-w-0 flex-1 items-center gap-2.5 rounded text-left focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none"
283
+ >
284
+ <span
285
+ aria-hidden
286
+ className="flex h-7 w-7 shrink-0 items-center justify-center rounded-[7px] bg-[var(--acc-l)] text-[var(--acc)]"
287
+ >
288
+ <Icon size={14} />
289
+ </span>
290
+ <span className="flex min-w-0 flex-col">
291
+ <span
292
+ className={`truncate text-[13px] font-medium ${
293
+ entry.hidden ? 'text-[var(--muted)] line-through' : 'text-[var(--txt)]'
294
+ }`}
295
+ >
296
+ {entry.name}
297
+ </span>
298
+ <span className="truncate text-[11px] text-[var(--muted)]">{entry.meta}</span>
299
+ </span>
300
+ </button>
301
+ </div>
302
+ </li>
303
+ )
304
+ }
@@ -1,6 +1,6 @@
1
1
  'use client'
2
2
 
3
- import { useState, useEffect, useCallback } from 'react'
3
+ import { useState, useEffect, useCallback, useRef } from 'react'
4
4
  import { Loader2, AlertTriangle } from 'lucide-react'
5
5
  import { toast } from 'sonner'
6
6
  import { cmsApi } from '../../lib/api.js'
@@ -15,6 +15,7 @@ import type { PageNode } from '@actuate-media/cms-core'
15
15
  import { BuilderToolbar } from './BuilderToolbar.js'
16
16
  import { BottomBar } from './BottomBar.js'
17
17
  import { BuilderCanvas } from './canvas/index.js'
18
+ import { BuilderSectionsPanel } from './BuilderSectionsPanel.js'
18
19
  import { ContextPanel } from './ContextPanel.js'
19
20
  import { AIGenerateDialog } from './AIGenerateDialog.js'
20
21
 
@@ -33,12 +34,35 @@ const DEVICE_WIDTHS = {
33
34
  mobile: '375px',
34
35
  } as const
35
36
 
37
+ const SECTIONS_PANEL_KEY = 'actuate-builder-sections-panel'
38
+
39
+ /**
40
+ * Left sections panel default: open on wide viewports (≥1280px), collapsed
41
+ * below so the canvas keeps room. An explicit user choice, persisted in
42
+ * localStorage, always wins over the breakpoint default.
43
+ */
44
+ function initialSectionsCollapsed(): boolean {
45
+ try {
46
+ const stored = localStorage.getItem(SECTIONS_PANEL_KEY)
47
+ if (stored === 'open') return false
48
+ if (stored === 'collapsed') return true
49
+ } catch {
50
+ /* localStorage unavailable — fall through to the breakpoint default */
51
+ }
52
+ if (typeof window !== 'undefined') {
53
+ return window.innerWidth < 1280
54
+ }
55
+ return false
56
+ }
57
+
36
58
  export function PageBuilder({ documentId, collectionSlug, config, onNavigate }: PageBuilderProps) {
37
59
  const [loading, setLoading] = useState(!!documentId)
38
60
  const [loadError, setLoadError] = useState<string | null>(null)
39
61
  const [saving, setSaving] = useState(false)
40
62
  const [status, setStatus] = useState<DocumentStatus>('DRAFT')
41
63
  const [aiDialogOpen, setAiDialogOpen] = useState(false)
64
+ const [sectionsCollapsed, setSectionsCollapsed] = useState(initialSectionsCollapsed)
65
+ const canvasRef = useRef<HTMLDivElement>(null)
42
66
 
43
67
  const builder = useBuilderState()
44
68
  const {
@@ -182,6 +206,38 @@ export function PageBuilder({ documentId, collectionSlug, config, onNavigate }:
182
206
  [replaceTree],
183
207
  )
184
208
 
209
+ const toggleSectionsPanel = useCallback(() => {
210
+ setSectionsCollapsed((prev) => {
211
+ const next = !prev
212
+ try {
213
+ localStorage.setItem(SECTIONS_PANEL_KEY, next ? 'collapsed' : 'open')
214
+ } catch {
215
+ /* localStorage unavailable — the choice just won't persist */
216
+ }
217
+ return next
218
+ })
219
+ }, [])
220
+
221
+ // Selecting from the left panel highlights the section on the canvas (the
222
+ // canvas already outlines the selected node) and opens its settings in the
223
+ // right-hand context panel, then scrolls the section into view.
224
+ const handlePanelSelect = useCallback(
225
+ (id: string) => {
226
+ selectNode(id)
227
+ canvasRef.current
228
+ ?.querySelector(`[data-node-id="${CSS.escape(id)}"]`)
229
+ ?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
230
+ },
231
+ [selectNode],
232
+ )
233
+
234
+ const handlePanelReorder = useCallback(
235
+ (id: string, toIndex: number) => {
236
+ moveNodeById(id, tree.id, toIndex)
237
+ },
238
+ [moveNodeById, tree.id],
239
+ )
240
+
185
241
  if (loading) {
186
242
  return (
187
243
  <div className="bg-background flex h-full items-center justify-center">
@@ -247,8 +303,19 @@ export function PageBuilder({ documentId, collectionSlug, config, onNavigate }:
247
303
  />
248
304
 
249
305
  <div className="flex flex-1 overflow-hidden">
306
+ {/* Sections panel (design parity: sections list left of the canvas) */}
307
+ <BuilderSectionsPanel
308
+ tree={tree}
309
+ selectedNodeId={selectedNodeId}
310
+ collapsed={sectionsCollapsed}
311
+ onToggleCollapsed={toggleSectionsPanel}
312
+ onSelect={handlePanelSelect}
313
+ onReorder={handlePanelReorder}
314
+ onAddSection={addSection}
315
+ />
316
+
250
317
  {/* Canvas area */}
251
- <div className="bg-muted flex-1 overflow-auto">
318
+ <div ref={canvasRef} className="bg-muted flex-1 overflow-auto">
252
319
  <div
253
320
  className="mx-auto h-full max-w-full transition-all duration-200"
254
321
  style={{ width: canvasWidth }}