@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.
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +23 -1
- package/dist/AdminRoot.js.map +1 -1
- package/dist/actuate-admin.css +1 -1
- package/dist/components/Breadcrumbs.d.ts.map +1 -1
- package/dist/components/Breadcrumbs.js +1 -0
- package/dist/components/Breadcrumbs.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/layout/Sidebar.d.ts +7 -0
- package/dist/layout/Sidebar.d.ts.map +1 -1
- package/dist/layout/Sidebar.js +35 -11
- package/dist/layout/Sidebar.js.map +1 -1
- package/dist/views/DocumentEdit.d.ts.map +1 -1
- package/dist/views/DocumentEdit.js +49 -5
- package/dist/views/DocumentEdit.js.map +1 -1
- package/dist/views/ForgotPassword.d.ts +5 -0
- package/dist/views/ForgotPassword.d.ts.map +1 -0
- package/dist/views/ForgotPassword.js +41 -0
- package/dist/views/ForgotPassword.js.map +1 -0
- package/dist/views/ResetPassword.d.ts +6 -0
- package/dist/views/ResetPassword.d.ts.map +1 -0
- package/dist/views/ResetPassword.js +46 -0
- package/dist/views/ResetPassword.js.map +1 -0
- package/dist/views/ScriptTagEditor.d.ts +6 -0
- package/dist/views/ScriptTagEditor.d.ts.map +1 -0
- package/dist/views/ScriptTagEditor.js +109 -0
- package/dist/views/ScriptTagEditor.js.map +1 -0
- package/dist/views/ScriptTags.d.ts +5 -0
- package/dist/views/ScriptTags.d.ts.map +1 -0
- package/dist/views/ScriptTags.js +54 -0
- package/dist/views/ScriptTags.js.map +1 -0
- package/dist/views/Settings.d.ts +2 -1
- package/dist/views/Settings.d.ts.map +1 -1
- package/dist/views/Settings.js +31 -3
- package/dist/views/Settings.js.map +1 -1
- package/package.json +5 -3
- package/src/AdminRoot.tsx +26 -1
- package/src/components/Breadcrumbs.tsx +1 -0
- package/src/index.ts +4 -0
- package/src/layout/Sidebar.tsx +72 -22
- package/src/views/DocumentEdit.tsx +82 -4
- package/src/views/ForgotPassword.tsx +136 -0
- package/src/views/ResetPassword.tsx +192 -0
- package/src/views/ScriptTagEditor.tsx +361 -0
- package/src/views/ScriptTags.tsx +174 -0
- 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
|
+
}
|
package/src/views/Settings.tsx
CHANGED
|
@@ -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'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>
|