@actuate-media/cms-admin 0.69.0 → 0.70.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 (188) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/AdminRoot.d.ts.map +1 -1
  3. package/dist/AdminRoot.js +4 -1
  4. package/dist/AdminRoot.js.map +1 -1
  5. package/dist/__tests__/lib/pages-tree.test.js +40 -1
  6. package/dist/__tests__/lib/pages-tree.test.js.map +1 -1
  7. package/dist/__tests__/views/form-entries.render.test.js +14 -4
  8. package/dist/__tests__/views/form-entries.render.test.js.map +1 -1
  9. package/dist/__tests__/views/form-submissions.render.test.js +14 -4
  10. package/dist/__tests__/views/form-submissions.render.test.js.map +1 -1
  11. package/dist/__tests__/views/media-seo-audit.render.test.js +26 -0
  12. package/dist/__tests__/views/media-seo-audit.render.test.js.map +1 -1
  13. package/dist/__tests__/views/page-builder-template-seed.render.test.d.ts +2 -0
  14. package/dist/__tests__/views/page-builder-template-seed.render.test.d.ts.map +1 -0
  15. package/dist/__tests__/views/page-builder-template-seed.render.test.js +48 -0
  16. package/dist/__tests__/views/page-builder-template-seed.render.test.js.map +1 -0
  17. package/dist/__tests__/views/page-templates.render.test.js +21 -0
  18. package/dist/__tests__/views/page-templates.render.test.js.map +1 -1
  19. package/dist/__tests__/views/redirects-pane.render.test.d.ts +2 -0
  20. package/dist/__tests__/views/redirects-pane.render.test.d.ts.map +1 -0
  21. package/dist/__tests__/views/redirects-pane.render.test.js +121 -0
  22. package/dist/__tests__/views/redirects-pane.render.test.js.map +1 -0
  23. package/dist/actuate-admin.css +1 -1
  24. package/dist/components/SEOPanel.js +3 -3
  25. package/dist/components/SEOPanel.js.map +1 -1
  26. package/dist/components/TipTapEditor.d.ts.map +1 -1
  27. package/dist/components/TipTapEditor.js +11 -9
  28. package/dist/components/TipTapEditor.js.map +1 -1
  29. package/dist/components/collections/EntryDetailPane.js +1 -1
  30. package/dist/components/collections/EntryDetailPane.js.map +1 -1
  31. package/dist/components/forms/EntriesTable.d.ts +3 -1
  32. package/dist/components/forms/EntriesTable.d.ts.map +1 -1
  33. package/dist/components/forms/EntriesTable.js +5 -2
  34. package/dist/components/forms/EntriesTable.js.map +1 -1
  35. package/dist/components/forms/FormEntryDetailPane.d.ts +10 -0
  36. package/dist/components/forms/FormEntryDetailPane.d.ts.map +1 -0
  37. package/dist/components/forms/FormEntryDetailPane.js +137 -0
  38. package/dist/components/forms/FormEntryDetailPane.js.map +1 -0
  39. package/dist/components/forms/FormSchemaPane.js +1 -1
  40. package/dist/components/forms/FormSchemaPane.js.map +1 -1
  41. package/dist/components/script-tags/ScriptTagEditorPane.js +1 -1
  42. package/dist/components/script-tags/ScriptTagEditorPane.js.map +1 -1
  43. package/dist/components/seo/RedirectEditorPane.d.ts +12 -0
  44. package/dist/components/seo/RedirectEditorPane.d.ts.map +1 -0
  45. package/dist/components/seo/RedirectEditorPane.js +89 -0
  46. package/dist/components/seo/RedirectEditorPane.js.map +1 -0
  47. package/dist/components/seo/SeoEditorPane.js +1 -1
  48. package/dist/components/seo/SeoEditorPane.js.map +1 -1
  49. package/dist/components/ui/DataTable.js +2 -2
  50. package/dist/components/ui/DataTable.js.map +1 -1
  51. package/dist/components/ui/Skeleton.js +3 -3
  52. package/dist/components/ui/Skeleton.js.map +1 -1
  53. package/dist/components/ui/Toggle.js +1 -1
  54. package/dist/components/ui/Toggle.js.map +1 -1
  55. package/dist/fields/BlockBuilderField.js +1 -1
  56. package/dist/fields/BlockBuilderField.js.map +1 -1
  57. package/dist/fields/MediaField.js +1 -1
  58. package/dist/fields/MediaField.js.map +1 -1
  59. package/dist/fields/PropInput.js +1 -1
  60. package/dist/fields/PropInput.js.map +1 -1
  61. package/dist/index.d.ts +3 -0
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +3 -0
  64. package/dist/index.js.map +1 -1
  65. package/dist/layout/Header.js +1 -1
  66. package/dist/layout/Header.js.map +1 -1
  67. package/dist/lib/pages-service.d.ts +7 -0
  68. package/dist/lib/pages-service.d.ts.map +1 -1
  69. package/dist/lib/pages-service.js +11 -0
  70. package/dist/lib/pages-service.js.map +1 -1
  71. package/dist/lib/pages-tree.d.ts +14 -0
  72. package/dist/lib/pages-tree.d.ts.map +1 -1
  73. package/dist/lib/pages-tree.js +51 -0
  74. package/dist/lib/pages-tree.js.map +1 -1
  75. package/dist/views/DocumentEdit.js +1 -1
  76. package/dist/views/DocumentEdit.js.map +1 -1
  77. package/dist/views/FormEditor.js +5 -7
  78. package/dist/views/FormEditor.js.map +1 -1
  79. package/dist/views/FormEntries.d.ts.map +1 -1
  80. package/dist/views/FormEntries.js +18 -14
  81. package/dist/views/FormEntries.js.map +1 -1
  82. package/dist/views/FormSubmissions.d.ts.map +1 -1
  83. package/dist/views/FormSubmissions.js +18 -15
  84. package/dist/views/FormSubmissions.js.map +1 -1
  85. package/dist/views/Forms.js +5 -5
  86. package/dist/views/Forms.js.map +1 -1
  87. package/dist/views/HiddenLoginNotFound.d.ts +6 -0
  88. package/dist/views/HiddenLoginNotFound.d.ts.map +1 -1
  89. package/dist/views/HiddenLoginNotFound.js +6 -0
  90. package/dist/views/HiddenLoginNotFound.js.map +1 -1
  91. package/dist/views/MediaBrowser.d.ts.map +1 -1
  92. package/dist/views/MediaBrowser.js +3 -2
  93. package/dist/views/MediaBrowser.js.map +1 -1
  94. package/dist/views/MediaSeoAudit.d.ts +4 -1
  95. package/dist/views/MediaSeoAudit.d.ts.map +1 -1
  96. package/dist/views/MediaSeoAudit.js +12 -2
  97. package/dist/views/MediaSeoAudit.js.map +1 -1
  98. package/dist/views/PageEditor.d.ts +4 -0
  99. package/dist/views/PageEditor.d.ts.map +1 -1
  100. package/dist/views/PageEditor.js +4 -0
  101. package/dist/views/PageEditor.js.map +1 -1
  102. package/dist/views/Pages/PagesListView.d.ts.map +1 -1
  103. package/dist/views/Pages/PagesListView.js +52 -17
  104. package/dist/views/Pages/PagesListView.js.map +1 -1
  105. package/dist/views/Pages/PagesTreeTable.d.ts +11 -1
  106. package/dist/views/Pages/PagesTreeTable.d.ts.map +1 -1
  107. package/dist/views/Pages/PagesTreeTable.js +40 -9
  108. package/dist/views/Pages/PagesTreeTable.js.map +1 -1
  109. package/dist/views/Pages.d.ts +4 -0
  110. package/dist/views/Pages.d.ts.map +1 -1
  111. package/dist/views/Pages.js +4 -0
  112. package/dist/views/Pages.js.map +1 -1
  113. package/dist/views/Redirects.d.ts +4 -0
  114. package/dist/views/Redirects.d.ts.map +1 -1
  115. package/dist/views/Redirects.js +4 -0
  116. package/dist/views/Redirects.js.map +1 -1
  117. package/dist/views/ScriptTags.d.ts.map +1 -1
  118. package/dist/views/ScriptTags.js +4 -3
  119. package/dist/views/ScriptTags.js.map +1 -1
  120. package/dist/views/page-builder/PageBuilder.d.ts +3 -1
  121. package/dist/views/page-builder/PageBuilder.d.ts.map +1 -1
  122. package/dist/views/page-builder/PageBuilder.js +24 -2
  123. package/dist/views/page-builder/PageBuilder.js.map +1 -1
  124. package/dist/views/page-builder/PageTemplates.d.ts.map +1 -1
  125. package/dist/views/page-builder/PageTemplates.js +30 -4
  126. package/dist/views/page-builder/PageTemplates.js.map +1 -1
  127. package/dist/views/seo/ContentTab.js +6 -6
  128. package/dist/views/seo/ContentTab.js.map +1 -1
  129. package/dist/views/seo/RedirectsTab.d.ts.map +1 -1
  130. package/dist/views/seo/RedirectsTab.js +23 -8
  131. package/dist/views/seo/RedirectsTab.js.map +1 -1
  132. package/package.json +1 -1
  133. package/src/AdminRoot.tsx +11 -1
  134. package/src/__tests__/lib/pages-tree.test.ts +49 -0
  135. package/src/__tests__/views/form-entries.render.test.tsx +16 -4
  136. package/src/__tests__/views/form-submissions.render.test.tsx +16 -4
  137. package/src/__tests__/views/media-seo-audit.render.test.tsx +34 -0
  138. package/src/__tests__/views/page-builder-template-seed.render.test.tsx +59 -0
  139. package/src/__tests__/views/page-templates.render.test.tsx +40 -0
  140. package/src/__tests__/views/redirects-pane.render.test.tsx +156 -0
  141. package/src/components/SEOPanel.tsx +9 -9
  142. package/src/components/TipTapEditor.tsx +11 -9
  143. package/src/components/collections/EntryDetailPane.tsx +1 -1
  144. package/src/components/forms/EntriesTable.tsx +12 -3
  145. package/src/components/forms/{EntryDetailDrawer.tsx → FormEntryDetailPane.tsx} +112 -102
  146. package/src/components/forms/FormSchemaPane.tsx +1 -1
  147. package/src/components/script-tags/ScriptTagEditorPane.tsx +1 -1
  148. package/src/components/seo/RedirectEditorPane.tsx +271 -0
  149. package/src/components/seo/SeoEditorPane.tsx +1 -1
  150. package/src/components/ui/DataTable.tsx +3 -3
  151. package/src/components/ui/Skeleton.tsx +8 -8
  152. package/src/components/ui/Toggle.tsx +1 -1
  153. package/src/fields/BlockBuilderField.tsx +1 -1
  154. package/src/fields/MediaField.tsx +1 -1
  155. package/src/fields/PropInput.tsx +1 -1
  156. package/src/index.ts +3 -0
  157. package/src/layout/Header.tsx +2 -2
  158. package/src/lib/pages-service.ts +15 -0
  159. package/src/lib/pages-tree.ts +54 -0
  160. package/src/styles/actuate-tokens.css +4 -0
  161. package/src/styles/theme.css +9 -3
  162. package/src/views/DocumentEdit.tsx +4 -4
  163. package/src/views/FormEditor.tsx +10 -10
  164. package/src/views/FormEntries.tsx +81 -66
  165. package/src/views/FormSubmissions.tsx +78 -64
  166. package/src/views/Forms.tsx +16 -16
  167. package/src/views/HiddenLoginNotFound.tsx +6 -0
  168. package/src/views/MediaBrowser.tsx +14 -10
  169. package/src/views/MediaSeoAudit.tsx +39 -2
  170. package/src/views/PageEditor.tsx +4 -0
  171. package/src/views/Pages/PagesListView.tsx +99 -54
  172. package/src/views/Pages/PagesTreeTable.tsx +204 -109
  173. package/src/views/Pages.tsx +4 -0
  174. package/src/views/Redirects.tsx +4 -0
  175. package/src/views/ScriptTags.tsx +36 -42
  176. package/src/views/page-builder/PageBuilder.tsx +30 -2
  177. package/src/views/page-builder/PageTemplates.tsx +56 -15
  178. package/src/views/seo/ContentTab.tsx +10 -10
  179. package/src/views/seo/RedirectsTab.tsx +145 -109
  180. package/dist/components/forms/EntryDetailDrawer.d.ts +0 -11
  181. package/dist/components/forms/EntryDetailDrawer.d.ts.map +0 -1
  182. package/dist/components/forms/EntryDetailDrawer.js +0 -145
  183. package/dist/components/forms/EntryDetailDrawer.js.map +0 -1
  184. package/dist/components/seo/RedirectEditorDrawer.d.ts +0 -9
  185. package/dist/components/seo/RedirectEditorDrawer.d.ts.map +0 -1
  186. package/dist/components/seo/RedirectEditorDrawer.js +0 -95
  187. package/dist/components/seo/RedirectEditorDrawer.js.map +0 -1
  188. package/src/components/seo/RedirectEditorDrawer.tsx +0 -231
