@actuate-media/cms-admin 0.3.0 → 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.
@@ -16,6 +16,9 @@ import {
16
16
  BookOpen,
17
17
  HelpCircle,
18
18
  Newspaper,
19
+ PanelTop,
20
+ PanelBottom,
21
+ Layers,
19
22
  } from 'lucide-react';
20
23
  import type { LucideIcon } from 'lucide-react';
21
24
 
@@ -53,6 +56,9 @@ const ICON_MAP: Record<string, LucideIcon> = {
53
56
  book: BookOpen,
54
57
  help: HelpCircle,
55
58
  newspaper: Newspaper,
59
+ PanelTop: PanelTop,
60
+ PanelBottom: PanelBottom,
61
+ Layers: Layers,
56
62
  };
57
63
 
58
64
  function BrandLogo({ config, collapsed }: { config?: any; collapsed: boolean }) {
@@ -143,28 +149,44 @@ export function Sidebar({ collapsed, onToggleCollapse, currentPath, onNavigate,
143
149
  </div>
144
150
 
145
151
  <nav className="p-3 space-y-1">
146
- {navItems.map((item) => {
152
+ {navItems.map((item, idx) => {
147
153
  const Icon = item.icon;
148
154
  const isActive =
149
155
  currentPath === item.path ||
150
156
  (item.path !== '/' && currentPath.startsWith(item.path));
151
157
 
158
+ const prevGroup = idx > 0 ? navItems[idx - 1]?.group : undefined;
159
+ const showGroupLabel = item.group && item.group !== prevGroup;
160
+
152
161
  return (
153
- <button
154
- key={item.path}
155
- onClick={() => onNavigate(item.path)}
156
- className={`flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors w-full text-left ${
157
- isActive
158
- ? 'bg-sidebar-accent text-sidebar-primary'
159
- : 'text-sidebar-foreground hover:bg-sidebar-accent'
160
- } ${collapsed ? 'justify-center' : ''}`}
161
- title={collapsed ? item.label : ''}
162
- >
163
- <Icon className="w-5 h-5 shrink-0" />
164
- {!collapsed && (
165
- <span className="text-sm font-medium">{item.label}</span>
162
+ <div key={item.path}>
163
+ {showGroupLabel && !collapsed && (
164
+ <div className="pt-3 pb-1 px-3">
165
+ <span className="text-[10px] font-semibold uppercase tracking-wider text-sidebar-foreground/50">
166
+ {item.group}
167
+ </span>
168
+ </div>
169
+ )}
170
+ {showGroupLabel && collapsed && (
171
+ <div className="pt-2 pb-1 flex justify-center">
172
+ <span className="w-4 border-t border-sidebar-foreground/20" />
173
+ </div>
166
174
  )}
167
- </button>
175
+ <button
176
+ onClick={() => onNavigate(item.path)}
177
+ className={`flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors w-full text-left ${
178
+ isActive
179
+ ? 'bg-sidebar-accent text-sidebar-primary'
180
+ : 'text-sidebar-foreground hover:bg-sidebar-accent'
181
+ } ${collapsed ? 'justify-center' : ''}`}
182
+ title={collapsed ? item.label : ''}
183
+ >
184
+ <Icon className="w-5 h-5 shrink-0" />
185
+ {!collapsed && (
186
+ <span className="text-sm font-medium">{item.label}</span>
187
+ )}
188
+ </button>
189
+ </div>
168
190
  );
169
191
  })}
170
192
  </nav>
@@ -178,7 +200,14 @@ function resolveIcon(collection: any): LucideIcon {
178
200
  return collection.type === 'page' ? File : FileText;
179
201
  }
180
202
 
181
- function buildNavItems(config: any) {
203
+ export interface NavItem {
204
+ path: string;
205
+ label: string;
206
+ icon: LucideIcon;
207
+ group?: string;
208
+ }
209
+
210
+ function buildNavItems(config: any): NavItem[] {
182
211
  if (!config?.collections) return defaultNavItems;
183
212
 
184
213
  const raw = config.collections;
@@ -186,17 +215,19 @@ function buildNavItems(config: any) {
186
215
 
187
216
  const visible = collectionsList.filter((c) => !c.admin?.hidden);
188
217
 
189
- const pages = visible.filter((c) => c.type === 'page');
190
- const posts = visible.filter((c) => c.type === 'post');
191
- const other = visible.filter((c) => c.type !== 'page' && c.type !== 'post');
218
+ const ungrouped = visible.filter((c) => !c.admin?.group);
219
+ const grouped = visible.filter((c) => c.admin?.group);
192
220
 
193
- const sorted = [...pages, ...posts, ...other];
221
+ const pages = ungrouped.filter((c) => c.type === 'page');
222
+ const posts = ungrouped.filter((c) => c.type === 'post');
223
+ const other = ungrouped.filter((c) => c.type !== 'page' && c.type !== 'post');
224
+ const sortedUngrouped = [...pages, ...posts, ...other];
194
225
 
195
- const items: typeof defaultNavItems = [
226
+ const items: NavItem[] = [
196
227
  { path: '/', label: 'Dashboard', icon: LayoutDashboard },
197
228
  ];
198
229
 
199
- for (const collection of sorted) {
230
+ for (const collection of sortedUngrouped) {
200
231
  items.push({
201
232
  label: collection.labels?.plural ?? collection.slug,
202
233
  path: `/${collection.slug}`,
@@ -204,6 +235,23 @@ function buildNavItems(config: any) {
204
235
  });
205
236
  }
206
237
 
238
+ const groups = new Map<string, typeof grouped>();
239
+ for (const col of grouped) {
240
+ const group = col.admin.group as string;
241
+ if (!groups.has(group)) groups.set(group, []);
242
+ groups.get(group)!.push(col);
243
+ }
244
+ for (const [groupName, cols] of groups) {
245
+ for (const collection of cols) {
246
+ items.push({
247
+ label: collection.labels?.plural ?? collection.slug,
248
+ path: `/${collection.slug}`,
249
+ icon: resolveIcon(collection),
250
+ group: groupName,
251
+ });
252
+ }
253
+ }
254
+
207
255
  items.push(
208
256
  { path: '/media', label: 'Media', icon: Image },
209
257
  { path: '/forms', label: 'Forms', icon: ClipboardList },
@@ -4,6 +4,7 @@ import { useState, useEffect, useCallback, useRef } from 'react';
4
4
  import { AlertTriangle, Eye, EyeOff, Save, Copy, Loader2, Clock } from 'lucide-react';
5
5
  import { toast } from 'sonner';
6
6
  import { FieldRenderer } from '../fields/FieldRenderer.js';
7
+ import { RelationshipField } from '../fields/RelationshipField.js';
7
8
  import { Button } from '../components/ui/Button.js';
8
9
  import { Badge } from '../components/ui/Badge.js';
9
10
  import { LivePreview } from '../components/LivePreview.js';
@@ -26,6 +27,8 @@ export function DocumentEdit({ collectionSlug, documentId, config, onNavigate }:
26
27
  const [initialValues, setInitialValues] = useState<Record<string, any>>({});
27
28
  const [seoData, setSeoData] = useState<SEOData>({});
28
29
  const [initialSeoData, setInitialSeoData] = useState<SEOData>({});
30
+ const [layoutAssignments, setLayoutAssignments] = useState<Record<string, string>>({});
31
+ const [initialLayoutAssignments, setInitialLayoutAssignments] = useState<Record<string, string>>({});
29
32
  const [saving, setSaving] = useState(false);
30
33
  const [loading, setLoading] = useState(!isNew);
31
34
  const [showPreview, setShowPreview] = useState(false);
@@ -38,6 +41,16 @@ export function DocumentEdit({ collectionSlug, documentId, config, onNavigate }:
38
41
  ? collections.find((c: any) => c.slug === collectionSlug)
39
42
  : collections?.[collectionSlug];
40
43
 
44
+ const layoutConfig = config?.layout;
45
+ const layoutRegions: Array<{ name: string; collection: string; label: string }> = layoutConfig?.regions
46
+ ? Object.entries(layoutConfig.regions).map(([name, region]: [string, any]) => ({
47
+ name,
48
+ collection: region.collection,
49
+ label: region.label ?? name.charAt(0).toUpperCase() + name.slice(1),
50
+ }))
51
+ : [];
52
+ const hasLayout = layoutRegions.length > 0 && (collection?.type === 'page' || collection?.urlPrefix !== undefined);
53
+
41
54
  const previewUrl = collection?.admin?.preview ? collection.admin.preview({}) : undefined;
42
55
  const fields: any[] = collection?.fields
43
56
  ? (Array.isArray(collection.fields)
@@ -51,7 +64,8 @@ export function DocumentEdit({ collectionSlug, documentId, config, onNavigate }:
51
64
  : `Edit ${values[useAsTitleField] ?? 'Document'}`;
52
65
 
53
66
  const isDirty = JSON.stringify(values) !== JSON.stringify(initialValues)
54
- || JSON.stringify(seoData) !== JSON.stringify(initialSeoData);
67
+ || JSON.stringify(seoData) !== JSON.stringify(initialSeoData)
68
+ || JSON.stringify(layoutAssignments) !== JSON.stringify(initialLayoutAssignments);
55
69
 
56
70
  useEffect(() => {
57
71
  if (!isNew && documentId && !hasLoadedRef.current) {
@@ -95,6 +109,12 @@ export function DocumentEdit({ collectionSlug, documentId, config, onNavigate }:
95
109
  }
96
110
  setSeoData(loadedSeo);
97
111
  setInitialSeoData(loadedSeo);
112
+
113
+ if (docData._layout && typeof docData._layout === 'object') {
114
+ const loaded = docData._layout as Record<string, string>;
115
+ setLayoutAssignments(loaded);
116
+ setInitialLayoutAssignments(loaded);
117
+ }
98
118
  } else if (res.error) {
99
119
  toast.error(`Failed to load document: ${res.error}`);
100
120
  }
@@ -107,7 +127,8 @@ export function DocumentEdit({ collectionSlug, documentId, config, onNavigate }:
107
127
 
108
128
  async function handleSave() {
109
129
  setSaving(true);
110
- const payload = { ...values, ...seoData };
130
+ const layoutPayload = Object.keys(layoutAssignments).length > 0 ? { _layout: layoutAssignments } : {};
131
+ const payload = { ...values, ...seoData, ...layoutPayload };
111
132
  try {
112
133
  if (isNew) {
113
134
  const res = await cmsApi<any>(`/collections/${collectionSlug}`, {
@@ -121,6 +142,7 @@ export function DocumentEdit({ collectionSlug, documentId, config, onNavigate }:
121
142
  const newId = res.data?.id;
122
143
  setInitialValues(values);
123
144
  setInitialSeoData(seoData);
145
+ setInitialLayoutAssignments(layoutAssignments);
124
146
  if (newId && onNavigate) {
125
147
  onNavigate(`/${collectionSlug}/${newId}`);
126
148
  }
@@ -136,6 +158,7 @@ export function DocumentEdit({ collectionSlug, documentId, config, onNavigate }:
136
158
  toast.success('Changes saved');
137
159
  setInitialValues(values);
138
160
  setInitialSeoData(seoData);
161
+ setInitialLayoutAssignments(layoutAssignments);
139
162
  if (res.data?.status) setDocStatus(res.data.status);
140
163
  }
141
164
  }
@@ -148,9 +171,10 @@ export function DocumentEdit({ collectionSlug, documentId, config, onNavigate }:
148
171
  async function handlePublish() {
149
172
  if (isNew) return;
150
173
  setSaving(true);
174
+ const layoutPayload = Object.keys(layoutAssignments).length > 0 ? { _layout: layoutAssignments } : {};
151
175
  const res = await cmsApi<any>(`/collections/${collectionSlug}/${documentId}`, {
152
176
  method: 'PUT',
153
- body: JSON.stringify({ ...values, ...seoData, status: 'PUBLISHED' }),
177
+ body: JSON.stringify({ ...values, ...seoData, ...layoutPayload, status: 'PUBLISHED' }),
154
178
  });
155
179
  if (res.error) {
156
180
  toast.error(res.error);
@@ -159,6 +183,7 @@ export function DocumentEdit({ collectionSlug, documentId, config, onNavigate }:
159
183
  setDocStatus('PUBLISHED');
160
184
  setInitialValues(values);
161
185
  setInitialSeoData(seoData);
186
+ setInitialLayoutAssignments(layoutAssignments);
162
187
  }
163
188
  setSaving(false);
164
189
  }
@@ -166,9 +191,10 @@ export function DocumentEdit({ collectionSlug, documentId, config, onNavigate }:
166
191
  async function handleUnpublish() {
167
192
  if (isNew) return;
168
193
  setSaving(true);
194
+ const layoutPayload = Object.keys(layoutAssignments).length > 0 ? { _layout: layoutAssignments } : {};
169
195
  const res = await cmsApi<any>(`/collections/${collectionSlug}/${documentId}`, {
170
196
  method: 'PUT',
171
- body: JSON.stringify({ ...values, ...seoData, status: 'DRAFT' }),
197
+ body: JSON.stringify({ ...values, ...seoData, ...layoutPayload, status: 'DRAFT' }),
172
198
  });
173
199
  if (res.error) {
174
200
  toast.error(res.error);
@@ -177,6 +203,7 @@ export function DocumentEdit({ collectionSlug, documentId, config, onNavigate }:
177
203
  setDocStatus('DRAFT');
178
204
  setInitialValues(values);
179
205
  setInitialSeoData(seoData);
206
+ setInitialLayoutAssignments(layoutAssignments);
180
207
  }
181
208
  setSaving(false);
182
209
  }
@@ -335,6 +362,57 @@ export function DocumentEdit({ collectionSlug, documentId, config, onNavigate }:
335
362
  onChange={setSeoData}
336
363
  siteUrl={config?.siteUrl}
337
364
  />
365
+
366
+ {hasLayout && (
367
+ <div className="rounded-lg border border-[var(--border)] bg-[var(--card)] p-4">
368
+ <h3 className="mb-1 font-semibold">Layout</h3>
369
+ <p className="mb-4 text-xs text-[var(--muted-foreground)]">
370
+ Assign header/footer variants. Child pages inherit from ancestors.
371
+ </p>
372
+ <div className="space-y-4">
373
+ {layoutRegions.map((region) => (
374
+ <div key={region.name}>
375
+ <RelationshipField
376
+ label={region.label}
377
+ value={layoutAssignments[region.name] ?? ''}
378
+ onChange={(val) => {
379
+ setLayoutAssignments((prev) => {
380
+ const next = { ...prev };
381
+ if (val && typeof val === 'string') {
382
+ next[region.name] = val;
383
+ } else {
384
+ delete next[region.name];
385
+ }
386
+ return next;
387
+ });
388
+ }}
389
+ relationTo={region.collection}
390
+ helpText={
391
+ !layoutAssignments[region.name]
392
+ ? 'Inheriting from parent page or site default'
393
+ : undefined
394
+ }
395
+ />
396
+ {layoutAssignments[region.name] && (
397
+ <button
398
+ type="button"
399
+ onClick={() => {
400
+ setLayoutAssignments((prev) => {
401
+ const next = { ...prev };
402
+ delete next[region.name];
403
+ return next;
404
+ });
405
+ }}
406
+ className="mt-1 text-xs text-[var(--primary)] hover:underline"
407
+ >
408
+ Clear override (inherit from parent)
409
+ </button>
410
+ )}
411
+ </div>
412
+ ))}
413
+ </div>
414
+ </div>
415
+ )}
338
416
  </div>
339
417
  </div>
340
418
  )}
@@ -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>