@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
@@ -1,7 +1,20 @@
1
1
  'use client'
2
2
 
3
- import { AlertTriangle, Layers, Loader2, RefreshCw } from 'lucide-react'
3
+ import { useMemo, useState } from 'react'
4
+ import { AlertTriangle, Layers, Pencil, RefreshCw, Trash2 } from 'lucide-react'
5
+ import { toast } from 'sonner'
6
+ import { cmsApi } from '../../lib/api.js'
4
7
  import { useApiData } from '../../lib/useApiData.js'
8
+ import { ConfirmDialog } from '../../components/ui/ConfirmDialog.js'
9
+ import { EmptyState } from '../../components/ui/EmptyState.js'
10
+ import { FilterPill } from '../../components/ui/FilterPill.js'
11
+ import { Input } from '../../components/ui/Input.js'
12
+ import { Modal } from '../../components/ui/Modal.js'
13
+ import { Button } from '../../components/ui/Button.js'
14
+ import { RowActions, RowActionButton } from '../../components/ui/RowActions.js'
15
+ import { SearchInput } from '../../components/ui/SearchInput.js'
16
+ import { Select } from '../../components/ui/Select.js'
17
+ import { Skeleton } from '../../components/ui/Skeleton.js'
5
18
 
6
19
  interface PageTemplate {
7
20
  id: string
@@ -10,6 +23,25 @@ interface PageTemplate {
10
23
  category?: string
11
24
  builtIn?: boolean
12
25
  updatedAt?: string
26
+ thumbnail?: string | null
27
+ tree?: { children?: unknown[] } | null
28
+ }
29
+
30
+ type CategoryId = 'content' | 'landing' | 'utility'
31
+
32
+ const CATEGORIES: Record<CategoryId, { label: string; dot: string; text: string }> = {
33
+ content: { label: 'Content', dot: 'bg-[var(--acc)]', text: 'text-[var(--acc)]' },
34
+ landing: { label: 'Landing', dot: 'bg-[var(--suc)]', text: 'text-[var(--suc)]' },
35
+ utility: { label: 'Utility', dot: 'bg-[var(--warn)]', text: 'text-[var(--warn)]' },
36
+ }
37
+
38
+ function categoryOf(template: PageTemplate): CategoryId {
39
+ const cat = template.category
40
+ return cat === 'landing' || cat === 'utility' ? cat : 'content'
41
+ }
42
+
43
+ function sectionCountOf(template: PageTemplate): number {
44
+ return Array.isArray(template.tree?.children) ? template.tree.children.length : 0
13
45
  }
14
46
 
15
47
  export interface PageTemplatesProps {
@@ -18,94 +50,345 @@ export interface PageTemplatesProps {
18
50
 
19
51
  export function PageTemplates({ onNavigate }: PageTemplatesProps) {
20
52
  const { data, loading, error, refetch } = useApiData<PageTemplate[]>('/page-templates')
21
- const templates = data ?? []
53
+ const templates = useMemo(() => data ?? [], [data])
22
54
 
23
- if (loading) {
24
- return (
25
- <div className="flex h-64 items-center justify-center p-4" role="status" aria-live="polite">
26
- <Loader2 className="text-primary h-6 w-6 animate-spin" />
27
- <span className="sr-only">Loading page templates</span>
28
- </div>
29
- )
55
+ const [search, setSearch] = useState('')
56
+ const [category, setCategory] = useState<CategoryId | 'all'>('all')
57
+ const [editTarget, setEditTarget] = useState<PageTemplate | null>(null)
58
+ const [deleteTarget, setDeleteTarget] = useState<PageTemplate | null>(null)
59
+
60
+ const filtered = useMemo(() => {
61
+ const q = search.trim().toLowerCase()
62
+ return templates.filter((t) => {
63
+ const matchesQuery =
64
+ !q ||
65
+ (t.name ?? '').toLowerCase().includes(q) ||
66
+ (t.description ?? '').toLowerCase().includes(q)
67
+ const matchesCategory = category === 'all' || categoryOf(t) === category
68
+ return matchesQuery && matchesCategory
69
+ })
70
+ }, [templates, search, category])
71
+
72
+ async function confirmDelete() {
73
+ if (!deleteTarget) return
74
+ const res = await cmsApi(`/page-templates/${deleteTarget.id}`, { method: 'DELETE' })
75
+ if (res.error) {
76
+ toast.error(res.error)
77
+ } else {
78
+ toast.success('Template deleted')
79
+ refetch()
80
+ }
30
81
  }
31
82
 
32
83
  return (
33
84
  <div className="p-4 pr-8">
34
85
  {error && (
35
- <div className="border-border bg-card mb-4 flex items-center gap-3 rounded-lg border p-3">
36
- <AlertTriangle className="text-muted-foreground h-5 w-5 shrink-0" />
37
- <span className="text-foreground flex-1 text-sm">{error}</span>
86
+ <div className="mb-4 flex items-center gap-3 rounded-[var(--r)] border border-[var(--bdr)] bg-[var(--card)] p-3">
87
+ <AlertTriangle className="h-5 w-5 shrink-0 text-[var(--muted)]" aria-hidden />
88
+ <span className="flex-1 text-sm text-[var(--txt)]">{error}</span>
38
89
  <button
39
90
  type="button"
40
91
  onClick={refetch}
41
- className="border-border text-foreground hover:bg-accent rounded-md border px-3 py-1 text-sm"
92
+ className="rounded-md border border-[var(--bdr)] px-3 py-1 text-sm text-[var(--txt)] hover:bg-[var(--acc-l)]"
42
93
  >
43
94
  Retry
44
95
  </button>
45
96
  </div>
46
97
  )}
47
98
 
48
- <div className="mb-4 flex items-center justify-between">
99
+ <div className="mb-4 flex items-center justify-between gap-4">
49
100
  <div>
50
101
  <h1 className="text-foreground mb-1">Page Templates</h1>
51
- <p className="text-muted-foreground text-sm">
102
+ <p className="text-sm text-[var(--sub)]">
52
103
  {templates.length} saved template{templates.length === 1 ? '' : 's'}
53
104
  </p>
54
105
  </div>
55
106
  <button
56
107
  type="button"
57
108
  onClick={refetch}
58
- className="border-border text-foreground hover:bg-accent inline-flex items-center gap-2 rounded-md border px-3 py-2 text-sm font-medium"
109
+ className="inline-flex items-center gap-2 rounded-md border border-[var(--bdr)] px-3 py-2 text-sm font-medium text-[var(--txt)] transition-colors duration-[var(--motion-fast)] hover:border-[var(--acc-m)] hover:bg-[var(--acc-l)] hover:text-[var(--acc)]"
59
110
  >
60
- <RefreshCw className="h-4 w-4" />
111
+ <RefreshCw className="h-4 w-4" aria-hidden />
61
112
  Refresh
62
113
  </button>
63
114
  </div>
64
115
 
65
- {templates.length === 0 ? (
66
- <div className="border-border bg-card rounded-lg border p-8 text-center">
67
- <Layers className="text-muted-foreground mx-auto mb-3 h-8 w-8" />
68
- <h2 className="text-foreground mb-1 text-lg font-medium">No page templates yet</h2>
69
- <p className="text-muted-foreground mb-4 text-sm">
70
- Built-in templates are seeded by the CMS when the templates API is available.
71
- </p>
72
- <button
73
- type="button"
74
- onClick={() => onNavigate?.('/saved-sections')}
75
- className="bg-primary text-primary-foreground rounded-md px-4 py-2 text-sm font-medium hover:opacity-90"
76
- >
77
- View Saved Sections
78
- </button>
116
+ {!loading && templates.length > 0 && (
117
+ <div className="mb-4 flex flex-wrap items-center gap-2">
118
+ <div className="max-w-xs min-w-52 flex-1">
119
+ <SearchInput value={search} onChange={setSearch} placeholder="Search templates…" />
120
+ </div>
121
+ <div className="flex gap-1.5" role="group" aria-label="Filter templates by category">
122
+ <FilterPill active={category === 'all'} onClick={() => setCategory('all')}>
123
+ All
124
+ </FilterPill>
125
+ {(Object.keys(CATEGORIES) as CategoryId[]).map((cat) => (
126
+ <FilterPill key={cat} active={category === cat} onClick={() => setCategory(cat)}>
127
+ {CATEGORIES[cat].label}
128
+ </FilterPill>
129
+ ))}
130
+ </div>
131
+ <span className="ml-auto text-[12.5px] whitespace-nowrap text-[var(--muted)]">
132
+ {filtered.length} of {templates.length}
133
+ </span>
79
134
  </div>
80
- ) : (
81
- <div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
82
- {templates.map((template) => (
83
- <div key={template.id} className="border-border bg-card rounded-lg border p-4">
84
- <div className="mb-3 flex items-start justify-between gap-3">
85
- <div>
86
- <h2 className="text-foreground text-base font-medium">
87
- {template.name ?? 'Untitled template'}
88
- </h2>
89
- <p className="text-muted-foreground mt-1 text-sm">
90
- {template.description ?? 'No description provided.'}
91
- </p>
92
- </div>
93
- {template.builtIn && (
94
- <span className="bg-muted text-muted-foreground rounded-full px-2 py-0.5 text-xs">
95
- Built-in
96
- </span>
97
- )}
98
- </div>
99
- <div className="text-muted-foreground flex items-center justify-between text-sm">
100
- <span>{template.category ?? 'content'}</span>
101
- <span>
102
- {template.updatedAt ? new Date(template.updatedAt).toLocaleDateString() : ''}
103
- </span>
104
- </div>
105
- </div>
135
+ )}
136
+
137
+ {loading ? (
138
+ <div
139
+ className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3"
140
+ role="status"
141
+ aria-live="polite"
142
+ aria-label="Loading page templates"
143
+ >
144
+ {Array.from({ length: 6 }, (_, i) => (
145
+ <Skeleton key={i} variant="card" />
106
146
  ))}
107
147
  </div>
148
+ ) : templates.length === 0 ? (
149
+ <EmptyState
150
+ icon={<Layers size={24} aria-hidden />}
151
+ title="No page templates yet"
152
+ description="Built-in templates are seeded by the CMS when the templates API is available."
153
+ actionLabel="View Saved Sections"
154
+ onAction={() => onNavigate?.('/saved-sections')}
155
+ />
156
+ ) : filtered.length === 0 ? (
157
+ <p className="py-12 text-center text-sm text-[var(--muted)]">
158
+ No templates match your search.
159
+ </p>
160
+ ) : (
161
+ <ul
162
+ className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3"
163
+ aria-label="Page templates"
164
+ >
165
+ {filtered.map((template) => (
166
+ <TemplateCard
167
+ key={template.id}
168
+ template={template}
169
+ onEdit={() => setEditTarget(template)}
170
+ onDelete={() => setDeleteTarget(template)}
171
+ />
172
+ ))}
173
+ </ul>
108
174
  )}
175
+
176
+ <TemplateEditModal
177
+ template={editTarget}
178
+ onClose={() => setEditTarget(null)}
179
+ onSaved={() => {
180
+ setEditTarget(null)
181
+ refetch()
182
+ }}
183
+ />
184
+
185
+ <ConfirmDialog
186
+ open={deleteTarget !== null}
187
+ onClose={() => setDeleteTarget(null)}
188
+ onConfirm={() => void confirmDelete()}
189
+ title="Delete template?"
190
+ description={`"${deleteTarget?.name ?? 'This template'}" will be permanently removed. Pages already created from it are not affected.`}
191
+ confirmLabel="Delete"
192
+ destructive
193
+ />
109
194
  </div>
110
195
  )
111
196
  }
197
+
198
+ function TemplateCard({
199
+ template,
200
+ onEdit,
201
+ onDelete,
202
+ }: {
203
+ template: PageTemplate
204
+ onEdit: () => void
205
+ onDelete: () => void
206
+ }) {
207
+ const cat = CATEGORIES[categoryOf(template)]
208
+ const sections = sectionCountOf(template)
209
+ const name = template.name ?? 'Untitled template'
210
+ const updated = template.updatedAt ? new Date(template.updatedAt).toLocaleDateString() : null
211
+
212
+ return (
213
+ <li className="group relative flex flex-col overflow-hidden rounded-[var(--r)] border border-[var(--bdr)] bg-[var(--card)] shadow-[var(--shd)] transition-[border-color,box-shadow,transform] duration-[var(--motion-fast)] focus-within:border-[var(--acc-m)] focus-within:shadow-[var(--shd2)] hover:-translate-y-0.5 hover:border-[var(--acc-m)] hover:shadow-[var(--shd2)] motion-reduce:hover:translate-y-0">
214
+ <TemplatePreview template={template} sections={sections} />
215
+
216
+ <div className="flex flex-1 flex-col p-4 pb-0">
217
+ <div className="flex items-start justify-between gap-3">
218
+ <h2 className="text-[15px] leading-snug font-medium text-[var(--txt)]">{name}</h2>
219
+ {template.builtIn && (
220
+ <span className="shrink-0 rounded-full border border-[var(--bdr)] bg-[var(--bg)] px-2 py-0.5 text-[10.5px] font-medium whitespace-nowrap text-[var(--sub)]">
221
+ Built-in
222
+ </span>
223
+ )}
224
+ </div>
225
+ <p className="mt-2 min-h-10 text-[12.5px] leading-relaxed text-[var(--sub)]">
226
+ {template.description ?? 'No description provided.'}
227
+ </p>
228
+
229
+ <div className="relative mt-3 flex h-[46px] items-center justify-between border-t border-[var(--bdr)] py-2.5">
230
+ <span
231
+ className={`inline-flex items-center gap-1.5 text-[11.5px] font-medium ${cat.text}`}
232
+ >
233
+ <span aria-hidden className={`h-[7px] w-[7px] shrink-0 rounded-full ${cat.dot}`} />
234
+ {cat.label}
235
+ </span>
236
+ <span className="text-[11.5px] text-[var(--muted)] transition-opacity duration-[var(--motion-fast)] group-focus-within:opacity-0 group-hover:opacity-0">
237
+ {sections} section{sections === 1 ? '' : 's'}
238
+ {updated ? ` · ${updated}` : ''}
239
+ </span>
240
+ {!template.builtIn && (
241
+ <RowActions className="absolute top-1/2 right-0 -translate-y-1/2">
242
+ <RowActionButton aria-label={`Edit ${name}`} onClick={onEdit}>
243
+ <Pencil size={14} aria-hidden />
244
+ </RowActionButton>
245
+ <RowActionButton aria-label={`Delete ${name}`} danger onClick={onDelete}>
246
+ <Trash2 size={14} aria-hidden />
247
+ </RowActionButton>
248
+ </RowActions>
249
+ )}
250
+ </div>
251
+ </div>
252
+ </li>
253
+ )
254
+ }
255
+
256
+ /**
257
+ * Card preview area. Uses the stored thumbnail when one exists; otherwise
258
+ * renders an honest wireframe placeholder built from the template's section
259
+ * count (a hero band plus one bar per section) — no fake screenshots.
260
+ */
261
+ function TemplatePreview({ template, sections }: { template: PageTemplate; sections: number }) {
262
+ if (template.thumbnail) {
263
+ return (
264
+ <div className="h-28 shrink-0 overflow-hidden border-b border-[var(--bdr)] bg-[var(--bg)]">
265
+ <img
266
+ src={template.thumbnail}
267
+ alt={`Preview of ${template.name ?? 'template'}`}
268
+ className="h-full w-full object-cover"
269
+ />
270
+ </div>
271
+ )
272
+ }
273
+
274
+ const bars = Math.max(0, Math.min(sections - 1, 3))
275
+ return (
276
+ <div
277
+ aria-hidden
278
+ className="flex h-28 shrink-0 flex-col gap-1.5 border-b border-[var(--bdr)] bg-[var(--bg)] p-3"
279
+ >
280
+ <div className="h-9 rounded-[5px] bg-[var(--acc-l)]" />
281
+ {Array.from({ length: bars }, (_, i) => (
282
+ <div
283
+ key={i}
284
+ className="h-3.5 rounded-[4px] bg-[var(--bdr)]"
285
+ style={{ width: `${88 - i * 14}%` }}
286
+ />
287
+ ))}
288
+ </div>
289
+ )
290
+ }
291
+
292
+ function TemplateEditModal({
293
+ template,
294
+ onClose,
295
+ onSaved,
296
+ }: {
297
+ template: PageTemplate | null
298
+ onClose: () => void
299
+ onSaved: () => void
300
+ }) {
301
+ // Keyed remount seeds the form state from the selected template each time
302
+ // the modal opens, without effect-based state syncing.
303
+ if (!template) return null
304
+ return (
305
+ <TemplateEditForm key={template.id} template={template} onClose={onClose} onSaved={onSaved} />
306
+ )
307
+ }
308
+
309
+ function TemplateEditForm({
310
+ template,
311
+ onClose,
312
+ onSaved,
313
+ }: {
314
+ template: PageTemplate
315
+ onClose: () => void
316
+ onSaved: () => void
317
+ }) {
318
+ const [name, setName] = useState(template.name ?? '')
319
+ const [description, setDescription] = useState(template.description ?? '')
320
+ const [category, setCategory] = useState<CategoryId>(categoryOf(template))
321
+ const [saving, setSaving] = useState(false)
322
+
323
+ async function handleSave() {
324
+ if (!name.trim()) {
325
+ toast.error('Template name is required')
326
+ return
327
+ }
328
+ setSaving(true)
329
+ const res = await cmsApi(`/page-templates/${template.id}`, {
330
+ method: 'PUT',
331
+ body: JSON.stringify({
332
+ name: name.trim(),
333
+ description: description.trim() || null,
334
+ category,
335
+ }),
336
+ })
337
+ setSaving(false)
338
+ if (res.error) {
339
+ toast.error(res.error)
340
+ } else {
341
+ toast.success('Template updated')
342
+ onSaved()
343
+ }
344
+ }
345
+
346
+ return (
347
+ <Modal
348
+ open
349
+ onClose={onClose}
350
+ title="Edit template"
351
+ actions={
352
+ <>
353
+ <Button variant="ghost" onClick={onClose} disabled={saving}>
354
+ Cancel
355
+ </Button>
356
+ <Button variant="primary" onClick={() => void handleSave()} loading={saving}>
357
+ Save changes
358
+ </Button>
359
+ </>
360
+ }
361
+ >
362
+ <div className="flex flex-col gap-4">
363
+ <label className="flex flex-col gap-1.5">
364
+ <span className="text-sm font-medium text-[var(--txt)]">Name</span>
365
+ <Input
366
+ value={name}
367
+ onChange={(e) => setName(e.target.value)}
368
+ placeholder="Template name"
369
+ />
370
+ </label>
371
+ <label className="flex flex-col gap-1.5">
372
+ <span className="text-sm font-medium text-[var(--txt)]">Description</span>
373
+ <textarea
374
+ value={description}
375
+ onChange={(e) => setDescription(e.target.value)}
376
+ rows={3}
377
+ placeholder="What this template is for…"
378
+ className="w-full resize-y rounded-[var(--r-control)] border border-[var(--bdr)] bg-[var(--input-background)] px-3 py-2 text-sm text-[var(--txt)] placeholder:text-[var(--muted)] focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none"
379
+ />
380
+ </label>
381
+ <label className="flex flex-col gap-1.5">
382
+ <span className="text-sm font-medium text-[var(--txt)]">Category</span>
383
+ <Select value={category} onChange={(e) => setCategory(e.target.value as CategoryId)}>
384
+ {(Object.keys(CATEGORIES) as CategoryId[]).map((cat) => (
385
+ <option key={cat} value={cat}>
386
+ {CATEGORIES[cat].label}
387
+ </option>
388
+ ))}
389
+ </Select>
390
+ </label>
391
+ </div>
392
+ </Modal>
393
+ )
394
+ }