@@ -3,8 +3,9 @@
3
3
  /**
4
4
  * All Entries — a cross-form submissions inbox. Same token-only table, search,
5
5
  * status pills (All / Unread / Starred / Archived), bulk actions, CSV export,
6
- * pagination, and detail drawer as the per-form Submissions view, plus a Form
7
- * filter and a Form column so editors can triage every form from one place.
6
+ * pagination, and inline detail pane as the per-form Submissions view, plus a
7
+ * Form filter and a Form column so editors can triage every form from one
8
+ * place.
8
9
  */
9
10
  import { useCallback, useEffect, useState } from 'react'
10
11
  import { Download, Inbox, Search } from 'lucide-react'
@@ -22,9 +23,10 @@ import {
22
23
  type FormSubmission,
23
24
  } from '../lib/forms-service.js'
24
25
  import { emitFormsChanged, onFormsChanged } from '../lib/forms-events.js'
25
- import { EntryDetailDrawer } from '../components/forms/EntryDetailDrawer.js'
26
+ import { FormEntryDetailPane } from '../components/forms/FormEntryDetailPane.js'
26
27
  import { EntriesTable } from '../components/forms/EntriesTable.js'
27
28
  import { ConfirmDialog } from '../components/ui/ConfirmDialog.js'
29
+ import { InspectorPane, SplitPaneLayout, SplitPaneList } from '../components/ui/InspectorPane.js'
28
30
  import {
29
31
  FormsEmptyState,
30
32
  FormsErrorState,
@@ -106,7 +108,6 @@ export function FormEntries({ onNavigate }: FormEntriesProps) {
106
108
  const [page, setPage] = useState(1)
107
109
  const [selected, setSelected] = useState<Set<string>>(new Set())
108
110
  const [activeEntry, setActiveEntry] = useState<string | null>(null)
109
- const [drawerOpen, setDrawerOpen] = useState(false)
110
111
  const [confirmBulkDelete, setConfirmBulkDelete] = useState(false)
111
112
 
112
113
  const scopedFormId = formFilter === 'all' ? undefined : formFilter
@@ -167,8 +168,8 @@ export function FormEntries({ onNavigate }: FormEntriesProps) {
167
168
  }, [load, loadCounts])
168
169
 
169
170
  const openEntry = useCallback((id: string) => {
170
- setActiveEntry(id)
171
- setDrawerOpen(true)
171
+ // Toggle: clicking the open row again collapses the pane.
172
+ setActiveEntry((cur) => (cur === id ? null : id))
172
173
  setEntries((cur) => cur.map((e) => (e.id === id ? { ...e, unread: false } : e)))
173
174
  }, [])
174
175
 
@@ -372,68 +373,82 @@ export function FormEntries({ onNavigate }: FormEntriesProps) {
372
373
  </div>
373
374
  )}
