@actuate-media/cms-admin 0.14.1 → 0.15.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.
@@ -1,5 +1,6 @@
1
1
  'use client'
2
2
 
3
+ import type { ReactNode } from 'react'
3
4
  import {
4
5
  LayoutDashboard,
5
6
  FileText,
@@ -7,7 +8,6 @@ import {
7
8
  Image,
8
9
  Settings,
9
10
  ChevronLeft,
10
- ChevronRight,
11
11
  ClipboardList,
12
12
  Users,
13
13
  Search as SearchIcon,
@@ -20,9 +20,10 @@ import {
20
20
  PanelBottom,
21
21
  Layers,
22
22
  Code2,
23
- LayoutTemplate,
24
23
  Library,
25
24
  KeyRound,
25
+ PanelLeftClose,
26
+ PanelLeftOpen,
26
27
  } from 'lucide-react'
27
28
  import type { LucideIcon } from 'lucide-react'
28
29
  import { ActuateBrandLogo } from '../assets/actuate-logo.js'
@@ -48,11 +49,6 @@ function ActuateMark({ className }: { className?: string }) {
48
49
  )
49
50
  }
50
51
 
51
- /**
52
- * Full Actuate Media lockup. Inline SVG with a transparent background so it
53
- * sits naturally on whatever surface the sidebar uses (light or dark theme,
54
- * custom branding background, etc.).
55
- */
56
52
  function ActuateWordmark({ className }: { className?: string }) {
57
53
  return <ActuateBrandLogo className={className} />
58
54
  }
@@ -91,9 +87,7 @@ function BrandLogo({ config, collapsed }: { config?: any; collapsed: boolean })
91
87
  className="h-8 w-auto shrink-0 object-contain"
92
88
  />
93
89
  {brandName && (
94
- <span className="text-sidebar-foreground truncate text-sm font-semibold">
95
- {brandName}
96
- </span>
90
+ <span className="text-sidebar-foreground truncate text-sm font-medium">{brandName}</span>
97
91
  )}
98
92
  </div>
99
93
  )
@@ -103,7 +97,7 @@ function BrandLogo({ config, collapsed }: { config?: any; collapsed: boolean })
103
97
  return (
104
98
  <div className="flex min-w-0 items-center gap-2.5">
105
99
  <ActuateMark className="h-7 w-7 shrink-0" />
106
- <span className="text-sidebar-foreground truncate text-sm font-semibold">{brandName}</span>
100
+ <span className="text-sidebar-foreground truncate text-sm font-medium">{brandName}</span>
107
101
  </div>
108
102
  )
109
103
  }
@@ -111,16 +105,33 @@ function BrandLogo({ config, collapsed }: { config?: any; collapsed: boolean })
111
105
  return <ActuateWordmark className="h-9" />
112
106
  }
113
107
 
