@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.
- package/CHANGELOG.md +16 -0
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +29 -5
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/hooks/useIdleLogout.test.d.ts +2 -0
- package/dist/__tests__/hooks/useIdleLogout.test.d.ts.map +1 -0
- package/dist/__tests__/hooks/useIdleLogout.test.js +64 -0
- package/dist/__tests__/hooks/useIdleLogout.test.js.map +1 -0
- package/dist/__tests__/lib/post-types-service.test.d.ts +2 -0
- package/dist/__tests__/lib/post-types-service.test.d.ts.map +1 -0
- package/dist/__tests__/lib/post-types-service.test.js +88 -0
- package/dist/__tests__/lib/post-types-service.test.js.map +1 -0
- package/dist/__tests__/views/collection-list-nav.render.test.js +43 -5
- package/dist/__tests__/views/collection-list-nav.render.test.js.map +1 -1
- package/dist/__tests__/views/forms-list.render.test.js +25 -6
- package/dist/__tests__/views/forms-list.render.test.js.map +1 -1
- package/dist/__tests__/views/page-builder-sections-panel.render.test.d.ts +2 -0
- package/dist/__tests__/views/page-builder-sections-panel.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/page-builder-sections-panel.render.test.js +121 -0
- package/dist/__tests__/views/page-builder-sections-panel.render.test.js.map +1 -0
- package/dist/__tests__/views/page-templates.render.test.d.ts +2 -0
- package/dist/__tests__/views/page-templates.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/page-templates.render.test.js +105 -0
- package/dist/__tests__/views/page-templates.render.test.js.map +1 -0
- package/dist/__tests__/views/post-type-editor.render.test.d.ts +2 -0
- package/dist/__tests__/views/post-type-editor.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/post-type-editor.render.test.js +102 -0
- package/dist/__tests__/views/post-type-editor.render.test.js.map +1 -0
- package/dist/__tests__/views/security-settings.render.test.js +85 -4
- package/dist/__tests__/views/security-settings.render.test.js.map +1 -1
- package/dist/__tests__/views/seo-content-pane.render.test.d.ts +2 -0
- package/dist/__tests__/views/seo-content-pane.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/seo-content-pane.render.test.js +117 -0
- package/dist/__tests__/views/seo-content-pane.render.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/components/collections/EntryDetailPane.d.ts +10 -0
- package/dist/components/collections/EntryDetailPane.d.ts.map +1 -0
- package/dist/components/collections/EntryDetailPane.js +92 -0
- package/dist/components/collections/EntryDetailPane.js.map +1 -0
- package/dist/components/forms/FieldsPanel.js +1 -1
- package/dist/components/forms/FieldsPanel.js.map +1 -1
- package/dist/components/forms/FormSchemaPane.d.ts +8 -0
- package/dist/components/forms/FormSchemaPane.d.ts.map +1 -0
- package/dist/components/forms/FormSchemaPane.js +95 -0
- package/dist/components/forms/FormSchemaPane.js.map +1 -0
- package/dist/components/seo/SeoEditorPane.d.ts +9 -0
- package/dist/components/seo/SeoEditorPane.d.ts.map +1 -0
- package/dist/components/seo/SeoEditorPane.js +175 -0
- package/dist/components/seo/SeoEditorPane.js.map +1 -0
- package/dist/components/ui/Badge.d.ts +1 -1
- package/dist/components/ui/Button.d.ts +1 -1
- package/dist/components/ui/StatusBadge.d.ts +1 -1
- package/dist/hooks/useIdleLogout.d.ts +18 -0
- package/dist/hooks/useIdleLogout.d.ts.map +1 -0
- package/dist/hooks/useIdleLogout.js +66 -0
- package/dist/hooks/useIdleLogout.js.map +1 -0
- package/dist/lib/api.d.ts +1 -0
- package/dist/lib/api.d.ts.map +1 -1
- package/dist/lib/api.js +7 -1
- package/dist/lib/api.js.map +1 -1
- package/dist/lib/post-types-service.d.ts +44 -0
- package/dist/lib/post-types-service.d.ts.map +1 -0
- package/dist/lib/post-types-service.js +83 -0
- package/dist/lib/post-types-service.js.map +1 -0
- package/dist/views/CollectionList.d.ts.map +1 -1
- package/dist/views/CollectionList.js +23 -4
- package/dist/views/CollectionList.js.map +1 -1
- package/dist/views/Forms.d.ts.map +1 -1
- package/dist/views/Forms.js +25 -21
- package/dist/views/Forms.js.map +1 -1
- package/dist/views/Posts/PostTypeEditor.d.ts +16 -0
- package/dist/views/Posts/PostTypeEditor.d.ts.map +1 -0
- package/dist/views/Posts/PostTypeEditor.js +159 -0
- package/dist/views/Posts/PostTypeEditor.js.map +1 -0
- package/dist/views/Posts/PostTypesView.d.ts +3 -2
- package/dist/views/Posts/PostTypesView.d.ts.map +1 -1
- package/dist/views/Posts/PostTypesView.js +1 -1
- package/dist/views/Posts/PostTypesView.js.map +1 -1
- package/dist/views/page-builder/BuilderSectionsPanel.d.ts +20 -0
- package/dist/views/page-builder/BuilderSectionsPanel.d.ts.map +1 -0
- package/dist/views/page-builder/BuilderSectionsPanel.js +105 -0
- package/dist/views/page-builder/BuilderSectionsPanel.js.map +1 -0
- package/dist/views/page-builder/PageBuilder.d.ts.map +1 -1
- package/dist/views/page-builder/PageBuilder.js +51 -2
- package/dist/views/page-builder/PageBuilder.js.map +1 -1
- package/dist/views/page-builder/PageTemplates.d.ts.map +1 -1
- package/dist/views/page-builder/PageTemplates.js +113 -6
- package/dist/views/page-builder/PageTemplates.js.map +1 -1
- package/dist/views/seo/ContentTab.d.ts.map +1 -1
- package/dist/views/seo/ContentTab.js +22 -6
- package/dist/views/seo/ContentTab.js.map +1 -1
- package/dist/views/settings/AccessProtectionCard.d.ts +5 -1
- package/dist/views/settings/AccessProtectionCard.d.ts.map +1 -1
- package/dist/views/settings/AccessProtectionCard.js +13 -10
- package/dist/views/settings/AccessProtectionCard.js.map +1 -1
- package/dist/views/settings/SecurityTab.d.ts.map +1 -1
- package/dist/views/settings/SecurityTab.js +3 -3
- package/dist/views/settings/SecurityTab.js.map +1 -1
- package/dist/views/settings/SessionPolicyCard.d.ts +1 -2
- package/dist/views/settings/SessionPolicyCard.d.ts.map +1 -1
- package/dist/views/settings/SessionPolicyCard.js +12 -5
- package/dist/views/settings/SessionPolicyCard.js.map +1 -1
- package/dist/views/settings/useSecuritySettings.d.ts +13 -1
- package/dist/views/settings/useSecuritySettings.d.ts.map +1 -1
- package/dist/views/settings/useSecuritySettings.js +47 -4
- package/dist/views/settings/useSecuritySettings.js.map +1 -1
- package/package.json +2 -2
- package/src/AdminRoot.tsx +36 -7
- package/src/__tests__/hooks/useIdleLogout.test.tsx +86 -0
- package/src/__tests__/lib/post-types-service.test.ts +112 -0
- package/src/__tests__/views/collection-list-nav.render.test.tsx +51 -5
- package/src/__tests__/views/forms-list.render.test.tsx +34 -6
- package/src/__tests__/views/page-builder-sections-panel.render.test.tsx +138 -0
- package/src/__tests__/views/page-templates.render.test.tsx +142 -0
- package/src/__tests__/views/post-type-editor.render.test.tsx +116 -0
- package/src/__tests__/views/security-settings.render.test.tsx +111 -4
- package/src/__tests__/views/seo-content-pane.render.test.tsx +142 -0
- package/src/components/collections/EntryDetailPane.tsx +182 -0
- package/src/components/forms/FieldsPanel.tsx +3 -1
- package/src/components/forms/FormSchemaPane.tsx +192 -0
- package/src/components/seo/SeoEditorPane.tsx +533 -0
- package/src/hooks/useIdleLogout.ts +77 -0
- package/src/lib/api.ts +8 -2
- package/src/lib/post-types-service.ts +129 -0
- package/src/views/CollectionList.tsx +158 -110
- package/src/views/Forms.tsx +154 -126
- package/src/views/Posts/PostTypeEditor.tsx +568 -0
- package/src/views/Posts/PostTypesView.tsx +5 -4
- package/src/views/page-builder/BuilderSectionsPanel.tsx +304 -0
- package/src/views/page-builder/PageBuilder.tsx +69 -2
- package/src/views/page-builder/PageTemplates.tsx +340 -57
- package/src/views/seo/ContentTab.tsx +113 -80
- package/src/views/settings/AccessProtectionCard.tsx +83 -53
- package/src/views/settings/SecurityTab.tsx +23 -2
- package/src/views/settings/SessionPolicyCard.tsx +45 -9
- package/src/views/settings/useSecuritySettings.ts +78 -22
- package/dist/components/forms/FormSchemaDrawer.d.ts +0 -9
- package/dist/components/forms/FormSchemaDrawer.d.ts.map +0 -1
- package/dist/components/forms/FormSchemaDrawer.js +0 -99
- package/dist/components/forms/FormSchemaDrawer.js.map +0 -1
- package/dist/components/seo/SeoEditorDrawer.d.ts +0 -8
- package/dist/components/seo/SeoEditorDrawer.d.ts.map +0 -1
- package/dist/components/seo/SeoEditorDrawer.js +0 -172
- package/dist/components/seo/SeoEditorDrawer.js.map +0 -1
- package/src/components/forms/FormSchemaDrawer.tsx +0 -192
- package/src/components/seo/SeoEditorDrawer.tsx +0 -529
|
@@ -1,7 +1,20 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import {
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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="
|
|
36
|
-
<AlertTriangle className="
|
|
37
|
-
<span className="
|
|
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="
|
|
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-
|
|
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="
|
|
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
|
|
66
|
-
<div className="
|
|
67
|
-
<
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
+
}
|