374
375
 
375
- {error ? (
376
- <FormsErrorState message={error} onRetry={load} />
377
- ) : loading ? (
378
- <FormsLoading label="Loading entries…" />
379
- ) : showEmpty ? (
380
- <FormsEmptyState
381
- icon={<Inbox className="h-6 w-6" aria-hidden />}
382
- title={isFiltered ? 'No entries match your filters' : 'No entries yet'}
383
- description={
384
- isFiltered
385
- ? 'Try a different search term, form, or status filter.'
386
- : 'Submissions across all your forms will appear here.'
387
- }
388
- action={
389
- isFiltered ? (
390
- <button
391
- type="button"
392
- onClick={() => {
393
- setSearch('')
394
- setFilter('all')
395
- setFormFilter('all')
396
- setPage(1)
397
- }}
398
- className="text-primary text-sm font-medium hover:underline"
399
- >
400
- Clear filters
401
- </button>
402
- ) : undefined
403
- }
404
- />
405
- ) : (
406
- <EntriesTable
407
- entries={entries}
408
- selected={selected}
409
- allSelected={allSelected}
410
- onToggleSelectAll={toggleSelectAll}
411
- onToggleSelect={toggleSelect}
412
- onOpen={openEntry}
413
- onToggleStar={toggleStar}
414
- showForm
415
- onOpenForm={(id) => onNavigate?.(`/forms/${id}/submissions`)}
416
- ariaLabel="All entries"
417
- footer={
418
- <FormsPagination
419
- page={page}
420
- pageSize={PAGE_SIZE}
421
- total={total}
422
- onPageChange={setPage}
423
- noun="submission"
424
- label="Entries pagination"
376
+ {/* The page scrolls, so the split area takes a viewport-derived height
377
+ to keep the pane's body/footer independently scrollable. */}
378
+ <SplitPaneLayout className="h-[calc(100vh-340px)] min-h-[480px]">
379
+ <SplitPaneList>
380
+ {error ? (
381
+ <FormsErrorState message={error} onRetry={load} />
382
+ ) : loading ? (
383
+ <FormsLoading label="Loading entries…" />
384
+ ) : showEmpty ? (
385
+ <FormsEmptyState
386
+ icon={<Inbox className="h-6 w-6" aria-hidden />}
387
+ title={isFiltered ? 'No entries match your filters' : 'No entries yet'}
388
+ description={
389
+ isFiltered
390
+ ? 'Try a different search term, form, or status filter.'
391
+ : 'Submissions across all your forms will appear here.'
392
+ }
393
+ action={
394
+ isFiltered ? (
395
+ <button
396
+ type="button"
397
+ onClick={() => {
398
+ setSearch('')
399
+ setFilter('all')
400
+ setFormFilter('all')
401
+ setPage(1)
402
+ }}
403
+ className="text-primary text-sm font-medium hover:underline"
404
+ >
405
+ Clear filters
406
+ </button>
407
+ ) : undefined
408
+ }
425
409
  />
426
- }
427
- />
428
- )}
410
+ ) : (
411
+ <EntriesTable
412
+ entries={entries}
413
+ selected={selected}
414
+ allSelected={allSelected}
415
+ onToggleSelectAll={toggleSelectAll}
416
+ onToggleSelect={toggleSelect}
417
+ onOpen={openEntry}
418
+ onToggleStar={toggleStar}
419
+ activeId={activeEntry}
420
+ showForm
421
+ onOpenForm={(id) => onNavigate?.(`/forms/${id}/submissions`)}
422
+ ariaLabel="All entries"
423
+ footer={
424
+ <FormsPagination
425
+ page={page}
426
+ pageSize={PAGE_SIZE}
427
+ total={total}
428
+ onPageChange={setPage}
429
+ noun="submission"
430
+ label="Entries pagination"
431
+ />
432
+ }
433
+ />
434
+ )}
435
+ </SplitPaneList>
429
436
 
