@actuate-media/cms-admin 0.2.1 → 0.4.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 (38) hide show
  1. package/dist/AdminRoot.js +2 -2
  2. package/dist/AdminRoot.js.map +1 -1
  3. package/dist/actuate-admin.css +1 -1
  4. package/dist/components/ContentOverviewChart.d.ts +8 -0
  5. package/dist/components/ContentOverviewChart.d.ts.map +1 -0
  6. package/dist/components/ContentOverviewChart.js +20 -0
  7. package/dist/components/ContentOverviewChart.js.map +1 -0
  8. package/dist/components/SEOPerformance.d.ts +5 -0
  9. package/dist/components/SEOPerformance.d.ts.map +1 -0
  10. package/dist/components/SEOPerformance.js +24 -0
  11. package/dist/components/SEOPerformance.js.map +1 -0
  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 +34 -10
  15. package/dist/layout/Sidebar.js.map +1 -1
  16. package/dist/views/Dashboard.d.ts +2 -1
  17. package/dist/views/Dashboard.d.ts.map +1 -1
  18. package/dist/views/Dashboard.js +81 -32
  19. package/dist/views/Dashboard.js.map +1 -1
  20. package/dist/views/DocumentEdit.d.ts.map +1 -1
  21. package/dist/views/DocumentEdit.js +49 -5
  22. package/dist/views/DocumentEdit.js.map +1 -1
  23. package/dist/views/Pages.d.ts.map +1 -1
  24. package/dist/views/Pages.js +57 -2
  25. package/dist/views/Pages.js.map +1 -1
  26. package/dist/views/Settings.d.ts +2 -1
  27. package/dist/views/Settings.d.ts.map +1 -1
  28. package/dist/views/Settings.js +31 -3
  29. package/dist/views/Settings.js.map +1 -1
  30. package/package.json +3 -2
  31. package/src/AdminRoot.tsx +2 -2
  32. package/src/components/ContentOverviewChart.tsx +70 -0
  33. package/src/components/SEOPerformance.tsx +134 -0
  34. package/src/layout/Sidebar.tsx +70 -22
  35. package/src/views/Dashboard.tsx +175 -192
  36. package/src/views/DocumentEdit.tsx +82 -4
  37. package/src/views/Pages.tsx +132 -58
  38. package/src/views/Settings.tsx +79 -2
@@ -14,6 +14,63 @@ export interface PagesProps {
14
14
  onNavigate?: (path: string) => void;
15
15
  }
16
16
 
