@actuate-media/cms-admin 0.3.0 → 0.6.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 (49) hide show
  1. package/dist/AdminRoot.d.ts.map +1 -1
  2. package/dist/AdminRoot.js +23 -1
  3. package/dist/AdminRoot.js.map +1 -1
  4. package/dist/actuate-admin.css +1 -1
  5. package/dist/components/Breadcrumbs.d.ts.map +1 -1
  6. package/dist/components/Breadcrumbs.js +1 -0
  7. package/dist/components/Breadcrumbs.js.map +1 -1
  8. package/dist/index.d.ts +4 -0
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +2 -0
  11. package/dist/index.js.map +1 -1
  12. package/dist/layout/Sidebar.d.ts +7 -0
  13. package/dist/layout/Sidebar.d.ts.map +1 -1
  14. package/dist/layout/Sidebar.js +35 -11
  15. package/dist/layout/Sidebar.js.map +1 -1
  16. package/dist/views/DocumentEdit.d.ts.map +1 -1
  17. package/dist/views/DocumentEdit.js +49 -5
  18. package/dist/views/DocumentEdit.js.map +1 -1
  19. package/dist/views/ForgotPassword.d.ts +5 -0
  20. package/dist/views/ForgotPassword.d.ts.map +1 -0
  21. package/dist/views/ForgotPassword.js +41 -0
  22. package/dist/views/ForgotPassword.js.map +1 -0
  23. package/dist/views/ResetPassword.d.ts +6 -0
  24. package/dist/views/ResetPassword.d.ts.map +1 -0
  25. package/dist/views/ResetPassword.js +46 -0
  26. package/dist/views/ResetPassword.js.map +1 -0
  27. package/dist/views/ScriptTagEditor.d.ts +6 -0
  28. package/dist/views/ScriptTagEditor.d.ts.map +1 -0
  29. package/dist/views/ScriptTagEditor.js +109 -0
  30. package/dist/views/ScriptTagEditor.js.map +1 -0
  31. package/dist/views/ScriptTags.d.ts +5 -0
  32. package/dist/views/ScriptTags.d.ts.map +1 -0
  33. package/dist/views/ScriptTags.js +54 -0
  34. package/dist/views/ScriptTags.js.map +1 -0
  35. package/dist/views/Settings.d.ts +2 -1
  36. package/dist/views/Settings.d.ts.map +1 -1
  37. package/dist/views/Settings.js +31 -3
  38. package/dist/views/Settings.js.map +1 -1
  39. package/package.json +5 -3
  40. package/src/AdminRoot.tsx +26 -1
  41. package/src/components/Breadcrumbs.tsx +1 -0
  42. package/src/index.ts +4 -0
  43. package/src/layout/Sidebar.tsx +72 -22
  44. package/src/views/DocumentEdit.tsx +82 -4
  45. package/src/views/ForgotPassword.tsx +136 -0
  46. package/src/views/ResetPassword.tsx +192 -0
  47. package/src/views/ScriptTagEditor.tsx +361 -0
  48. package/src/views/ScriptTags.tsx +174 -0
  49. package/src/views/Settings.tsx +79 -2