430
- <EntryDetailDrawer
431
- entryId={activeEntry}
432
- open={drawerOpen}
433
- onOpenChange={setDrawerOpen}
434
- onChanged={refresh}
435
- onDeleted={() => refresh()}
436
- />
437
+ <InspectorPane open={activeEntry !== null} width={400} aria-label="Entry details">
438
+ {activeEntry && (
439
+ <FormEntryDetailPane
440
+ key={activeEntry}
441
+ entryId={activeEntry}
442
+ onClose={() => setActiveEntry(null)}
443
+ onChanged={refresh}
444
+ onDeleted={() => {
445
+ setActiveEntry(null)
446
+ refresh()
447
+ }}
448
+ />
449
+ )}
450
+ </InspectorPane>
451
+ </SplitPaneLayout>
437
452
 
438
453
  <ConfirmDialog
439
454
  open={confirmBulkDelete}
@@ -6,9 +6,9 @@
6
6
  *
7
7
  * Features: summary chips, search + status pills (All / Unread / Starred /
8
8
  * Archived), row selection with bulk actions (mark read, star, archive,
9
- * delete), per-row star toggle, CSV export, and a detail drawer that opens on
10
- * row click (marking the entry read). Loading / empty / filtered-empty / error
11
- * states are all handled.
9
+ * delete), per-row star toggle, CSV export, and an inline detail pane that
10
+ * opens on row click (marking the entry read). Loading / empty /
11
+ * filtered-empty / error states are all handled.
12
12
  */
