@actuate-media/cms-admin 0.20.0 → 0.21.1

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 (89) hide show
  1. package/dist/AdminRoot.d.ts.map +1 -1
  2. package/dist/AdminRoot.js +28 -4
  3. package/dist/AdminRoot.js.map +1 -1
  4. package/dist/__tests__/layout/sidebar-posts.test.js +6 -4
  5. package/dist/__tests__/layout/sidebar-posts.test.js.map +1 -1
  6. package/dist/__tests__/layout/sidebar-submenu.render.test.d.ts +2 -0
  7. package/dist/__tests__/layout/sidebar-submenu.render.test.d.ts.map +1 -0
  8. package/dist/__tests__/layout/sidebar-submenu.render.test.js +60 -0
  9. package/dist/__tests__/layout/sidebar-submenu.render.test.js.map +1 -0
  10. package/dist/__tests__/lib/seo-service-errors.test.d.ts +2 -0
  11. package/dist/__tests__/lib/seo-service-errors.test.d.ts.map +1 -0
  12. package/dist/__tests__/lib/seo-service-errors.test.js +46 -0
  13. package/dist/__tests__/lib/seo-service-errors.test.js.map +1 -0
  14. package/dist/__tests__/views/use-seo-resource.test.d.ts +2 -0
  15. package/dist/__tests__/views/use-seo-resource.test.d.ts.map +1 -0
  16. package/dist/__tests__/views/use-seo-resource.test.js +56 -0
  17. package/dist/__tests__/views/use-seo-resource.test.js.map +1 -0
  18. package/dist/actuate-admin.css +1 -1
  19. package/dist/components/Breadcrumbs.d.ts.map +1 -1
  20. package/dist/components/Breadcrumbs.js +4 -1
  21. package/dist/components/Breadcrumbs.js.map +1 -1
  22. package/dist/components/ErrorBoundary.d.ts +1 -1
  23. package/dist/components/seo/Drawer.d.ts.map +1 -1
  24. package/dist/components/seo/Drawer.js +5 -1
  25. package/dist/components/seo/Drawer.js.map +1 -1
  26. package/dist/components/seo/SeoEditorDrawer.d.ts.map +1 -1
  27. package/dist/components/seo/SeoEditorDrawer.js +11 -2
  28. package/dist/components/seo/SeoEditorDrawer.js.map +1 -1
  29. package/dist/components/seo/primitives.d.ts.map +1 -1
  30. package/dist/components/seo/primitives.js +2 -1
  31. package/dist/components/seo/primitives.js.map +1 -1
  32. package/dist/layout/Sidebar.d.ts.map +1 -1
  33. package/dist/layout/Sidebar.js +105 -12
  34. package/dist/layout/Sidebar.js.map +1 -1
  35. package/dist/lib/seo-service.d.ts.map +1 -1
  36. package/dist/lib/seo-service.js +40 -4
  37. package/dist/lib/seo-service.js.map +1 -1
  38. package/dist/views/ApiKeys.d.ts +4 -1
  39. package/dist/views/ApiKeys.d.ts.map +1 -1
  40. package/dist/views/ApiKeys.js +5 -3
  41. package/dist/views/ApiKeys.js.map +1 -1
  42. package/dist/views/Dashboard.js +2 -2
  43. package/dist/views/Dashboard.js.map +1 -1
  44. package/dist/views/Forms.d.ts.map +1 -1
  45. package/dist/views/Forms.js +2 -1
  46. package/dist/views/Forms.js.map +1 -1
  47. package/dist/views/Settings.d.ts +4 -1
  48. package/dist/views/Settings.d.ts.map +1 -1
  49. package/dist/views/Settings.js +45 -6
  50. package/dist/views/Settings.js.map +1 -1
  51. package/dist/views/Users.d.ts +4 -1
  52. package/dist/views/Users.d.ts.map +1 -1
  53. package/dist/views/Users.js +5 -3
  54. package/dist/views/Users.js.map +1 -1
  55. package/dist/views/seo/AuditTab.js +2 -2
  56. package/dist/views/seo/AuditTab.js.map +1 -1
  57. package/dist/views/seo/ContentTab.js +1 -1
  58. package/dist/views/seo/ContentTab.js.map +1 -1
  59. package/dist/views/seo/RedirectsTab.d.ts.map +1 -1
  60. package/dist/views/seo/RedirectsTab.js +8 -2
  61. package/dist/views/seo/RedirectsTab.js.map +1 -1
  62. package/dist/views/seo/TechnicalTab.d.ts.map +1 -1
  63. package/dist/views/seo/TechnicalTab.js +7 -4
  64. package/dist/views/seo/TechnicalTab.js.map +1 -1
  65. package/dist/views/seo/useSeoResource.d.ts.map +1 -1
  66. package/dist/views/seo/useSeoResource.js +20 -7
  67. package/dist/views/seo/useSeoResource.js.map +1 -1
  68. package/package.json +2 -2
  69. package/src/AdminRoot.tsx +26 -4
  70. package/src/__tests__/layout/sidebar-posts.test.ts +6 -4
  71. package/src/__tests__/layout/sidebar-submenu.render.test.tsx +86 -0
  72. package/src/__tests__/lib/seo-service-errors.test.ts +60 -0
  73. package/src/__tests__/views/use-seo-resource.test.tsx +68 -0
  74. package/src/components/Breadcrumbs.tsx +4 -1
  75. package/src/components/seo/Drawer.tsx +4 -0
  76. package/src/components/seo/SeoEditorDrawer.tsx +15 -3
  77. package/src/components/seo/primitives.tsx +4 -1
  78. package/src/layout/Sidebar.tsx +228 -19
  79. package/src/lib/seo-service.ts +41 -4
  80. package/src/views/ApiKeys.tsx +10 -5
  81. package/src/views/Dashboard.tsx +2 -2
  82. package/src/views/Forms.tsx +3 -1
  83. package/src/views/Settings.tsx +93 -40
  84. package/src/views/Users.tsx +10 -4
  85. package/src/views/seo/AuditTab.tsx +6 -6
  86. package/src/views/seo/ContentTab.tsx +3 -3
  87. package/src/views/seo/RedirectsTab.tsx +8 -2
  88. package/src/views/seo/TechnicalTab.tsx +25 -13
  89. package/src/views/seo/useSeoResource.ts +21 -7