17
+ const COLOR_PALETTE = [
18
+ { bg: 'bg-blue-100', text: 'text-blue-700' },
19
+ { bg: 'bg-green-100', text: 'text-green-700' },
20
+ { bg: 'bg-purple-100', text: 'text-purple-700' },
21
+ { bg: 'bg-orange-100', text: 'text-orange-700' },
22
+ { bg: 'bg-pink-100', text: 'text-pink-700' },
23
+ { bg: 'bg-teal-100', text: 'text-teal-700' },
24
+ { bg: 'bg-indigo-100', text: 'text-indigo-700' },
25
+ { bg: 'bg-rose-100', text: 'text-rose-700' },
26
+ ];
27
+
28
+ function hashString(s: string): number {
29
+ let hash = 0;
30
+ for (let i = 0; i < s.length; i++) {
31
+ hash = ((hash << 5) - hash + s.charCodeAt(i)) | 0;
32
+ }
33
+ return Math.abs(hash);
34
+ }
35
+
36
+ const folderColorCache: Record<string, (typeof COLOR_PALETTE)[0]> = {};
37
+
38
+ function getFolderColor(name: string) {
39
+ if (!folderColorCache[name]) {
40
+ folderColorCache[name] = COLOR_PALETTE[hashString(name) % COLOR_PALETTE.length]!;
41
+ }
42
+ return folderColorCache[name]!;
43
+ }
44
+
45
+ function computeSeoScore(data: Record<string, unknown> | null | undefined): number {
46
+ if (!data) return 0;
47
+ let score = 0;
48
+ if (data.metaTitle || data.seoTitle) score += 25;
49
+ if (data.metaDescription || data.seoDescription) score += 25;
50
+ if (data.canonical) score += 25;
51
+ if (data.schemaType) score += 25;
52
+ return score;
53
+ }
54
+
55
+ function SeoScoreBadge({ score }: { score: number }) {
56
+ const color = score >= 80 ? 'bg-green-500' : score >= 60 ? 'bg-amber-500' : 'bg-red-500';
57
+ return (
58
+ <div className="flex items-center gap-1.5">
59
+ <span className={`w-2.5 h-2.5 rounded-full ${color}`} />
60
+ <span className="text-xs text-gray-600">{score}</span>
61
+ </div>
62
+ );
63
+ }
64
+
65
+ function FolderBadge({ name }: { name: string }) {
66
+ const colors = getFolderColor(name);
67
+ return (
68
+ <span className={`inline-flex px-2 py-0.5 rounded text-xs font-medium ${colors.bg} ${colors.text}`}>
69
+ {name}
70
+ </span>
71
+ );
72
+ }
73
+
17
74
  function buildApiUrl(folderSel: FolderSelection): string {
18
75
  const base = '/collections/pages?pageSize=100';
19
76
  if (folderSel.type === 'smart') {
@@ -41,6 +98,7 @@ export function Pages({ onNavigate }: PagesProps) {
41
98
  const [sortConfig, setSortConfig] = useState<SortConfig<PageSortKey> | null>(null);
42
99
 
43
100
  const pages = data?.docs ?? [];
101
+ const totalCount = data?.total ?? pages.length;
44
102
 
45
103
  const filteredAndSorted = useMemo(() => {
46
104
  let results = pages.filter((page: any) => {
@@ -177,8 +235,8 @@ export function Pages({ onNavigate }: PagesProps) {
177
235
  <FolderInput className="w-5 h-5 text-gray-600" />
178
236
  </button>
179
237
  <div>
180
- <h1 className="text-xl sm:text-2xl font-semibold text-gray-900 mb-1">Pages</h1>
181
- <p className="text-sm text-gray-600">{filteredAndSorted.length} total pages</p>
238
+ <h1 className="text-xl sm:text-2xl font-semibold text-gray-900">Pages</h1>
239
+ <p className="text-sm text-gray-500">{totalCount} page{totalCount !== 1 ? 's' : ''}</p>
182
240
  </div>
183
241
  </div>
184
242
  <button
@@ -270,48 +328,58 @@ export function Pages({ onNavigate }: PagesProps) {
270
328
  </th>
271
329
  <th className="w-6 px-1 py-2"></th>
272
330
  <th className="px-3 py-2 text-left"><SortHeader label="Title" sortKey="title" /></th>
331
+ <th className="px-3 py-2 text-left text-xs font-medium text-gray-700">Folder</th>
273
332
  <th className="px-3 py-2 text-left"><SortHeader label="Author" sortKey="author" /></th>
274
- <th className="px-3 py-2 text-left"><SortHeader label="Template" sortKey="template" /></th>
333
+ <th className="px-3 py-2 text-left text-xs font-medium text-gray-700">SEO</th>
275
334
  <th className="px-3 py-2 text-left"><SortHeader label="Status" sortKey="status" /></th>
276
335
  <th className="px-3 py-2 text-left"><SortHeader label="Date" sortKey="date" /></th>
277
336
  <th className="px-3 py-2 text-left text-xs font-medium text-gray-700">Actions</th>
278
337
  </tr>
279
338
  </thead>
280
339
  <tbody className="divide-y divide-gray-200">
281
- {filteredAndSorted.map((page: any) => (
282
- <tr
283
- key={page.id}
284
- className="hover:bg-gray-50 transition-colors"
285
- draggable
286
- onDragStart={(e) => handleDragStart(e, page.id)}
287
- >
288
- <td className="px-3 py-2">
289
- <input type="checkbox" checked={selectedPages.includes(page.id)} onChange={() => handleSelectPage(page.id)} className="rounded border-gray-300" />
290
- </td>
291
- <td className="px-1 py-2 cursor-grab">
292
- <GripVertical className="w-4 h-4 text-gray-300" />
293
- </td>
294
- <td className="px-3 py-2">
295
- <button type="button" onClick={() => onNavigate?.(`/pages/${page.id}`)} className="font-medium text-gray-900 hover:text-blue-600 text-sm text-left">{page.title}</button>
296
- </td>
297
- <td className="px-3 py-2 text-sm text-gray-600">{page.author}</td>
298
- <td className="px-3 py-2 text-sm text-gray-600">{page.template}</td>
299
- <td className="px-3 py-2">
300
- <span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${page.status === 'Published' ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'}`}>{page.status}</span>
301
- </td>
302
- <td className="px-3 py-2 text-sm text-gray-600">{page.date}</td>
303
- <td className="px-3 py-2">
304
- <div className="flex items-center gap-2">
305
- <button type="button" onClick={() => onNavigate?.(`/pages/${page.id}`)} className="p-1.5 hover:bg-gray-100 rounded transition-colors" title="Edit">
306
- <Pencil className="w-4 h-4 text-gray-600" />
307
- </button>
308
- <button type="button" onClick={() => handleDelete(page.id)} className="p-1.5 hover:bg-gray-100 rounded transition-colors" title="Delete">
309
- <Trash2 className="w-4 h-4 text-red-600" />
310
- </button>
311
- </div>
312
- </td>
313
- </tr>
314
- ))}
340
+ {filteredAndSorted.map((page: any) => {
341
+ const seoScore = computeSeoScore(page.data);
342
+ const folderName = page.folder?.name ?? page.folderName;
343
+ return (
344
+ <tr
345
+ key={page.id}
346
+ className="hover:bg-gray-50 transition-colors"
347
+ draggable
348
+ onDragStart={(e) => handleDragStart(e, page.id)}
349
+ >
350
+ <td className="px-3 py-2">
351
+ <input type="checkbox" checked={selectedPages.includes(page.id)} onChange={() => handleSelectPage(page.id)} className="rounded border-gray-300" />
352
+ </td>
353
+ <td className="px-1 py-2 cursor-grab">
354
+ <GripVertical className="w-4 h-4 text-gray-300" />
355
+ </td>
356
+ <td className="px-3 py-2">
357
+ <button type="button" onClick={() => onNavigate?.(`/pages/${page.id}`)} className="font-medium text-gray-900 hover:text-blue-600 text-sm text-left">{page.title}</button>
358
+ </td>
359
+ <td className="px-3 py-2">
360
+ {folderName ? <FolderBadge name={folderName} /> : <span className="text-xs text-gray-400">—</span>}
361
+ </td>
362
+ <td className="px-3 py-2 text-sm text-gray-600">{page.author}</td>
363
+ <td className="px-3 py-2">
364
+ <SeoScoreBadge score={seoScore} />
365
+ </td>
366
+ <td className="px-3 py-2">
367
+ <span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${page.status === 'Published' || page.status === 'PUBLISHED' ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'}`}>{page.status}</span>
368
+ </td>
369
+ <td className="px-3 py-2 text-sm text-gray-600">{page.date}</td>
370
+ <td className="px-3 py-2">
371
+ <div className="flex items-center gap-2">
372
+ <button type="button" onClick={() => onNavigate?.(`/pages/${page.id}`)} className="p-1.5 hover:bg-gray-100 rounded transition-colors" title="Edit">
373
+ <Pencil className="w-4 h-4 text-gray-600" />
374
+ </button>
375
+ <button type="button" onClick={() => handleDelete(page.id)} className="p-1.5 hover:bg-gray-100 rounded transition-colors" title="Delete">
376
+ <Trash2 className="w-4 h-4 text-red-600" />
377
+ </button>
378
+ </div>
379
+ </td>
380
+ </tr>
381
+ );
382
+ })}
315
383
  </tbody>
316
384
  </table>
317
385
  </div>
@@ -319,30 +387,36 @@ export function Pages({ onNavigate }: PagesProps) {
319
387
 
320
388
  <div className="md:hidden bg-white rounded-lg border border-gray-200 flex-1 overflow-auto">
321
389
  <div className="divide-y divide-gray-200">
322
- {filteredAndSorted.map((page: any) => (
323
- <div
324
- key={page.id}
325
- className="p-3"
326
- draggable
327
- onDragStart={(e) => handleDragStart(e, page.id)}
328
- >
329
- <div className="flex items-start gap-3">
330
- <input type="checkbox" checked={selectedPages.includes(page.id)} onChange={() => handleSelectPage(page.id)} className="rounded border-gray-300 mt-1" />
331
- <div className="flex-1 min-w-0">
332
- <button type="button" onClick={() => onNavigate?.(`/pages/${page.id}`)} className="font-medium text-sm text-gray-900 hover:text-blue-600 block mb-1 text-left">{page.title}</button>
333
- <div className="flex flex-wrap items-center gap-2 text-xs text-gray-600 mb-2">
334
- <span>{page.author}</span>
335
- <span>•</span>
336
- <span>{page.date}</span>
337
- <span className={`px-2 py-0.5 rounded-full text-xs font-medium ${page.status === 'Published' ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'}`}>{page.status}</span>
390
+ {filteredAndSorted.map((page: any) => {
391
+ const seoScore = computeSeoScore(page.data);
392
+ const folderName = page.folder?.name ?? page.folderName;
393
+ return (
394
+ <div
395
+ key={page.id}
396
+ className="p-3"
397
+ draggable
398
+ onDragStart={(e) => handleDragStart(e, page.id)}
399
+ >
400
+ <div className="flex items-start gap-3">
401
+ <input type="checkbox" checked={selectedPages.includes(page.id)} onChange={() => handleSelectPage(page.id)} className="rounded border-gray-300 mt-1" />
402
+ <div className="flex-1 min-w-0">
403
+ <button type="button" onClick={() => onNavigate?.(`/pages/${page.id}`)} className="font-medium text-sm text-gray-900 hover:text-blue-600 block mb-1 text-left">{page.title}</button>
404
+ <div className="flex flex-wrap items-center gap-2 text-xs text-gray-600 mb-2">
405
+ {folderName && <FolderBadge name={folderName} />}
406
+ <span>{page.author}</span>
407
+ <span>&middot;</span>
408
+ <span>{page.date}</span>
409
+ <SeoScoreBadge score={seoScore} />
410
+ <span className={`px-2 py-0.5 rounded-full text-xs font-medium ${page.status === 'Published' || page.status === 'PUBLISHED' ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'}`}>{page.status}</span>
411
+ </div>
338
412
  </div>
413
+ <button type="button" onClick={() => handleDelete(page.id)} className="p-1.5 hover:bg-gray-100 rounded transition-colors">
414
+ <Trash2 className="w-4 h-4 text-red-600" />
415
+ </button>
339
416
  </div>
340
- <button type="button" onClick={() => handleDelete(page.id)} className="p-1.5 hover:bg-gray-100 rounded transition-colors">
341
- <Trash2 className="w-4 h-4 text-red-600" />
342
- </button>
343
417
  </div>
344
- </div>
345
- ))}
418
+ );
419
+ })}
346
420
  </div>
347
421
  </div>
348
422
  </>
@@ -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>