13
13
  import { useCallback, useEffect, useState } from 'react'
14
14
  import { ArrowLeft, Download, Inbox, Search } from 'lucide-react'
@@ -25,9 +25,10 @@ import {
25
25
  type FormSubmission,
26
26
  } from '../lib/forms-service.js'
27
27
  import { emitFormsChanged, onFormsChanged } from '../lib/forms-events.js'
28
- import { EntryDetailDrawer } from '../components/forms/EntryDetailDrawer.js'
28
+ import { FormEntryDetailPane } from '../components/forms/FormEntryDetailPane.js'
29
29
  import { EntriesTable } from '../components/forms/EntriesTable.js'
30
30
  import { ConfirmDialog } from '../components/ui/ConfirmDialog.js'
31
+ import { InspectorPane, SplitPaneLayout, SplitPaneList } from '../components/ui/InspectorPane.js'
31
32
  import {
32
33
  FormsEmptyState,
33
34
  FormsErrorState,
@@ -109,7 +110,6 @@ export function FormSubmissions({ formId, onNavigate }: FormSubmissionsProps) {
109
110
  const [page, setPage] = useState(1)
110
111
  const [selected, setSelected] = useState<Set<string>>(new Set())
111
112
  const [activeEntry, setActiveEntry] = useState<string | null>(null)
112
- const [drawerOpen, setDrawerOpen] = useState(false)
113
113
  const [confirmBulkDelete, setConfirmBulkDelete] = useState(false)
114
114
 
115
115
  // Form metadata for the title (loads once).
@@ -177,9 +177,9 @@ export function FormSubmissions({ formId, onNavigate }: FormSubmissionsProps) {
177
177
  }, [load, loadCounts])
178
178
 
179
179
  const openEntry = useCallback((id: string) => {
180
- setActiveEntry(id)
181
- setDrawerOpen(true)
182
- // Reflect read state immediately in the row; the drawer persists it.
180
+ // Toggle: clicking the open row again collapses the pane.
181
+ setActiveEntry((cur) => (cur === id ? null : id))
182
+ // Reflect read state immediately in the row; the pane persists it.
183
183
  setEntries((cur) => cur.map((e) => (e.id === id ? { ...e, unread: false } : e)))
184
184
  }, [])
185
185
 
@@ -377,64 +377,78 @@ export function FormSubmissions({ formId, onNavigate }: FormSubmissionsProps) {
377
377
  </div>
378
378
  )}
379
379
 
380
- {error ? (
381
- <FormsErrorState message={error} onRetry={load} />
382
- ) : loading ? (
383
- <FormsLoading label="Loading submissions…" />
384
- ) : showEmpty ? (
385
- <FormsEmptyState
386
- icon={<Inbox className="h-6 w-6" aria-hidden />}
387
- title={isFiltered ? 'No submissions match your filters' : 'No submissions yet'}
388
- description={
389
- isFiltered
390
- ? 'Try a different search term or status filter.'
391
- : 'Submissions will appear here once your form starts receiving entries.'
392
- }
393
- action={
394
- isFiltered ? (
395
- <button
396
- type="button"
397
- onClick={() => {
398
- setSearch('')
399
- setFilter('all')
400
- setPage(1)
401
- }}
402
- className="text-primary text-sm font-medium hover:underline"
403
- >
404
- Clear filters
405
- </button>
406
- ) : undefined
407
- }
408
- />
409
- ) : (
410
- <EntriesTable
411
- entries={entries}
412
- selected={selected}
413
- allSelected={allSelected}
414
- onToggleSelectAll={toggleSelectAll}
415
- onToggleSelect={toggleSelect}
416
- onOpen={openEntry}
417
- onToggleStar={toggleStar}
418
- footer={
419
- <FormsPagination
420
- page={page}
421
- pageSize={PAGE_SIZE}
422
- total={total}
423
- onPageChange={setPage}
424
- noun="submission"
425
- label="Submissions pagination"
380
+ {/* The page scrolls, so the split area takes a viewport-derived height
381
+ to keep the pane's body/footer independently scrollable. */}
382
+ <SplitPaneLayout className="h-[calc(100vh-340px)] min-h-[480px]">
383
+ <SplitPaneList>
384
+ {error ? (
385
+ <FormsErrorState message={error} onRetry={load} />
386
+ ) : loading ? (
387
+ <FormsLoading label="Loading submissions…" />
388
+ ) : showEmpty ? (
389
+ <FormsEmptyState
390
+ icon={<Inbox className="h-6 w-6" aria-hidden />}
391
+ title={isFiltered ? 'No submissions match your filters' : 'No submissions yet'}
392
+ description={
393
+ isFiltered
394
+ ? 'Try a different search term or status filter.'
395
+ : 'Submissions will appear here once your form starts receiving entries.'
396
+ }
397
+ action={
398
+ isFiltered ? (
399
+ <button
400
+ type="button"
401
+ onClick={() => {
402
+ setSearch('')
403
+ setFilter('all')
404
+ setPage(1)
405
+ }}
406
+ className="text-primary text-sm font-medium hover:underline"
407
+ >
408
+ Clear filters
409
+ </button>
410
+ ) : undefined
411
+ }
426
412
  />
427
- }
428
- />
429
- )}
413
+ ) : (
414
+ <EntriesTable
415
+ entries={entries}
416
+ selected={selected}
417
+ allSelected={allSelected}
418
+ onToggleSelectAll={toggleSelectAll}
419
+ onToggleSelect={toggleSelect}
420
+ onOpen={openEntry}
421
+ onToggleStar={toggleStar}
422
+ activeId={activeEntry}
423
+ footer={
424
+ <FormsPagination
425
+ page={page}
426
+ pageSize={PAGE_SIZE}
427
+ total={total}
428
+ onPageChange={setPage}
429
+ noun="submission"
430
+ label="Submissions pagination"
431
+ />
432
+ }
433
+ />
434
+ )}
435
+ </SplitPaneList>
430
436
 