@@ -1,6 +1,8 @@
1
1
  'use client'
2
2
 
3
- import type { ReactNode } from 'react'
3
+ import { useRef, useState, type ReactNode } from 'react'
4
+ import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
5
+ import { useTheme } from '../components/ThemeProvider.js'
4
6
  import {
5
7
  LayoutDashboard,
6
8
  FileText,
@@ -8,8 +10,8 @@ import {
8
10
  Image,
9
11
  Settings,
10
12
  ChevronLeft,
13
+ ChevronDown,
11
14
  ClipboardList,
12
- Users,
13
15
  Search as SearchIcon,
14
16
  Briefcase,
15
17
  FolderOpen,
@@ -22,7 +24,6 @@ import {
22
24
  LayoutGrid,
23
25
  Code2,
24
26
  Library,
25
- KeyRound,
26
27
  ListTree,
27
28
  Menu as MenuIcon,
28
29
  PanelLeftClose,
@@ -156,8 +157,6 @@ const defaultNavItems: NavItem[] = [
156
157
  { path: '/forms', label: 'Forms', icon: ClipboardList, group: 'Content' },
157
158
  { path: '/seo', label: 'SEO', icon: SearchIcon, group: 'Content' },
158
159
  { path: '/script-tags', label: 'Script Tags', icon: Code2, group: 'Content' },
159
- { path: '/users', label: 'Users', icon: Users, group: 'Settings' },
160
- { path: '/api-keys', label: 'API Keys', icon: KeyRound, group: 'Settings' },
161
160
  { path: '/settings', label: 'Settings', icon: Settings, group: 'Settings' },
162
161
  ]
163
162
 
@@ -275,36 +274,117 @@ function renderNavTree(items: NavItem[], ctx: NavRenderContext): ReactNode {
275
274
  lastGroup = undefined
276
275
  }
277
276
 
277
+ const hasChildren = !!item.children && item.children.length > 0
278
+
279
+ // Collapsed parents reveal their children in a hover/focus flyout instead
280
+ // of inline (there's no room for indented rows in the 80px rail).
281
+ if (hasChildren && ctx.collapsed) {
282
+ nodes.push(<CollapsedFlyout key={`flyout:${item.path}`} item={item} ctx={ctx} />)
283
+ continue
284
+ }
285
+
286
+ // Expanded parents with children render as a collapsible section
287
+ // (chevron toggle to contract/expand the submenu).
288
+ if (hasChildren) {
289
+ nodes.push(<NavSection key={`section:${item.path}`} item={item} ctx={ctx} />)
290
+ continue
291
+ }
292
+
278
293
  // Keys are namespaced because a parent and its first child can share
279
294
  // the same path (e.g. the Posts parent and its "All Posts" child are
280
295
  // both `/posts`). Using the raw path as the key collided and made
281
296
  // React warn about — and risk dropping — duplicate siblings.
282
297
  nodes.push(<NavLink key={`item:${item.path}`} item={item} ctx={ctx} />)
283
- if (item.children && item.children.length > 0 && !ctx.collapsed) {
284
- for (const child of item.children) {
285
- nodes.push(
286
- <NavLink key={`child:${item.path}:${child.path}`} item={child} ctx={ctx} nested />,
287
- )
288
- }
289
- }
290
298
  }
291
299
 
292
300
  return nodes
293
301
  }
294
302
 
303
+ /**
304
+ * Expanded parent with a collapsible submenu. The label/icon navigates to the
305
+ * parent destination; a trailing chevron toggles the child list open/closed.
306
+ * Defaults to open, and auto-opens when the parent or one of its children is
307
+ * the active route so the current page is never hidden behind a collapsed
308
+ * section.
309
+ */
310
+ function NavSection({ item, ctx }: { item: NavItem; ctx: NavRenderContext }) {
311
+ const Icon = item.icon
312
+ const isActive = isPathActive(ctx.currentPath, item.path)
313
+ const childActive = item.children!.some((c) => isPathActive(ctx.currentPath, c.path))
314
+ const [open, setOpen] = useState(true)
315
+ const childListId = `nav-section-${item.path.replace(/[^a-z0-9]+/gi, '-')}`
316
+
317
+ // Keep the active section visible: if the user is on this section's route
318
+ // (parent or child) it stays expanded regardless of the manual toggle.
319
+ const expanded = open || isActive || childActive
320
+
321
+ return (
322
+ <div>
323
+ <div
324
+ className={`flex items-center rounded-lg transition-colors ${
325
+ isActive
326
+ ? 'bg-sidebar-accent text-sidebar-primary'
327
+ : 'text-sidebar-foreground hover:bg-sidebar-accent'
328
+ }`}
329
+ >
330
+ <button
331
+ onClick={() => ctx.onNavigate(item.path)}
332
+ className="flex min-w-0 flex-1 items-center gap-3 rounded-lg py-2.5 pr-1 pl-3 text-left"
333
+ aria-current={isActive ? 'page' : undefined}
334
+ >
335
+ <Icon className="h-5 w-5 shrink-0" aria-hidden />
336
+ <span className="truncate text-sm font-medium">{item.label}</span>
337
+ </button>
338
+ <button
339
+ onClick={() => setOpen((v) => !v)}
340
+ className="text-sidebar-foreground/55 hover:text-sidebar-foreground mr-1 flex shrink-0 items-center rounded-md p-1.5 transition-colors"
341
+ aria-label={`${expanded ? 'Collapse' : 'Expand'} ${item.label}`}
342
+ aria-expanded={expanded}
343
+ aria-controls={childListId}
344
+ >
345
+ <ChevronDown
346
+ className={`h-4 w-4 transition-transform duration-200 ${expanded ? '' : '-rotate-90'}`}
347
+ aria-hidden
348
+ />
349
+ </button>
350
+ </div>
351
+
352
+ {/* Tight (gap-less) connector container so the vertical tree guides of
353
+ adjacent rows touch and read as one continuous line with curved
354
+ elbows into each child. */}
355
+ {expanded && (
356
+ <div id={childListId} className="relative">
357
+ {item.children!.map((child, i) => (
358
+ <NavLink
359
+ key={`child:${item.path}:${child.path}`}
360
+ item={child}
361
+ ctx={ctx}
362
+ nested
363
+ isLast={i === item.children!.length - 1}
364
+ />
365
+ ))}
366
+ </div>
367
+ )}
368
+ </div>
369
+ )
370
+ }
371
+
295
372
  function NavLink({
296
373
  item,
297
374
  ctx,
298
375
  nested = false,
376
+ isLast = false,
299
377
  }: {
300
378
  item: NavItem
301
379
  ctx: NavRenderContext
302
380
  nested?: boolean
381
+ /** Last child in its group — the elbow stops here (no continuation line). */
382
+ isLast?: boolean
303
383
  }) {
304
384
  const Icon = item.icon
305
385
  const isActive = isPathActive(ctx.currentPath, item.path)
306
386
 
307
- return (
387
+ const button = (
308
388
  <button
309
389
  onClick={() => ctx.onNavigate(item.path)}
310
390
  className={`flex w-full items-center gap-3 rounded-lg text-left transition-colors ${
@@ -328,6 +408,137 @@ function NavLink({
328
408
  )}
329
409
  </button>
330
410
  )
411
+
412
+ if (!nested || ctx.collapsed) return button
413
+
414
+ // Curved-elbow tree connector (expanded only). The elbow draws the vertical
415
+ // run from the row top to its vertical center, curves at the bottom-left
416
+ // corner, then runs horizontally into the row. Non-last rows continue the
417
+ // vertical line to the row bottom so it meets the next sibling's elbow.
418
+ // The guide is centered (~21px) under the parent item's 20px icon.
419
+ return (
420
+ <div className="relative">
421
+ <span
422
+ aria-hidden
423
+ className="border-sidebar-border absolute top-0 left-[21px] h-1/2 w-[15px] rounded-bl-lg border-b border-l"
424
+ />
425
+ {!isLast && (
426
+ <span
427
+ aria-hidden
428
+ className="border-sidebar-border absolute top-1/2 bottom-0 left-[21px] border-l"
429
+ />
430
+ )}
431
+ {button}
432
+ </div>
433
+ )
434
+ }
435
+
436
+ /**
437
+ * Collapsed-rail parent: shows the icon button and, on hover or keyboard
438
+ * focus, a flyout listing the parent's children. Built on Radix
439
+ * DropdownMenu so the panel is portaled (escaping the nav's `overflow`
440
+ * clip), positioned, dismissable, and keyboard-navigable. Hover-opening is
441
+ * controlled manually; we suppress Radix's auto-focus when opened by pointer
442
+ * so hovering doesn't steal focus, while keyboard opening still moves focus
443
+ * into the menu.
444
+ */
445
+ function CollapsedFlyout({ item, ctx }: { item: NavItem; ctx: NavRenderContext }) {
446
+ const Icon = item.icon
447
+ const isActive = isPathActive(ctx.currentPath, item.path)
448
+ const { resolvedTheme } = useTheme()
449
+ const [open, setOpen] = useState(false)
450
+ const openedByPointer = useRef(false)
451
+ const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
452
+
453
+ const cancelClose = () => {
454
+ if (closeTimer.current) {
455
+ clearTimeout(closeTimer.current)
456
+ closeTimer.current = null
457
+ }
458
+ }
459
+ const scheduleClose = () => {
460
+ cancelClose()
461
+ closeTimer.current = setTimeout(() => setOpen(false), 120)
462
+ }
463
+
464
+ return (
465
+ <DropdownMenu.Root
466
+ open={open}
467
+ onOpenChange={(next) => {
468
+ if (!next) openedByPointer.current = false
469
+ setOpen(next)
470
+ }}
471
+ modal={false}
472
+ >
473
+ <div
474
+ onMouseEnter={() => {
475
+ openedByPointer.current = true
476
+ cancelClose()
477
+ setOpen(true)
478
+ }}
479
+ onMouseLeave={scheduleClose}
480
+ >
481
+ <DropdownMenu.Trigger asChild>
482
+ <button
483
+ onClick={() => ctx.onNavigate(item.path)}
484
+ className={`flex w-full items-center justify-center rounded-lg px-3 py-2.5 transition-colors ${
485
+ isActive
486
+ ? 'bg-sidebar-accent text-sidebar-primary'
487
+ : 'text-sidebar-foreground hover:bg-sidebar-accent'
488
+ }`}
489
+ aria-label={item.label}
490
+ title={item.label}
491
+ aria-current={isActive ? 'page' : undefined}
492
+ >
493
+ <Icon className="h-5 w-5 shrink-0" aria-hidden />
494
+ </button>
495
+ </DropdownMenu.Trigger>
496
+ </div>
497
+
498
+ <DropdownMenu.Portal>
499
+ <DropdownMenu.Content
500
+ side="right"
501
+ align="start"
502
+ sideOffset={10}
503
+ onMouseEnter={cancelClose}
504
+ onMouseLeave={scheduleClose}
505
+ onCloseAutoFocus={(e) => {
506
+ // After a pointer-driven open, don't yank focus back to the rail
507
+ // trigger (it would leave a stray focus ring once the cursor moves
508
+ // away). Keyboard-driven opens keep the default focus-return.
509
+ if (openedByPointer.current) e.preventDefault()
510
+ }}
511
+ // Radix portals to <body>, outside the `.actuate-admin` scope where
512
+ // the theme tokens (`--popover`, `--radius`, …) live — without the
513
+ // scope class the panel renders transparent with square corners.
514
+ // Re-assert the scope (and current dark state) on the panel itself so
515
+ // it inherits the tokens regardless of portal position.
516
+ className={`actuate-admin ${resolvedTheme === 'dark' ? 'dark' : ''} bg-popover text-popover-foreground border-border motion-safe:animate-in motion-safe:fade-in z-50 min-w-44 overflow-hidden rounded-lg border p-1 shadow-lg`}
517
+ >
518
+ <div className="text-muted-foreground border-border mb-1 border-b px-3 py-2 text-xs font-medium">
519
+ {item.label}
520
+ </div>
521
+ {item.children!.map((child) => {
522
+ const ChildIcon = child.icon
523
+ const childActive = isPathActive(ctx.currentPath, child.path)
524
+ return (
525
+ <DropdownMenu.Item
526
+ key={`flyout-item:${item.path}:${child.path}`}
527
+ onSelect={() => ctx.onNavigate(child.path)}
528
+ className={`data-highlighted:bg-accent hover:bg-accent flex cursor-pointer items-center gap-2.5 rounded-md px-3 py-2 text-sm font-medium outline-none select-none ${
529
+ childActive ? 'text-primary' : 'text-foreground'
530
+ }`}
531
+ aria-current={childActive ? 'page' : undefined}
532
+ >
533
+ <ChildIcon className="text-muted-foreground h-4 w-4 shrink-0" aria-hidden />
534
+ <span className="truncate">{child.label}</span>
535
+ </DropdownMenu.Item>
536
+ )
537
+ })}
538
+ </DropdownMenu.Content>
539
+ </DropdownMenu.Portal>
540
+ </DropdownMenu.Root>
541
+ )
331
542
  }
332
543
 
333
544
  // ─── Config-driven nav builder ──────────────────────────────────────────────
@@ -460,12 +671,10 @@ export function buildNavItems(config: any): NavItem[] {
460
671
  }
461
672
  for (const bucket of groupBuckets.values()) items.push(...bucket)
462
673
 
463
- // Settings destinations — always last.
464
- items.push(
465
- { path: '/users', label: 'Users', icon: Users, group: 'Settings' },
466
- { path: '/api-keys', label: 'API Keys', icon: KeyRound, group: 'Settings' },
467
- { path: '/settings', label: 'Settings', icon: Settings, group: 'Settings' },
468
- )
674
+ // Settings destinations — always last. Users + API Keys now live inside
675
+ // the Settings view as tabs (see Settings.tsx), so only Settings is anchored
676
+ // here.
677
+ items.push({ path: '/settings', label: 'Settings', icon: Settings, group: 'Settings' })
469
678
 
470
679
  return items
471
680
  }
@@ -10,6 +10,19 @@
10
10
  */
11
11
  import { cmsApi } from './api.js'
12
12
 
13
+ /**
14
+ * `cmsApi` never throws — it returns `{ error, status }` on a network/HTTP
15
+ * failure. Read (GET) fetchers below must re-surface that as a thrown error so
16
+ * `useSeoResource` can distinguish "request failed" from "empty result".
17
+ * Without this, a failed `/seo/overview` would fall through to the empty
18
+ * fallback (a fake score of 0 / grade "Critical") and the error/retry UI would
19
+ * be permanently unreachable. Mutations intentionally return `{ error }` and do
20
+ * not use this.
21
+ */
22
+ function throwIfError(res: { error?: string }): void {
23
+ if (res.error) throw new Error(res.error)
24
+ }
25
+
13
26
  // ─── Shared enums ────────────────────────────────────────────────────
14
27
 
15
28
  export type SeoGrade = 'good' | 'fair' | 'poor' | 'critical'
@@ -110,6 +123,7 @@ const EMPTY_OVERVIEW: SeoOverview = {
110
123
 
111
124
  export async function fetchSeoOverview(): Promise<SeoOverview> {
112
125
  const res = await cmsApi<SeoOverview>('/seo/overview')
126
+ throwIfError(res)
113
127
  return res.data ?? EMPTY_OVERVIEW
114
128
  }
115
129
 
@@ -120,6 +134,7 @@ export async function fetchSearchPerformance(
120
134
  ): Promise<SearchPerformanceRow[]> {
121
135
  const qs = params.limit ? `?limit=${params.limit}` : ''
122
136
  const res = await cmsApi<{ rows: SearchPerformanceRow[] }>(`/seo/search-performance${qs}`)
137
+ throwIfError(res)
123
138
  return res.data?.rows ?? []
124
139
  }
125
140
 
@@ -155,6 +170,7 @@ export type ContentSeoFilter = 'all' | 'pages' | 'posts'
155
170
 
156
171
  export async function fetchSeoContentRecords(): Promise<SeoContentRecord[]> {
157
172
  const res = await cmsApi<{ records: SeoContentRecord[] }>('/seo/content')
173
+ throwIfError(res)
158
174
  return res.data?.records ?? []
159
175
  }
160
176
 
@@ -165,6 +181,7 @@ export async function fetchSeoContentRecord(
165
181
  const res = await cmsApi<SeoContentRecord>(
166
182
  `/seo/content/${entityType}/${encodeURIComponent(entityId)}`,
167
183
  )
184
+ throwIfError(res)
168
185
  return res.data ?? null
169
186
  }
170
187
 
@@ -257,11 +274,13 @@ export async function runSeoAudit(
257
274
 
258
275
  export async function fetchSeoAuditRuns(): Promise<SeoAuditRun[]> {
259
276
  const res = await cmsApi<{ runs: SeoAuditRun[] }>('/seo/audits')
277
+ throwIfError(res)
260
278
  return res.data?.runs ?? []
261
279
  }
262
280
 
263
281
  export async function fetchSeoAuditRun(auditRunId: string): Promise<SeoAuditRun | null> {
264
282
  const res = await cmsApi<SeoAuditRun>(`/seo/audits/${encodeURIComponent(auditRunId)}`)
283
+ throwIfError(res)
265
284
  return res.data ?? null
266
285
  }
267
286
 
@@ -280,11 +299,13 @@ export async function fetchSeoIssues(query: SeoIssueQuery = {}): Promise<SeoIssu
280
299
  if (query.auditRunId) params.set('auditRunId', query.auditRunId)
281
300
  const qs = params.toString()
282
301
  const res = await cmsApi<{ issues: SeoIssue[] }>(`/seo/issues${qs ? `?${qs}` : ''}`)
302
+ throwIfError(res)
283
303
  return res.data?.issues ?? []
284
304
  }
285
305
 
286
306
  export async function fetchSeoIssue(issueId: string): Promise<SeoIssue | null> {
287
307
  const res = await cmsApi<SeoIssue>(`/seo/issues/${encodeURIComponent(issueId)}`)
308
+ throwIfError(res)
288
309
  return res.data ?? null
289
310
  }
290
311
 
@@ -325,6 +346,7 @@ export interface CrawlSettings {
325
346
 
326
347
  export async function fetchSitemapStatus(): Promise<SitemapStatus | null> {
327
348
  const res = await cmsApi<SitemapStatus>('/seo/sitemap/status')
349
+ throwIfError(res)
328
350
  return res.data ?? null
329
351
  }
330
352
 
@@ -336,6 +358,7 @@ export async function regenerateSitemap(): Promise<{ status?: SitemapStatus; err
336
358
 
337
359
  export async function fetchCrawlSettings(): Promise<CrawlSettings | null> {
338
360
  const res = await cmsApi<CrawlSettings>('/seo/crawl-settings')
361
+ throwIfError(res)
339
362
  return res.data ?? null
340
363
  }
341
364
 
@@ -363,6 +386,7 @@ export interface RobotsValidation {
363
386
 
364
387
  export async function fetchRobotsTxt(): Promise<RobotsConfig | null> {
365
388
  const res = await cmsApi<RobotsConfig>('/seo/robots')
389
+ throwIfError(res)
366
390
  return res.data ?? null
367
391
  }
368
392
 
@@ -463,6 +487,7 @@ export interface RedirectChain {
463
487
 
464
488
  export async function fetchRedirectOverview(): Promise<RedirectOverview> {
465
489
  const res = await cmsApi<RedirectOverview>('/seo/redirects/overview')
490
+ throwIfError(res)
466
491
  return (
467
492
  res.data ?? {
468
493
  activeRedirects: 0,
@@ -477,6 +502,7 @@ export async function fetchRedirectOverview(): Promise<RedirectOverview> {
477
502
 
478
503
  export async function fetchRedirectRules(): Promise<RedirectRule[]> {
479
504
  const res = await cmsApi<{ rules: RedirectRule[] }>('/seo/redirects')
505
+ throwIfError(res)
480
506
  return res.data?.rules ?? []
481
507
  }
482
508
 
@@ -516,6 +542,7 @@ export async function deleteRedirectRule(redirectId: string): Promise<{ error?:
516
542
 
517
543
  export async function fetchRedirectChains(): Promise<RedirectChain[]> {
518
544
  const res = await cmsApi<{ chains: RedirectChain[] }>('/seo/redirects/chains')
545
+ throwIfError(res)
519
546
  return res.data?.chains ?? []
520
547
  }
521
548
 
@@ -529,6 +556,7 @@ export async function flattenRedirectChain(chainId: string): Promise<{ error?: s
529
556
 
530
557
  export async function fetchRedirectSuggestions(): Promise<RedirectSuggestion[]> {
531
558
  const res = await cmsApi<{ suggestions: RedirectSuggestion[] }>('/seo/redirects/suggestions')
559
+ throwIfError(res)
532
560
  return res.data?.suggestions ?? []
533
561
  }
534
562
 
@@ -582,6 +610,15 @@ export interface SeoAiResult {
582
610
  unavailable?: boolean
583
611
  }
584
612
 
613
+ /**
614
+ * AI SEO endpoints report unavailability with two statuses: 501 (plugin-ai not
615
+ * installed) and 503 (installed but the provider isn't configured). Both map to
616
+ * the same graceful "AI unavailable" UX.
617
+ */
618
+ function isAiUnavailable(status?: number): boolean {
619
+ return status === 501 || status === 503
620
+ }
621
+
585
622
  export async function generateSeoField(
586
623
  field: 'metaTitle' | 'metaDescription' | 'ogTitle' | 'ogDescription' | 'focusKeyword',
587
624
  context: { entityType: string; entityId: string },
@@ -590,7 +627,7 @@ export async function generateSeoField(
590
627
  method: 'POST',
591
628
  body: JSON.stringify({ field, ...context }),
592
629
  })
593
- if (res.status === 501) return { unavailable: true }
630
+ if (isAiUnavailable(res.status)) return { unavailable: true }
594
631
  return res.data ?? {}
595
632
  }
596
633
 
@@ -599,7 +636,7 @@ export async function explainSeoIssue(issueId: string): Promise<SeoAiResult> {
599
636
  method: 'POST',
600
637
  body: JSON.stringify({ issueId }),
601
638
  })
602
- if (res.status === 501) return { unavailable: true }
639
+ if (isAiUnavailable(res.status)) return { unavailable: true }
603
640
  return res.data ?? {}
604
641
  }
605
642
 
@@ -612,12 +649,12 @@ export async function generateStructuredData(context: {
612
649
  method: 'POST',
613
650
  body: JSON.stringify(context),
614
651
  })
615
- if (res.status === 501) return { unavailable: true }
652
+ if (isAiUnavailable(res.status)) return { unavailable: true }
616
653
  return res.data ?? {}
617
654
  }
618
655
 
619
656
  export async function summarizeSeoAudit(): Promise<SeoAiResult> {
620
657
  const res = await cmsApi<SeoAiResult>('/ai/seo/summarize', { method: 'POST' })
621
- if (res.status === 501) return { unavailable: true }
658
+ if (isAiUnavailable(res.status)) return { unavailable: true }
622
659
  return res.data ?? {}
623
660
  }
@@ -19,6 +19,9 @@ import { cmsApi } from '../lib/api.js'
19
19
 
20
20
  export interface ApiKeysProps {
21
21
  onNavigate?: (path: string) => void
22
+ /** When rendered inside the Settings tab shell, drop the page padding and
23
+ * demote the heading so it reads as a section rather than a second page. */
24
+ embedded?: boolean
22
25
  }
23
26
 
24
27
  interface ApiKeyRecord {
@@ -68,12 +71,14 @@ function formatDate(value: string | null): string {
68
71
  }
69
72
  }
70
73
 
71
- export function ApiKeys(_props: ApiKeysProps) {
74
+ export function ApiKeys({ embedded = false }: ApiKeysProps = {}) {
72
75
  const { data, loading, error, refetch } = useApiData<ApiKeyRecord[]>('/api-keys')
73
76
  const [showCreate, setShowCreate] = useState(false)
74
77
  const [createdKey, setCreatedKey] = useState<{ key: string; record: ApiKeyRecord } | null>(null)
75
78
 
76
79
  const keys = data ?? []
80
+ const rootPadding = embedded ? '' : 'p-4 sm:p-6'
81
+ const Heading = embedded ? 'h2' : 'h1'
77
82
 
78
83
  const handleRevoke = async (id: string, name: string) => {
79
84
  if (!confirm(`Revoke API key "${name}"? This cannot be undone.`)) return
@@ -93,20 +98,20 @@ export function ApiKeys(_props: ApiKeysProps) {
93
98
 
94
99
  if (loading) {
95
100
  return (
96
- <div className="flex h-64 items-center justify-center p-4 sm:p-6">
101
+ <div className={`flex h-64 items-center justify-center ${embedded ? '' : 'p-4 sm:p-6'}`}>
97
102
  <Loader2 className="h-6 w-6 animate-spin text-blue-600" />
98
103
  </div>
99
104
  )
100
105
  }
101
106
 
102
107
  return (
103
- <div className="p-4 sm:p-6">
108
+ <div className={rootPadding}>
104
109
  <div className="mb-6 flex flex-col justify-between gap-3 sm:flex-row sm:items-center">
105
110
  <div>
106
- <h1 className="flex items-center gap-2 text-xl font-semibold text-gray-900 sm:text-2xl">
111
+ <Heading className="flex items-center gap-2 text-xl font-semibold text-gray-900 sm:text-2xl">
107
112
  <KeyRound className="h-5 w-5 text-gray-500" />
108
113
  API Keys
109
- </h1>
114
+ </Heading>
110
115
  <p className="mt-1 text-sm text-gray-500">
111
116
  Long-lived credentials for programmatic access. Use the{' '}
112
117
  <code className="rounded bg-gray-100 px-1 py-0.5 text-xs">Authorization: Bearer</code>{' '}
@@ -425,7 +425,7 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
425
425
  { label: 'New Page', icon: Plus, onClick: () => nav(`/${pagesSlug}/new`) },
426
426
  { label: 'Upload Media', icon: Upload, onClick: () => nav('/media') },
427
427
  { label: 'New Form', icon: Plus, onClick: () => nav('/forms') },
428
- { label: 'View API', icon: Globe, onClick: () => nav('/api-keys') },
428
+ { label: 'View API', icon: Globe, onClick: () => nav('/settings?tab=api-keys') },
429
429
  ]
430
430
  return items
431
431
  }, [collections, onNavigate]) // eslint-disable-line react-hooks/exhaustive-deps
@@ -899,7 +899,7 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
899
899
  </div>
900
900
  <button
901
901
  className="text-brand inline-flex items-center gap-1 text-xs font-medium hover:underline"
902
- onClick={() => nav('/api-keys')}
902
+ onClick={() => nav('/settings?tab=api-keys')}
903
903
  >
904
904
  API docs <ExternalLink className="h-3 w-3" />
905
905
  </button>
@@ -106,7 +106,9 @@ export function Forms({ onNavigate }: FormsProps) {
106
106
  <div className="mb-3 flex items-center gap-4 text-sm text-gray-600">
107
107
  <span className="flex items-center gap-1">
108
108
  <FileText className="h-4 w-4" />
109
- {form.fields} fields
109
+ {form.fieldCount ??
110
+ (Array.isArray(form.fields) ? form.fields.length : form.fields)}{' '}
111
+ fields
110
112
  </span>
111
113
  <span className="flex items-center gap-1">
112
114
  <Send className="h-4 w-4" />