@@ -0,0 +1,361 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect } from 'react';
4
+ import { ArrowLeft, Loader2, AlertTriangle, Trash2, X, Plus } from 'lucide-react';
5
+ import { toast } from 'sonner';
6
+ import { useApiData } from '../lib/useApiData.js';
7
+ import { cmsApi } from '../lib/api.js';
8
+
9
+ export interface ScriptTagEditorProps {
10
+ tagId?: string;
11
+ onNavigate?: (path: string) => void;
12
+ }
13
+
14
+ export function ScriptTagEditor({ tagId, onNavigate }: ScriptTagEditorProps) {
15
+ const isNew = !tagId;
16
+ const { data, loading, error } = useApiData<any>(
17
+ tagId ? `/script-tags` : null,
18
+ );
19
+
20
+ const [name, setName] = useState('');
21
+ const [code, setCode] = useState('');
22
+ const [placement, setPlacement] = useState('head');
23
+ const [scope, setScope] = useState('site');
24
+ const [targetPaths, setTargetPaths] = useState<string[]>([]);
25
+ const [pathInput, setPathInput] = useState('');
26
+ const [priority, setPriority] = useState(100);
27
+ const [enabled, setEnabled] = useState(true);
28
+ const [saving, setSaving] = useState(false);
29
+ const [deleting, setDeleting] = useState(false);
30
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
31
+
32
+ useEffect(() => {
33
+ if (data && tagId) {
34
+ const tags = Array.isArray(data) ? data : [];
35
+ const tag = tags.find((t: any) => t.id === tagId);
36
+ if (tag) {
37
+ setName(tag.name ?? '');
38
+ setCode(tag.code ?? '');
39
+ setPlacement(tag.placement ?? 'head');
40
+ setScope(tag.scope ?? 'site');
41
+ setTargetPaths(tag.targetPaths ?? []);
42
+ setPriority(tag.priority ?? 100);
43
+ setEnabled(tag.enabled ?? true);
44
+ }
45
+ }
46
+ }, [data, tagId]);
47
+
48
+ const addPath = () => {
49
+ const trimmed = pathInput.trim();
50
+ if (!trimmed) return;
51
+ const normalized = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
52
+ if (!targetPaths.includes(normalized)) {
53
+ setTargetPaths([...targetPaths, normalized]);
54
+ }
55
+ setPathInput('');
56
+ };
57
+
58
+ const removePath = (path: string) => {
59
+ setTargetPaths(targetPaths.filter((p) => p !== path));
60
+ };
61
+
62
+ const handleSave = async () => {
63
+ if (!name.trim()) {
64
+ toast.error('Name is required');
65
+ return;
66
+ }
67
+ if (!code.trim()) {
68
+ toast.error('Code snippet is required');
69
+ return;
70
+ }
71
+ if (scope !== 'site' && targetPaths.length === 0) {
72
+ toast.error('At least one target path is required for this scope');
73
+ return;
74
+ }
75
+
76
+ setSaving(true);
77
+ const payload = {
78
+ name: name.trim(),
79
+ code,
80
+ placement,
81
+ scope,
82
+ targetPaths: scope === 'site' ? [] : targetPaths,
83
+ priority,
84
+ enabled,
85
+ };
86
+
87
+ const res = isNew
88
+ ? await cmsApi('/script-tags', { method: 'POST', body: JSON.stringify(payload) })
89
+ : await cmsApi(`/script-tags/${tagId}`, { method: 'PUT', body: JSON.stringify(payload) });
90
+
91
+ setSaving(false);
92
+ if (res.error) {
93
+ toast.error(res.error);
94
+ } else {
95
+ toast.success(isNew ? 'Tag created' : 'Tag updated');
96
+ onNavigate?.('/script-tags');
97
+ }
98
+ };
99
+
100
+ const handleDelete = async () => {
101
+ if (!tagId) return;
102
+ setDeleting(true);
103
+ const res = await cmsApi(`/script-tags/${tagId}`, { method: 'DELETE' });
104
+ setDeleting(false);
105
+ if (res.error) {
106
+ toast.error(res.error);
107
+ } else {
108
+ toast.success('Tag deleted');
109
+ onNavigate?.('/script-tags');
110
+ }
111
+ };
112
+
113
+ if (loading && !isNew) {
114
+ return (
115
+ <div className="p-3 pr-6 sm:p-4 sm:pr-8 flex items-center justify-center h-64">
116
+ <Loader2 className="w-6 h-6 animate-spin text-blue-600" />
117
+ </div>
118
+ );
119
+ }
120
+
121
+ return (
122
+ <div className="p-3 pr-6 sm:p-4 sm:pr-8 max-w-3xl">
123
+ {error && (
124
+ <div className="mb-4 flex items-center gap-3 rounded-lg border border-red-200 bg-red-50 p-3">
125
+ <AlertTriangle className="w-5 h-5 text-red-600 shrink-0" />
126
+ <span className="text-sm text-red-800 flex-1">{error}</span>
127
+ </div>
128
+ )}
129
+
130
+ <div className="mb-6">
131
+ <button
132
+ type="button"
133
+ onClick={() => onNavigate?.('/script-tags')}
134
+ className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700 transition-colors mb-3"
135
+ >
136
+ <ArrowLeft className="w-4 h-4" />
137
+ Back to Script Tags
138
+ </button>
139
+ <h1 className="text-2xl font-semibold text-gray-900">
140
+ {isNew ? 'New Script Tag' : 'Edit Script Tag'}
141
+ </h1>
142
+ </div>
143
+
144
+ <div className="rounded-lg border border-amber-200 bg-amber-50 p-3 mb-6">
145
+ <div className="flex items-start gap-2">
146
+ <AlertTriangle className="w-4 h-4 text-amber-600 mt-0.5 shrink-0" />
147
+ <p className="text-sm text-amber-800">
148
+ This code will run on public pages matching the scope below. Only add trusted code from verified sources (e.g. Google Analytics, Meta Pixel).
149
+ </p>
150
+ </div>
151
+ </div>
152
+
153
+ <div className="space-y-6">
154
+ <div className="rounded-lg border border-gray-200 bg-white p-4 space-y-4">
155
+ <div>
156
+ <label className="mb-1 block text-sm font-medium text-gray-700">Name</label>
157
+ <input
158
+ type="text"
159
+ value={name}
160
+ onChange={(e) => setName(e.target.value)}
161
+ placeholder="e.g. Google Tag Manager, Facebook Pixel"
162
+ className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
163
+ />
164
+ </div>
165
+
166
+ <div>
167
+ <label className="mb-1 block text-sm font-medium text-gray-700">Code</label>
168
+ <textarea
169
+ value={code}
170
+ onChange={(e) => setCode(e.target.value)}
171
+ placeholder="Paste your HTML/JavaScript snippet here..."
172
+ rows={10}
173
+ className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500"
174
+ spellCheck={false}
175
+ />
176
+ <p className="mt-1 text-xs text-gray-500">
177
+ Paste the full code snippet including {'<script>'} tags.
178
+ </p>
179
+ </div>
180
+ </div>
181
+
182
+ <div className="rounded-lg border border-gray-200 bg-white p-4 space-y-4">
183
+ <h3 className="text-sm font-semibold text-gray-900">Placement & Scope</h3>
184
+
185
+ <div>
186
+ <label className="mb-1 block text-sm font-medium text-gray-700">Placement</label>
187
+ <select
188
+ value={placement}
189
+ onChange={(e) => setPlacement(e.target.value)}
190
+ className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
191
+ >
192
+ <option value="head">Head — inside {'<head>'}</option>
193
+ <option value="body_open">Body Open — right after {'<body>'}</option>
194
+ <option value="body_close">Body Close — before {'</body>'}</option>
195
+ </select>
196
+ <p className="mt-1 text-xs text-gray-500">
197
+ {placement === 'head' && 'Best for analytics scripts, meta tags, and custom CSS.'}
198
+ {placement === 'body_open' && 'Best for GTM noscript tags and early-loading scripts.'}
199
+ {placement === 'body_close' && 'Best for chat widgets, deferred scripts, and tracking pixels.'}
200
+ </p>
201
+ </div>
202
+
203
+ <div>
204
+ <label className="mb-1 block text-sm font-medium text-gray-700">Scope</label>
205
+ <select
206
+ value={scope}
207
+ onChange={(e) => setScope(e.target.value)}
208
+ className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
209
+ >
210
+ <option value="site">Entire Website</option>
211
+ <option value="parents">Specific Parent Pages (includes child pages)</option>
212
+ <option value="urls">Specific URLs (exact match)</option>
213
+ </select>
214
+ </div>
215
+
216
+ {scope !== 'site' && (
217
+ <div>
218
+ <label className="mb-1 block text-sm font-medium text-gray-700">
219
+ {scope === 'parents' ? 'Parent Paths' : 'URL Paths'}
220
+ </label>
221
+ <div className="flex gap-2">
222
+ <input
223
+ type="text"
224
+ value={pathInput}
225
+ onChange={(e) => setPathInput(e.target.value)}
226
+ onKeyDown={(e) => {
227
+ if (e.key === 'Enter') {
228
+ e.preventDefault();
229
+ addPath();
230
+ }
231
+ }}
232
+ placeholder="/services"
233
+ className="flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500"
234
+ />
235
+ <button
236
+ type="button"
237
+ onClick={addPath}
238
+ className="rounded-lg border border-gray-300 px-3 py-2 text-sm hover:bg-gray-50 transition-colors"
239
+ >
240
+ <Plus className="w-4 h-4" />
241
+ </button>
242
+ </div>
243
+ {scope === 'parents' && (
244
+ <p className="mt-1 text-xs text-gray-500">
245
+ This tag will also apply to all child pages under each path.
246
+ </p>
247
+ )}
248
+ {targetPaths.length > 0 && (
249
+ <div className="mt-2 flex flex-wrap gap-2">
250
+ {targetPaths.map((p) => (
251
+ <span
252
+ key={p}
253
+ className="inline-flex items-center gap-1 rounded-full bg-gray-100 px-3 py-1 text-xs font-mono text-gray-700"
254
+ >
255
+ {p}
256
+ <button
257
+ type="button"
258
+ onClick={() => removePath(p)}
259
+ className="ml-0.5 text-gray-400 hover:text-gray-600"
260
+ >
261
+ <X className="w-3 h-3" />
262
+ </button>
263
+ </span>
264
+ ))}
265
+ </div>
266
+ )}
267
+ </div>
268
+ )}
269
+
270
+ <div className="grid grid-cols-2 gap-4">
271
+ <div>
272
+ <label className="mb-1 block text-sm font-medium text-gray-700">Priority</label>
273
+ <input
274
+ type="number"
275
+ value={priority}
276
+ onChange={(e) => setPriority(parseInt(e.target.value, 10) || 0)}
277
+ min={0}
278
+ className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
279
+ />
280
+ <p className="mt-1 text-xs text-gray-500">
281
+ Lower numbers load first (e.g. 1 = first, 100 = default)
282
+ </p>
283
+ </div>
284
+ <div>
285
+ <label className="mb-1 block text-sm font-medium text-gray-700">Enabled</label>
286
+ <div className="pt-2">
287
+ <button
288
+ type="button"
289
+ onClick={() => setEnabled(!enabled)}
290
+ className={`relative h-6 w-11 shrink-0 rounded-full transition-colors ${enabled ? 'bg-blue-600' : 'bg-gray-300'}`}
291
+ aria-pressed={enabled}
292
+ >
293
+ <span
294
+ className={`absolute top-0.5 block h-5 w-5 rounded-full bg-white transition-transform ${
295
+ enabled ? 'translate-x-[22px]' : 'translate-x-0.5'
296
+ }`}
297
+ />
298
+ </button>
299
+ </div>
300
+ </div>
301
+ </div>
302
+ </div>
303
+
304
+ <div className="flex items-center justify-between">
305
+ <div>
306
+ {!isNew && (
307
+ <>
308
+ {showDeleteConfirm ? (
309
+ <div className="flex items-center gap-2">
310
+ <span className="text-sm text-red-600">Delete this tag?</span>
311
+ <button
312
+ type="button"
313
+ onClick={handleDelete}
314
+ disabled={deleting}
315
+ className="rounded-lg bg-red-600 px-3 py-1.5 text-sm text-white hover:bg-red-700 disabled:opacity-50"
316
+ >
317
+ {deleting ? 'Deleting...' : 'Confirm'}
318
+ </button>
319
+ <button
320
+ type="button"
321
+ onClick={() => setShowDeleteConfirm(false)}
322
+ className="rounded-lg border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-50"
323
+ >
324
+ Cancel
325
+ </button>
326
+ </div>
327
+ ) : (
328
+ <button
329
+ type="button"
330
+ onClick={() => setShowDeleteConfirm(true)}
331
+ className="flex items-center gap-1.5 text-sm text-red-600 hover:text-red-700 transition-colors"
332
+ >
333
+ <Trash2 className="w-4 h-4" />
334
+ Delete
335
+ </button>
336
+ )}
337
+ </>
338
+ )}
339
+ </div>
340
+ <div className="flex items-center gap-3">
341
+ <button
342
+ type="button"
343
+ onClick={() => onNavigate?.('/script-tags')}
344
+ className="rounded-lg border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
345
+ >
346
+ Cancel
347
+ </button>
348
+ <button
349
+ type="button"
350
+ onClick={handleSave}
351
+ disabled={saving}
352
+ className="rounded-lg bg-blue-600 px-6 py-2 text-sm text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
353
+ >
354
+ {saving ? 'Saving...' : isNew ? 'Create Tag' : 'Save Changes'}
355
+ </button>
356
+ </div>
357
+ </div>
358
+ </div>
359
+ </div>
360
+ );
361
+ }
@@ -0,0 +1,174 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { Code2, Plus, Loader2, AlertTriangle } from 'lucide-react';
5
+ import { toast } from 'sonner';
6
+ import { useApiData } from '../lib/useApiData.js';
7
+ import { cmsApi } from '../lib/api.js';
8
+
9
+ interface ScriptTag {
10
+ id: string;
11
+ name: string;
12
+ code: string;
13
+ placement: string;
14
+ scope: string;
15
+ targetPaths: string[];
16
+ priority: number;
17
+ enabled: boolean;
18
+ createdAt: string;
19
+ updatedAt: string;
20
+ }
21
+
22
+ export interface ScriptTagsProps {
23
+ onNavigate?: (path: string) => void;
24
+ }
25
+
26
+ const PLACEMENT_LABELS: Record<string, string> = {
27
+ head: 'Head',
28
+ body_open: 'Body Open',
29
+ body_close: 'Body Close',
30
+ };
31
+
32
+ const PLACEMENT_COLORS: Record<string, string> = {
33
+ head: 'bg-purple-100 text-purple-700',
34
+ body_open: 'bg-blue-100 text-blue-700',
35
+ body_close: 'bg-green-100 text-green-700',
36
+ };
37
+
38
+ function scopeLabel(tag: ScriptTag): string {
39
+ if (tag.scope === 'site') return 'Entire Site';
40
+ if (tag.scope === 'parents') {
41
+ const count = tag.targetPaths?.length ?? 0;
42
+ return `${count} parent path${count !== 1 ? 's' : ''} + children`;
43
+ }
44
+ if (tag.scope === 'urls') {
45
+ const count = tag.targetPaths?.length ?? 0;
46
+ return `${count} URL${count !== 1 ? 's' : ''}`;
47
+ }
48
+ return tag.scope;
49
+ }
50
+
51
+ export function ScriptTags({ onNavigate }: ScriptTagsProps) {
52
+ const { data, loading, error, refetch } = useApiData<ScriptTag[]>('/script-tags');
53
+ const [togglingId, setTogglingId] = useState<string | null>(null);
54
+
55
+ const tags = data ?? [];
56
+
57
+ const toggleEnabled = async (tag: ScriptTag) => {
58
+ setTogglingId(tag.id);
59
+ const res = await cmsApi(`/script-tags/${tag.id}`, {
60
+ method: 'PUT',
61
+ body: JSON.stringify({ enabled: !tag.enabled }),
62
+ });
63
+ setTogglingId(null);
64
+ if (res.error) {
65
+ toast.error(res.error);
66
+ } else {
67
+ refetch();
68
+ }
69
+ };
70
+
71
+ if (loading) {
72
+ return (
73
+ <div className="p-3 pr-6 sm:p-4 sm:pr-8 flex items-center justify-center h-64">
74
+ <Loader2 className="w-6 h-6 animate-spin text-blue-600" />
75
+ </div>
76
+ );
77
+ }
78
+
79
+ return (
80
+ <div className="p-3 pr-6 sm:p-4 sm:pr-8">
81
+ {error && (
82
+ <div className="mb-4 flex items-center gap-3 rounded-lg border border-red-200 bg-red-50 p-3">
83
+ <AlertTriangle className="w-5 h-5 text-red-600 shrink-0" />
84
+ <span className="text-sm text-red-800 flex-1">{error}</span>
85
+ <button onClick={refetch} className="px-3 py-1 text-sm text-red-700 border border-red-300 rounded-lg hover:bg-red-100 transition-colors">Retry</button>
86
+ </div>
87
+ )}
88
+
89
+ <div className="mb-4 flex items-center justify-between">
90
+ <div>
91
+ <h1 className="mb-1 text-2xl font-semibold text-gray-900">Script Tags</h1>
92
+ <p className="text-sm text-gray-600">Manage tracking codes, analytics, and custom scripts injected into your site</p>
93
+ </div>
94
+ <button
95
+ type="button"
96
+ onClick={() => onNavigate?.('/script-tags/new')}
97
+ className="flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700"
98
+ >
99
+ <Plus className="w-4 h-4" />
100
+ New Tag
101
+ </button>
102
+ </div>
103
+
104
+ {tags.length === 0 && !error ? (
105
+ <div className="rounded-lg border border-gray-200 bg-white p-12 text-center">
106
+ <Code2 className="mx-auto mb-3 h-10 w-10 text-gray-300" />
107
+ <h3 className="text-sm font-semibold text-gray-900">No script tags yet</h3>
108
+ <p className="mt-1 text-sm text-gray-500">
109
+ Add tracking codes like Google Analytics, Tag Manager, or Facebook Pixel.
110
+ </p>
111
+ <button
112
+ type="button"
113
+ onClick={() => onNavigate?.('/script-tags/new')}
114
+ className="mt-4 inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700"
115
+ >
116
+ <Plus className="w-4 h-4" />
117
+ Add Your First Tag
118
+ </button>
119
+ </div>
120
+ ) : (
121
+ <div className="rounded-lg border border-gray-200 bg-white overflow-hidden">
122
+ <table className="w-full text-sm">
123
+ <thead>
124
+ <tr className="border-b border-gray-200 bg-gray-50">
125
+ <th className="px-4 py-3 text-left font-medium text-gray-600">Name</th>
126
+ <th className="px-4 py-3 text-left font-medium text-gray-600">Placement</th>
127
+ <th className="px-4 py-3 text-left font-medium text-gray-600">Scope</th>
128
+ <th className="px-4 py-3 text-center font-medium text-gray-600">Priority</th>
129
+ <th className="px-4 py-3 text-center font-medium text-gray-600">Enabled</th>
130
+ </tr>
131
+ </thead>
132
+ <tbody>
133
+ {tags.map((tag) => (
134
+ <tr key={tag.id} className="border-b border-gray-100 last:border-0 hover:bg-gray-50 transition-colors">
135
+ <td className="px-4 py-3">
136
+ <button
137
+ type="button"
138
+ onClick={() => onNavigate?.(`/script-tags/${tag.id}`)}
139
+ className="font-medium text-blue-600 hover:text-blue-800 hover:underline"
140
+ >
141
+ {tag.name}
142
+ </button>
143
+ </td>
144
+ <td className="px-4 py-3">
145
+ <span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${PLACEMENT_COLORS[tag.placement] ?? 'bg-gray-100 text-gray-700'}`}>
146
+ {PLACEMENT_LABELS[tag.placement] ?? tag.placement}
147
+ </span>
148
+ </td>
149
+ <td className="px-4 py-3 text-gray-600">{scopeLabel(tag)}</td>
150
+ <td className="px-4 py-3 text-center text-gray-600 font-mono">{tag.priority}</td>
151
+ <td className="px-4 py-3 text-center">
152
+ <button
153
+ type="button"
154
+ onClick={() => toggleEnabled(tag)}
155
+ disabled={togglingId === tag.id}
156
+ className={`relative h-6 w-11 shrink-0 rounded-full transition-colors ${tag.enabled ? 'bg-blue-600' : 'bg-gray-300'}`}
157
+ aria-pressed={tag.enabled}
158
+ >
159
+ <span
160
+ className={`absolute top-0.5 block h-5 w-5 rounded-full bg-white transition-transform ${
161
+ tag.enabled ? 'translate-x-[22px]' : 'translate-x-0.5'
162
+ }`}
163
+ />
164
+ </button>
165
+ </td>
166
+ </tr>
167
+ ))}
168
+ </tbody>
169
+ </table>
170
+ </div>
171
+ )}
172
+ </div>
173
+ );
174
+ }
@@ -1,18 +1,20 @@
1
1
  'use client';
2
2
 
3
3
  import * as Tabs from '@radix-ui/react-tabs';
4
- import { Bot, Eye, EyeOff, Image, FileCode2, BookOpen, Sparkles, MessageSquare, Languages, Loader2, AlertTriangle, Download, CheckCircle2, ArrowUpCircle, ExternalLink, RefreshCw, GitPullRequest } from 'lucide-react';
4
+ import { Bot, Eye, EyeOff, Image, FileCode2, BookOpen, Sparkles, MessageSquare, Languages, Loader2, AlertTriangle, Download, CheckCircle2, ArrowUpCircle, ExternalLink, RefreshCw, GitPullRequest, Layers } from 'lucide-react';
5
5
  import { useState, useEffect } from 'react';
6
6
  import { toast } from 'sonner';
7
7
  import { useApiData } from '../lib/useApiData.js';
8
8
  import { cmsApi } from '../lib/api.js';
9
9
  import { useTheme } from '../components/ThemeProvider.js';
10
+ import { RelationshipField } from '../fields/RelationshipField.js';
10
11
 
11
12
  export interface SettingsProps {
12
13
  onNavigate?: (path: string) => void;
14
+ config?: any;
13
15
  }
14
16
 
15
- export function Settings(_props: SettingsProps = {}) {
17
+ export function Settings({ config, ..._props }: SettingsProps = {}) {
16
18
  const { data, loading, error, refetch } = useApiData<any>('/globals/settings');
17
19
 
18
20
  const [siteTitle, setSiteTitle] = useState('My CMS');
@@ -26,6 +28,18 @@ export function Settings(_props: SettingsProps = {}) {
26
28
  const [activeTab, setActiveTab] = useState('general');
27
29
  const [saving, setSaving] = useState(false);
28
30
 
31
+ // Layout defaults
32
+ const [defaultLayout, setDefaultLayout] = useState<Record<string, string>>({});
33
+ const layoutConfig = config?.layout;
34
+ const layoutRegions: Array<{ name: string; collection: string; label: string }> = layoutConfig?.regions
35
+ ? Object.entries(layoutConfig.regions).map(([name, region]: [string, any]) => ({
36
+ name,
37
+ collection: region.collection,
38
+ label: region.label ?? name.charAt(0).toUpperCase() + name.slice(1),
39
+ }))
40
+ : [];
41
+ const hasLayoutRegions = layoutRegions.length > 0;
42
+
29
43
  // AI settings
30
44
  const [aiProvider, setAiProvider] = useState('anthropic');
31
45
  const [aiApiKey, setAiApiKey] = useState('');
@@ -60,11 +74,15 @@ export function Settings(_props: SettingsProps = {}) {
60
74
  setAiWritingAssistant(data.aiWritingAssistant ?? true);
61
75
  setAiContentScoring(data.aiContentScoring ?? true);
62
76
  setAiTranslation(data.aiTranslation ?? false);
77
+ if (data.defaultLayout && typeof data.defaultLayout === 'object') {
78
+ setDefaultLayout(data.defaultLayout);
79
+ }
63
80
  }
64
81
  }, [data]);
65
82
 
66
83
  const handleSave = async () => {
67
84
  setSaving(true);
85
+ const layoutPayload = Object.keys(defaultLayout).length > 0 ? { defaultLayout } : {};
68
86
  const res = await cmsApi('/globals/settings', {
69
87
  method: 'PUT',
70
88
  body: JSON.stringify({
@@ -73,6 +91,7 @@ export function Settings(_props: SettingsProps = {}) {
73
91
  aiProvider, aiAltTags, aiMediaCategorize, aiMetaDescriptions,
74
92
  aiReadability, aiSchema, aiBrandVoice, aiWritingAssistant,
75
93
  aiContentScoring, aiTranslation,
94
+ ...layoutPayload,
76
95
  }),
77
96
  });
78
97
  setSaving(false);
@@ -112,6 +131,14 @@ export function Settings(_props: SettingsProps = {}) {
112
131
  <Tabs.List className="mb-4 flex gap-1 border-b border-gray-200 overflow-x-auto">
113
132
  <Tabs.Trigger value="general" className={tabTriggerClass}>General</Tabs.Trigger>
114
133
  <Tabs.Trigger value="appearance" className={tabTriggerClass}>Appearance</Tabs.Trigger>
134
+ {hasLayoutRegions && (
135
+ <Tabs.Trigger value="layout" className={tabTriggerClass}>
136
+ <span className="flex items-center gap-1.5">
137
+ <Layers className="w-4 h-4" />
138
+ Layout
139
+ </span>
140
+ </Tabs.Trigger>
141
+ )}
115
142
  <Tabs.Trigger value="security" className={tabTriggerClass}>Security</Tabs.Trigger>
116
143
  <Tabs.Trigger value="ai" className={tabTriggerClass}>
117
144
  <span className="flex items-center gap-1.5">
@@ -188,6 +215,56 @@ export function Settings(_props: SettingsProps = {}) {
188
215
  </div>
189
216
  </Tabs.Content>
190
217
 
218
+ {hasLayoutRegions && (
219
+ <Tabs.Content value="layout" className="space-y-4">
220
+ <div className="rounded-lg border border-gray-200 bg-white p-4">
221
+ <h3 className="mb-1 text-sm font-semibold text-gray-900">Default Layout Variants</h3>
222
+ <p className="mb-4 text-xs text-gray-500">
223
+ Select the default header, footer, and other layout variants used site-wide. Pages can override these individually or inherit from parent pages.
224
+ </p>
225
+ <div className="space-y-4">
226
+ {layoutRegions.map((region) => (
227
+ <RelationshipField
228
+ key={region.name}
229
+ label={`Default ${region.label}`}
230
+ value={defaultLayout[region.name] ?? ''}
231
+ onChange={(val) => {
232
+ setDefaultLayout((prev) => {
233
+ const next = { ...prev };
234
+ if (val && typeof val === 'string') {
235
+ next[region.name] = val;
236
+ } else {
237
+ delete next[region.name];
238
+ }
239
+ return next;
240
+ });
241
+ }}
242
+ relationTo={region.collection}
243
+ helpText={`The ${region.label.toLowerCase()} variant used when no page in the ancestor chain specifies one`}
244
+ />
245
+ ))}
246
+ </div>
247
+ </div>
248
+ <div className="rounded-lg border border-gray-200 bg-gray-50 p-4">
249
+ <h3 className="text-sm font-semibold text-gray-700 mb-2">How Layout Inheritance Works</h3>
250
+ <ul className="space-y-1.5 text-xs text-gray-600">
251
+ <li className="flex items-start gap-2">
252
+ <span className="w-4 h-4 rounded-full bg-blue-100 text-blue-600 flex items-center justify-center text-[10px] font-bold shrink-0 mt-0.5">1</span>
253
+ <span>Each page can assign specific layout variants (header, footer, etc.) from the document editor.</span>
254
+ </li>
255
+ <li className="flex items-start gap-2">
256
+ <span className="w-4 h-4 rounded-full bg-blue-100 text-blue-600 flex items-center justify-center text-[10px] font-bold shrink-0 mt-0.5">2</span>
257
+ <span>Child pages automatically inherit their parent&apos;s layout. For example, <code className="font-mono bg-gray-200 px-1 rounded">/hampton-roads/thank-you</code> inherits from <code className="font-mono bg-gray-200 px-1 rounded">/hampton-roads</code>.</span>
258
+ </li>
259
+ <li className="flex items-start gap-2">
260
+ <span className="w-4 h-4 rounded-full bg-blue-100 text-blue-600 flex items-center justify-center text-[10px] font-bold shrink-0 mt-0.5">3</span>
261
+ <span>If no page in the ancestor chain sets a variant, the defaults configured above are used.</span>
262
+ </li>
263
+ </ul>
264
+ </div>
265
+ </Tabs.Content>
266
+ )}
267
+
191
268
  <Tabs.Content value="security" className="space-y-4">
192
269
  <div className="rounded-lg border border-gray-200 bg-white p-4">
193
270
  <h3 className="mb-4 text-sm font-semibold text-gray-900">Security Settings</h3>