431
- <EntryDetailDrawer
432
- entryId={activeEntry}
433
- open={drawerOpen}
434
- onOpenChange={setDrawerOpen}
435
- onChanged={refresh}
436
- onDeleted={() => refresh()}
437
- />
437
+ <InspectorPane open={activeEntry !== null} width={400} aria-label="Entry details">
438
+ {activeEntry && (
439
+ <FormEntryDetailPane
440
+ key={activeEntry}
441
+ entryId={activeEntry}
442
+ onClose={() => setActiveEntry(null)}
443
+ onChanged={refresh}
444
+ onDeleted={() => {
445
+ setActiveEntry(null)
446
+ refresh()
447
+ }}
448
+ />
449
+ )}
450
+ </InspectorPane>
451
+ </SplitPaneLayout>
438
452
 
439
453
  <ConfirmDialog
440
454
  open={confirmBulkDelete}
@@ -166,8 +166,8 @@ export function Forms({ onNavigate }: FormsProps) {
166
166
  <div className="space-y-5 p-6 pb-4">
167
167
  <header className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
168
168
  <div>
169
- <h1 className="text-foreground">Forms</h1>
170
- <p className="text-muted-foreground mt-1 text-sm">
169
+ <h1 className="text-[var(--txt)]">Forms</h1>
170
+ <p className="mt-1 text-sm text-[var(--sub)]">
171
171
  Manage forms and view incoming submissions
172
172
  </p>
173
173
  </div>
@@ -196,7 +196,7 @@ export function Forms({ onNavigate }: FormsProps) {
196
196
  <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
197
197
  <div className="relative w-full sm:max-w-xs">
198
198
  <Search
199
- className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2"
199
+ className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-[var(--sub)]"
200
200
  aria-hidden
201
201
  />
202
202
  <input
@@ -208,7 +208,7 @@ export function Forms({ onNavigate }: FormsProps) {
208
208
  setPage(1)
209
209
  }}
210
210
  aria-label="Search forms"
211
- className="border-border bg-input-background text-foreground focus-visible:ring-ring w-full rounded-lg border py-2 pr-3 pl-9 text-sm focus-visible:ring-2 focus-visible:outline-none"
211
+ className="bg-input-background w-full rounded-lg border border-[var(--bdr)] py-2 pr-3 pl-9 text-sm text-[var(--txt)] focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none"
212
212
  />
213
213
  </div>
214
214
  <div className="flex gap-1" role="group" aria-label="Filter by status">
@@ -224,7 +224,7 @@ export function Forms({ onNavigate }: FormsProps) {
224
224
  className={`rounded-full px-3 py-1.5 text-sm font-medium transition-colors ${
225
225
  filter === f.id
226
226
  ? 'bg-primary text-primary-foreground'
227
- : 'text-muted-foreground hover:bg-accent hover:text-foreground'
227
+ : 'text-[var(--sub)] hover:bg-[var(--acc-l)] hover:text-[var(--txt)]'
228
228
  }`}
229
229
  >
230
230
  {f.label}
@@ -326,11 +326,11 @@ function FormsTable({
326
326
  footer?: ReactNode
327
327
  }) {
328
328
  return (
329
- <div className="border-border bg-card rounded-lg border">
329
+ <div className="rounded-lg border border-[var(--bdr)] bg-[var(--card)]">
330
330
  <div className="overflow-x-auto">
331
331
  <table className="w-full min-w-[760px] text-sm" aria-label="Forms">
332
332
  <thead>
333
- <tr className="border-border text-muted-foreground border-b text-left text-xs">
333
+ <tr className="border-b border-[var(--bdr)] text-left text-xs text-[var(--sub)]">
334
334
  <th scope="col" className="px-4 py-2.5 font-medium">
335
335
  Form
336
336
  </th>
@@ -363,8 +363,8 @@ function FormsTable({
363
363
  return (
364
364
  <tr
365
365
  key={form.id}
366
- className={`border-border border-b transition-colors last:border-b-0 ${
367
- isSelected ? 'bg-[var(--acc-l)]' : 'hover:bg-accent/40'
366
+ className={`border-b border-[var(--bdr)] transition-colors last:border-b-0 ${
367
+ isSelected ? 'bg-[var(--acc-l)]' : 'hover:bg-[var(--acc-l)]'
368
368
  } ${muted ? 'opacity-60' : ''}`}
369
369
  >
370
370
  <td className="px-4 py-3">
@@ -373,10 +373,10 @@ function FormsTable({
373
373
  onClick={() => onOpenForm(form.id)}
374
374
  className="block max-w-xs text-left"
375
375
  >
376
- <span className="text-foreground block truncate font-medium hover:underline">
376
+ <span className="block truncate font-medium text-[var(--txt)] hover:underline">
377
377
  {form.name}
378
378
  </span>
379
- <span className="text-muted-foreground block truncate text-xs">
379
+ <span className="block truncate text-xs text-[var(--sub)]">
380
380
  /{form.slug}
381
381
  {form.description ? ` · ${form.description}` : ''}
382
382
  </span>
@@ -387,7 +387,7 @@ function FormsTable({
387
387
  {form.fields.slice(0, 4).map((field) => (
388
388
  <FieldTypeBadge key={field.id} type={field.type} />
389
389
  ))}
390
- <span className="text-muted-foreground text-xs">{fieldCount} fields</span>
390
+ <span className="text-xs text-[var(--sub)]">{fieldCount} fields</span>
391
391
  </div>
392
392
  </td>
393
393
  <td className="px-4 py-3">
@@ -397,15 +397,15 @@ function FormsTable({
397
397
  <button
398
398
  type="button"
399
399
  onClick={() => onOpenEntries(form.id)}
400
- className="text-foreground hover:text-primary focus-visible:ring-ring inline-flex items-center gap-1.5 rounded font-medium tabular-nums focus-visible:ring-2 focus-visible:outline-none"
400
+ className="inline-flex items-center gap-1.5 rounded font-medium text-[var(--txt)] tabular-nums hover:text-[var(--acc)] focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none"
401
401
  aria-label={`View ${form.totalEntries ?? 0} entries for ${form.name}`}
402
402
  >
403
403
  {form.totalEntries ?? 0}
404
404
  <UnreadBadge count={unread} />
405
- <ChevronRight className="text-muted-foreground h-3.5 w-3.5" aria-hidden />
405
+ <ChevronRight className="h-3.5 w-3.5 text-[var(--sub)]" aria-hidden />
406
406
  </button>
407
407
  </td>
408
- <td className="text-muted-foreground px-4 py-3 whitespace-nowrap">
408
+ <td className="px-4 py-3 whitespace-nowrap text-[var(--sub)]">
409
409
  {formatRelative(form.lastEntryAt)}
410
410
  </td>
411
411
  <td className="px-4 py-3 text-center">
@@ -421,7 +421,7 @@ function FormsTable({
421
421
  type="button"
422
422
  onClick={() => onOpenSchema(form)}
423
423
  aria-expanded={isSelected}
424
- className="border-border text-foreground hover:bg-accent focus-visible:ring-ring inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none"
424
+ className="inline-flex items-center gap-1.5 rounded-md border border-[var(--bdr)] px-2.5 py-1.5 text-xs font-medium text-[var(--txt)] transition-colors hover:bg-[var(--acc-l)] focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none"
425
425
  >
426
426
  <Settings2 className="h-3.5 w-3.5" aria-hidden />
427
427
  Schema
@@ -5,6 +5,12 @@
5
5
  * (`config.admin.loginPath` set). Mimics the framework's default 404 page so
6
6
  * hitting `/admin` reveals nothing — no login form, no admin branding, and no
7
7
  * redirect that would leak the real login location.
8
+ *
9
+ * INTENTIONAL raw colors: although this mounts inside `.actuate-admin` (so the
10
+ * theme CSS vars are technically available), it must NOT use them. Painting
11
+ * this screen with the admin palette (or its dark-mode variant) would make it
12
+ * visibly differ from the framework's stock 404 and fingerprint the hidden
13
+ * admin to anyone probing `/admin`. Plain #fff/#000 matches the default 404.
8
14
  */
9
15
  export function HiddenLoginNotFound() {
10
16
  return (
@@ -31,6 +31,7 @@ import { sortByRelevance, type SortConfig, toggleSort } from '../lib/search.js'
31
31
  import { useApiData } from '../lib/useApiData.js'
32
32
  import { cmsApi } from '../lib/api.js'
33
33
  import { FolderTree, type FolderSelection } from '../components/FolderTree.js'
34
+ import { Button } from '../components/ui/Button.js'
34
35
  import { StatusBadge } from '../components/ui/StatusBadge.js'
35
36
  import { StatPill } from '../components/ui/StatPill.js'
36
37
  import { MediaSeoAudit, type MediaSeoAuditItem } from './MediaSeoAudit.js'
@@ -508,18 +509,16 @@ export function MediaBrowser({ onNavigate, currentPath }: MediaBrowserProps) {
508
509
  className="hidden"
509
510
  onChange={(e) => handleUploadFiles(e.target.files)}
510
511
  />
511
- <button
512
+ <Button
513
+ type="button"
514
+ variant="primary"
515
+ size="md"
512
516
  onClick={() => fileInputRef.current?.click()}
513
- disabled={uploading}
514
- className="bg-primary text-primary-foreground hover:bg-primary/90 flex items-center gap-2 rounded-lg px-4 py-2 text-sm transition-colors disabled:opacity-50"
517
+ loading={uploading}
518
+ leftIcon={<Upload size={16} aria-hidden />}
515
519
  >
516
- {uploading ? (
517
- <Loader2 className="h-4 w-4 animate-spin" />
518
- ) : (
519
- <Upload className="h-4 w-4" />
520
- )}
521
520
  {uploading ? 'Uploading...' : 'Upload Files'}
522
- </button>
521
+ </Button>
523
522
  </div>
524
523
  </div>
525
524
 
@@ -569,7 +568,12 @@ export function MediaBrowser({ onNavigate, currentPath }: MediaBrowserProps) {
569
568
  </div>
570
569
 
571
570
  {activeTab === 'audit' ? (
572
- <MediaSeoAudit items={mediaItems} onEdit={handleAuditEdit} />
571
+ <MediaSeoAudit
572
+ items={mediaItems}
573
+ onEdit={handleAuditEdit}
574
+ error={error}
575
+ onRetry={refetch}
576
+ />
573
577
  ) : (
574
578
  <div className="flex min-h-0 flex-1 gap-4 overflow-hidden">
575
579
  {sidebarOpen && (
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { Check, FileImage, ImageIcon, SquarePen, TriangleAlert, X } from 'lucide-react'
4
4
  import { useMemo, useState } from 'react'
5
+ import type { KeyboardEvent } from 'react'
5
6
  import { FilterPill } from '../components/ui/FilterPill.js'
6
7
 
7
8
  /**
@@ -27,6 +28,9 @@ export interface MediaSeoAuditProps {
27
28
  items: MediaSeoAuditItem[]
28
29
  /** Fired when a row / Edit button is activated — opens the item in the Library tab. */
29
30
  onEdit: (item: MediaSeoAuditItem) => void
31
+ /** Media fetch error — renders a retryable error state instead of an empty audit. */
32
+ error?: string | null
33
+ onRetry?: () => void
30
34
  }
31
35
 
32
36
  type AuditFilter = 'all' | 'alt' | 'title' | 'poor'
@@ -144,7 +148,7 @@ function StatCard({
144
148
  )
145
149
  }
146
150
 
147
- export function MediaSeoAudit({ items, onEdit }: MediaSeoAuditProps) {
151
+ export function MediaSeoAudit({ items, onEdit, error, onRetry }: MediaSeoAuditProps) {
148
152
  const [filter, setFilter] = useState<AuditFilter>('all')
149
153
 
150
154
  const stats = useMemo(() => {
@@ -174,6 +178,30 @@ export function MediaSeoAudit({ items, onEdit }: MediaSeoAuditProps) {
174
178
  }
175
179
  }, [items, filter])
176
180
 
181
+ if (error && items.length === 0) {
182
+ return (
183
+ <div className="min-h-0 flex-1 overflow-y-auto">
184
+ <div
185
+ role="alert"
186
+ className="flex flex-col items-center justify-center rounded-[10px] border border-[var(--bdr)] bg-[var(--card)] px-4 py-14 text-center"
187
+ >
188
+ <TriangleAlert size={24} className="mb-3 text-[var(--err)]" aria-hidden />
189
+ <p className="text-sm font-medium text-[var(--txt)]">Couldn&apos;t load media</p>
190
+ <p className="mt-1 max-w-sm text-xs text-[var(--sub)]">{error}</p>
191
+ {onRetry && (
192
+ <button
193
+ type="button"
194
+ onClick={onRetry}
195
+ className="mt-4 rounded-[7px] border border-[var(--bdr)] bg-[var(--card)] px-3.5 py-1.5 text-[12.5px] font-medium text-[var(--txt)] transition-colors hover:border-[var(--acc)] hover:text-[var(--acc)] focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none"
196
+ >
197
+ Retry
198
+ </button>
199
+ )}
200
+ </div>
201
+ </div>
202
+ )
203
+ }
204
+
177
205
  return (
178
206
  <div className="min-h-0 flex-1 overflow-y-auto">
179
207
  <div className="mb-[18px] grid grid-cols-2 gap-2.5 lg:grid-cols-4">
@@ -253,8 +281,17 @@ export function MediaSeoAudit({ items, onEdit }: MediaSeoAuditProps) {
253
281
  return (
254
282
  <tr
255
283
  key={item.id}
284
+ tabIndex={0}
256
285
  onClick={() => onEdit(item)}
257
- className="cursor-pointer border-b border-[var(--bdr)] transition-colors last:border-b-0 hover:bg-[var(--acc-l)]"
286
+ onKeyDown={(e: KeyboardEvent<HTMLTableRowElement>) => {
287
+ if (e.target !== e.currentTarget) return
288
+ if (e.key === 'Enter' || e.key === ' ') {
289
+ e.preventDefault()
290
+ onEdit(item)
291
+ }
292
+ }}
293
+ aria-label={`Edit SEO metadata for ${item.name}`}
294
+ className="cursor-pointer border-b border-[var(--bdr)] transition-colors last:border-b-0 hover:bg-[var(--acc-l)] focus-visible:bg-[var(--acc-l)] focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none focus-visible:ring-inset"
258
295
  >
259
296
  <td className="px-3 py-[9px] align-middle">
260
297
  <div className="flex items-center gap-2.5">
@@ -12,6 +12,10 @@ export interface PageEditorProps {
12
12
  onNavigate?: (path: string) => void
13
13
  }
14
14
 
15
+ /**
16
+ * @deprecated Superseded by the visual page builder (`PageBuilder` and the
17
+ * `views/page-editor/` section editor); will be removed in the next major.
18
+ */
15
19
  export function PageEditor({ id, onNavigate }: PageEditorProps) {
16
20
  const isNew = !id
17
21
  const { data, loading, error } = useApiData<any>(isNew ? '' : `/collections/pages/${id}`)