114
- const defaultNavItems = [
108
+ /**
109
+ * Nav tree for the default (config-less) install. Mirrors the reference
110
+ * dashboard exactly: Dashboard / Posts / Pages (with Saved Sections +
111
+ * Templates as nested children), then a CONTENT group (Media / Forms /
112
+ * SEO / Script Tags), then a SETTINGS group (Users / API Keys /
113
+ * Settings). The group ordering is meaningful — the renderer emits a
114
+ * section header the first time it sees a new group.
115
+ */
116
+ const defaultNavItems: NavItem[] = [
115
117
  { path: '/', label: 'Dashboard', icon: LayoutDashboard },
116
- { path: '/pages', label: 'Pages', icon: File },
117
118
  { path: '/posts', label: 'Posts', icon: FileText },
118
- { path: '/media', label: 'Media', icon: Image },
119
- { path: '/forms', label: 'Forms', icon: ClipboardList },
120
- { path: '/seo', label: 'SEO', icon: SearchIcon },
121
- { path: '/users', label: 'Users', icon: Users },
122
- { path: '/api-keys', label: 'API Keys', icon: KeyRound },
123
- { path: '/settings', label: 'Settings', icon: Settings },
119
+ {
120
+ path: '/pages',
121
+ label: 'Pages',
122
+ icon: File,
123
+ children: [
124
+ { path: '/saved-sections', label: 'Saved Sections', icon: Library },
125
+ { path: '/page-templates', label: 'Templates', icon: Layers },
126
+ ],
127
+ },
128
+ { path: '/media', label: 'Media', icon: Image, group: 'Content' },
129
+ { path: '/forms', label: 'Forms', icon: ClipboardList, group: 'Content' },
130
+ { path: '/seo', label: 'SEO', icon: SearchIcon, group: 'Content' },
131
+ { path: '/script-tags', label: 'Script Tags', icon: Code2, group: 'Content' },
132
+ { path: '/users', label: 'Users', icon: Users, group: 'Settings' },
133
+ { path: '/api-keys', label: 'API Keys', icon: KeyRound, group: 'Settings' },
134
+ { path: '/settings', label: 'Settings', icon: Settings, group: 'Settings' },
124
135
  ]
125
136
 
126
137
  export interface SidebarProps {
@@ -142,73 +153,140 @@ export function Sidebar({
142
153
 
143
154
  return (
144
155
  <aside
145
- className={`bg-sidebar border-sidebar-border h-full border-r transition-all duration-200 ${
156
+ className={`bg-sidebar border-sidebar-border flex h-full flex-col border-r transition-all duration-200 ${
146
157
  collapsed ? 'w-20' : 'w-64'
147
158
  }`}
148
159
  >
160
+ {/* Brand header — no toggle button here anymore; it now lives in the footer. */}
149
161
  <div
150
- className={`border-sidebar-border flex h-14 items-center border-b px-4 ${
151
- collapsed ? 'justify-center' : 'justify-between'
162
+ className={`border-sidebar-border flex h-14 shrink-0 items-center border-b px-4 ${
163
+ collapsed ? 'justify-center' : ''
152
164
  }`}
153
165
  >
154
- {!collapsed && <BrandLogo config={config} collapsed={false} />}
155
- {collapsed && <BrandLogo config={config} collapsed={true} />}
166
+ <BrandLogo config={config} collapsed={collapsed} />
167
+ </div>
168
+
169
+ <nav className="flex-1 space-y-1 overflow-y-auto p-3" aria-label="Primary">
170
+ {renderNavTree(navItems, { collapsed, currentPath, onNavigate })}
171
+ </nav>
172
+
173
+ {/* Footer collapse control. Sits at the bottom of the sidebar so it
174
+ mirrors the reference design and stays out of the way during
175
+ everyday nav. Hidden below md: the mobile sheet has its own
176
+ dismiss affordance in <AdminShell>. */}
177
+ <div className="border-sidebar-border hidden shrink-0 border-t p-3 md:block">
156
178
  <button
157
179
  onClick={onToggleCollapse}
158
- className="hover:bg-sidebar-accent hidden shrink-0 rounded-lg p-2 transition-colors md:block"
180
+ className={`text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-foreground flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition-colors ${
181
+ collapsed ? 'justify-center' : ''
182
+ }`}
159
183
  aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
184
+ title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
160
185
  >
161
186
  {collapsed ? (
162
- <ChevronRight className="text-sidebar-foreground h-4 w-4" />
187
+ <PanelLeftOpen className="h-4 w-4 shrink-0" aria-hidden />
163
188
  ) : (
164
- <ChevronLeft className="text-sidebar-foreground h-4 w-4" />
189
+ <>
190
+ <PanelLeftClose className="h-4 w-4 shrink-0" aria-hidden />
191
+ <span className="font-medium">Collapse sidebar</span>
192
+ </>
165
193
  )}
166
194
  </button>
167
195
  </div>
168
-
169
- <nav className="space-y-1 p-3">
170
- {navItems.map((item, idx) => {
171
- const Icon = item.icon
172
- const isActive =
173
- currentPath === item.path || (item.path !== '/' && currentPath.startsWith(item.path))
174
-
175
- const prevGroup = idx > 0 ? navItems[idx - 1]?.group : undefined
176
- const showGroupLabel = item.group && item.group !== prevGroup
177
-
178
- return (
179
- <div key={item.path}>
180
- {showGroupLabel && !collapsed && (
181
- <div className="px-3 pt-3 pb-1">
182
- <span className="text-sidebar-foreground/50 text-[10px] font-semibold tracking-wider uppercase">
183
- {item.group}
184
- </span>
185
- </div>
186
- )}
187
- {showGroupLabel && collapsed && (
188
- <div className="flex justify-center pt-2 pb-1">
189
- <span className="border-sidebar-foreground/20 w-4 border-t" />
190
- </div>
191
- )}
192
- <button
193
- onClick={() => onNavigate(item.path)}
194
- className={`flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left transition-colors ${
195
- isActive
196
- ? 'bg-sidebar-accent text-sidebar-primary'
197
- : 'text-sidebar-foreground hover:bg-sidebar-accent'
198
- } ${collapsed ? 'justify-center' : ''}`}
199
- title={collapsed ? item.label : ''}
200
- >
201
- <Icon className="h-5 w-5 shrink-0" />
202
- {!collapsed && <span className="text-sm font-medium">{item.label}</span>}
203
- </button>
204
- </div>
205
- )
206
- })}
207
- </nav>
208
196
  </aside>
209
197
  )
210
198
  }
211
199
 
200
+ // ─── Rendering ──────────────────────────────────────────────────────────────
201
+
202
+ interface NavRenderContext {
203
+ collapsed: boolean
204
+ currentPath: string
205
+ onNavigate: (path: string) => void
206
+ }
207
+
208
+ function isPathActive(currentPath: string, itemPath: string): boolean {
209
+ if (itemPath === '/') return currentPath === '/'
210
+ // Use boundary match so /pages doesn't match /pages-something. Exact, or
211
+ // followed by a `/` separator.
212
+ return currentPath === itemPath || currentPath.startsWith(itemPath + '/')
213
+ }
214
+
215
+ function renderNavTree(items: NavItem[], ctx: NavRenderContext): ReactNode {
216
+ const nodes: ReactNode[] = []
217
+ let lastGroup: string | undefined = undefined
218
+
219
+ for (const item of items) {
220
+ if (item.group && item.group !== lastGroup) {
221
+ nodes.push(
222
+ ctx.collapsed ? (
223
+ <div key={`g-${item.group}`} className="flex justify-center pt-3 pb-1">
224
+ <span className="border-sidebar-foreground/20 w-4 border-t" aria-hidden />
225
+ </div>
226
+ ) : (
227
+ <div key={`g-${item.group}`} className="px-3 pt-4 pb-1">
228
+ <span className="text-sidebar-foreground/55 text-[10px] font-semibold tracking-wider uppercase">
229
+ {item.group}
230
+ </span>
231
+ </div>
232
+ ),
233
+ )
234
+ lastGroup = item.group
235
+ } else if (!item.group) {
236
+ lastGroup = undefined
237
+ }
238
+
239
+ nodes.push(<NavLink key={item.path} item={item} ctx={ctx} />)
240
+ if (item.children && item.children.length > 0 && !ctx.collapsed) {
241
+ for (const child of item.children) {
242
+ nodes.push(<NavLink key={child.path} item={child} ctx={ctx} nested />)
243
+ }
244
+ }
245
+ }
246
+
247
+ return nodes
248
+ }
249
+
250
+ function NavLink({
251
+ item,
252
+ ctx,
253
+ nested = false,
254
+ }: {
255
+ item: NavItem
256
+ ctx: NavRenderContext
257
+ nested?: boolean
258
+ }) {
259
+ const Icon = item.icon
260
+ const isActive = isPathActive(ctx.currentPath, item.path)
261
+
262
+ return (
263
+ <button
264
+ onClick={() => ctx.onNavigate(item.path)}
265
+ className={`flex w-full items-center gap-3 rounded-lg text-left transition-colors ${
266
+ nested ? 'py-1.5 pr-3 pl-9' : 'px-3 py-2.5'
267
+ } ${
268
+ isActive
269
+ ? 'bg-sidebar-accent text-sidebar-primary'
270
+ : 'text-sidebar-foreground hover:bg-sidebar-accent'
271
+ } ${ctx.collapsed ? 'justify-center px-3' : ''}`}
272
+ title={ctx.collapsed ? item.label : ''}
273
+ aria-current={isActive ? 'page' : undefined}
274
+ >
275
+ {!nested && <Icon className="h-5 w-5 shrink-0" aria-hidden />}
276
+ {nested && !ctx.collapsed && (
277
+ <Icon className="text-sidebar-foreground/60 h-4 w-4 shrink-0" aria-hidden />
278
+ )}
279
+ {!ctx.collapsed && (
280
+ <span className={`truncate font-medium ${nested ? 'text-[13px]' : 'text-sm'}`}>
281
+ {item.label}
282
+ </span>
283
+ )}
284
+ </button>
285
+ )
286
+ }
287
+
288
+ // ─── Config-driven nav builder ──────────────────────────────────────────────
289
+
212
290
  function resolveIcon(collection: any): LucideIcon {
213
291
  const mapped = collection.admin?.icon ? ICON_MAP[collection.admin.icon] : undefined
214
292
  if (mapped) return mapped
@@ -219,28 +297,42 @@ export interface NavItem {
219
297
  path: string
220
298
  label: string
221
299
  icon: LucideIcon
300
+ /**
301
+ * Section header. Items sharing the same `group` are grouped together
302
+ * under a header rendered on first encounter. Items without a `group`
303
+ * render at the top of the nav in document order.
304
+ */
222
305
  group?: string
306
+ /** Indented children rendered immediately after this item. */
307
+ children?: NavItem[]
223
308
  }
224
309
 
310
+ /**
311
+ * Build the sidebar nav tree from the CMS config. If no collections are
312
+ * configured, returns the curated `defaultNavItems` tree. When
313
+ * collections ARE configured, page-type collections inherit the
314
+ * Pages / Saved Sections / Templates child block, and every static
315
+ * destination (Media, Forms, SEO, Script Tags, Users, API Keys,
316
+ * Settings) is anchored under the right Content / Settings group.
317
+ */
225
318
  function buildNavItems(config: any): NavItem[] {
226
319
  if (!config?.collections) return defaultNavItems
227
320
 
228
321
  const raw = config.collections
229
322
  const collectionsList: any[] = Array.isArray(raw) ? raw : Object.values(raw)
230
-
231
323
  const visible = collectionsList.filter((c) => !c.admin?.hidden)
232
324
 
233
- const ungrouped = visible.filter((c) => !c.admin?.group)
325
+ const pages = visible.filter((c) => c.type === 'page' && !c.admin?.group)
326
+ const posts = visible.filter((c) => c.type === 'post' && !c.admin?.group)
327
+ const otherUngrouped = visible.filter(
328
+ (c) => c.type !== 'page' && c.type !== 'post' && !c.admin?.group,
329
+ )
234
330
  const grouped = visible.filter((c) => c.admin?.group)
235
331
 
236
- const pages = ungrouped.filter((c) => c.type === 'page')
237
- const posts = ungrouped.filter((c) => c.type === 'post')
238
- const other = ungrouped.filter((c) => c.type !== 'page' && c.type !== 'post')
239
- const sortedUngrouped = [...pages, ...posts, ...other]
240
-
241
332
  const items: NavItem[] = [{ path: '/', label: 'Dashboard', icon: LayoutDashboard }]
242
333
 
243
- for (const collection of sortedUngrouped) {
334
+ // Posts-style collections come first (mirrors the reference layout).
335
+ for (const collection of posts) {
244
336
  items.push({
245
337
  label: collection.labels?.plural ?? collection.slug,
246
338
  path: `/${collection.slug}`,
@@ -248,34 +340,62 @@ function buildNavItems(config: any): NavItem[] {
248
340
  })
249
341
  }
250
342
 
251
- const groups = new Map<string, typeof grouped>()
343
+ // Pages-style collections get the nested Saved Sections + Templates
344
+ // tree. We attach the children to the *first* page-type collection
345
+ // so we don't end up with three "Saved Sections" links if a project
346
+ // configures multiple page collections — admins editing custom
347
+ // page-style content still navigate to those secondary surfaces via
348
+ // the primary Pages parent.
349
+ pages.forEach((collection, idx) => {
350
+ const item: NavItem = {
351
+ label: collection.labels?.plural ?? collection.slug,
352
+ path: `/${collection.slug}`,
353
+ icon: resolveIcon(collection),
354
+ }
355
+ if (idx === 0) {
356
+ item.children = [
357
+ { path: '/saved-sections', label: 'Saved Sections', icon: Library },
358
+ { path: '/page-templates', label: 'Templates', icon: Layers },
359
+ ]
360
+ }
361
+ items.push(item)
362
+ })
363
+
364
+ for (const collection of otherUngrouped) {
365
+ items.push({
366
+ label: collection.labels?.plural ?? collection.slug,
367
+ path: `/${collection.slug}`,
368
+ icon: resolveIcon(collection),
369
+ })
370
+ }
371
+
372
+ // Config-supplied groups land before our standard Content/Settings
373
+ // groups so admin-defined sections take precedence.
374
+ const groupBuckets = new Map<string, NavItem[]>()
252
375
  for (const col of grouped) {
253
376
  const group = col.admin.group as string
254
- if (!groups.has(group)) groups.set(group, [])
255
- groups.get(group)!.push(col)
256
- }
257
- for (const [groupName, cols] of groups) {
258
- for (const collection of cols) {
259
- items.push({
260
- label: collection.labels?.plural ?? collection.slug,
261
- path: `/${collection.slug}`,
262
- icon: resolveIcon(collection),
263
- group: groupName,
264
- })
265
- }
377
+ const bucket = groupBuckets.get(group) ?? []
378
+ bucket.push({
379
+ label: col.labels?.plural ?? col.slug,
380
+ path: `/${col.slug}`,
381
+ icon: resolveIcon(col),
382
+ group,
383
+ })
384
+ groupBuckets.set(group, bucket)
266
385
  }
386
+ for (const bucket of groupBuckets.values()) items.push(...bucket)
267
387
 
388
+ // Standard Content + Settings destinations. These are always
389
+ // present, regardless of which collections the admin configures,
390
+ // because they map to CMS features (not collections).
268
391
  items.push(
269
- { path: '/page-builder/new', label: 'Page Builder', icon: LayoutTemplate, group: 'Pages' },
270
- { path: '/saved-sections', label: 'Saved Sections', icon: Library, group: 'Pages' },
271
- { path: '/page-templates', label: 'Templates', icon: Layers, group: 'Pages' },
272
- { path: '/media', label: 'Media', icon: Image },
273
- { path: '/forms', label: 'Forms', icon: ClipboardList },
274
- { path: '/seo', label: 'SEO', icon: SearchIcon },
275
- { path: '/script-tags', label: 'Script Tags', icon: Code2 },
276
- { path: '/users', label: 'Users', icon: Users },
277
- { path: '/api-keys', label: 'API Keys', icon: KeyRound },
278
- { path: '/settings', label: 'Settings', icon: Settings },
392
+ { path: '/media', label: 'Media', icon: Image, group: 'Content' },
393
+ { path: '/forms', label: 'Forms', icon: ClipboardList, group: 'Content' },
394
+ { path: '/seo', label: 'SEO', icon: SearchIcon, group: 'Content' },
395
+ { path: '/script-tags', label: 'Script Tags', icon: Code2, group: 'Content' },
396
+ { path: '/users', label: 'Users', icon: Users, group: 'Settings' },
397
+ { path: '/api-keys', label: 'API Keys', icon: KeyRound, group: 'Settings' },
398
+ { path: '/settings', label: 'Settings', icon: Settings, group: 'Settings' },
279
399
  )
280
400
 
281
401
  return items