@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.
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +28 -4
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/layout/sidebar-posts.test.js +6 -4
- package/dist/__tests__/layout/sidebar-posts.test.js.map +1 -1
- package/dist/__tests__/layout/sidebar-submenu.render.test.d.ts +2 -0
- package/dist/__tests__/layout/sidebar-submenu.render.test.d.ts.map +1 -0
- package/dist/__tests__/layout/sidebar-submenu.render.test.js +60 -0
- package/dist/__tests__/layout/sidebar-submenu.render.test.js.map +1 -0
- package/dist/__tests__/lib/seo-service-errors.test.d.ts +2 -0
- package/dist/__tests__/lib/seo-service-errors.test.d.ts.map +1 -0
- package/dist/__tests__/lib/seo-service-errors.test.js +46 -0
- package/dist/__tests__/lib/seo-service-errors.test.js.map +1 -0
- package/dist/__tests__/views/use-seo-resource.test.d.ts +2 -0
- package/dist/__tests__/views/use-seo-resource.test.d.ts.map +1 -0
- package/dist/__tests__/views/use-seo-resource.test.js +56 -0
- package/dist/__tests__/views/use-seo-resource.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/components/Breadcrumbs.d.ts.map +1 -1
- package/dist/components/Breadcrumbs.js +4 -1
- package/dist/components/Breadcrumbs.js.map +1 -1
- package/dist/components/ErrorBoundary.d.ts +1 -1
- package/dist/components/seo/Drawer.d.ts.map +1 -1
- package/dist/components/seo/Drawer.js +5 -1
- package/dist/components/seo/Drawer.js.map +1 -1
- package/dist/components/seo/SeoEditorDrawer.d.ts.map +1 -1
- package/dist/components/seo/SeoEditorDrawer.js +11 -2
- package/dist/components/seo/SeoEditorDrawer.js.map +1 -1
- package/dist/components/seo/primitives.d.ts.map +1 -1
- package/dist/components/seo/primitives.js +2 -1
- package/dist/components/seo/primitives.js.map +1 -1
- package/dist/layout/Sidebar.d.ts.map +1 -1
- package/dist/layout/Sidebar.js +105 -12
- package/dist/layout/Sidebar.js.map +1 -1
- package/dist/lib/seo-service.d.ts.map +1 -1
- package/dist/lib/seo-service.js +40 -4
- package/dist/lib/seo-service.js.map +1 -1
- package/dist/views/ApiKeys.d.ts +4 -1
- package/dist/views/ApiKeys.d.ts.map +1 -1
- package/dist/views/ApiKeys.js +5 -3
- package/dist/views/ApiKeys.js.map +1 -1
- package/dist/views/Dashboard.js +2 -2
- package/dist/views/Dashboard.js.map +1 -1
- package/dist/views/Forms.d.ts.map +1 -1
- package/dist/views/Forms.js +2 -1
- package/dist/views/Forms.js.map +1 -1
- package/dist/views/Settings.d.ts +4 -1
- package/dist/views/Settings.d.ts.map +1 -1
- package/dist/views/Settings.js +45 -6
- package/dist/views/Settings.js.map +1 -1
- package/dist/views/Users.d.ts +4 -1
- package/dist/views/Users.d.ts.map +1 -1
- package/dist/views/Users.js +5 -3
- package/dist/views/Users.js.map +1 -1
- package/dist/views/seo/AuditTab.js +2 -2
- package/dist/views/seo/AuditTab.js.map +1 -1
- package/dist/views/seo/ContentTab.js +1 -1
- package/dist/views/seo/ContentTab.js.map +1 -1
- package/dist/views/seo/RedirectsTab.d.ts.map +1 -1
- package/dist/views/seo/RedirectsTab.js +8 -2
- package/dist/views/seo/RedirectsTab.js.map +1 -1
- package/dist/views/seo/TechnicalTab.d.ts.map +1 -1
- package/dist/views/seo/TechnicalTab.js +7 -4
- package/dist/views/seo/TechnicalTab.js.map +1 -1
- package/dist/views/seo/useSeoResource.d.ts.map +1 -1
- package/dist/views/seo/useSeoResource.js +20 -7
- package/dist/views/seo/useSeoResource.js.map +1 -1
- package/package.json +2 -2
- package/src/AdminRoot.tsx +26 -4
- package/src/__tests__/layout/sidebar-posts.test.ts +6 -4
- package/src/__tests__/layout/sidebar-submenu.render.test.tsx +86 -0
- package/src/__tests__/lib/seo-service-errors.test.ts +60 -0
- package/src/__tests__/views/use-seo-resource.test.tsx +68 -0
- package/src/components/Breadcrumbs.tsx +4 -1
- package/src/components/seo/Drawer.tsx +4 -0
- package/src/components/seo/SeoEditorDrawer.tsx +15 -3
- package/src/components/seo/primitives.tsx +4 -1
- package/src/layout/Sidebar.tsx +228 -19
- package/src/lib/seo-service.ts +41 -4
- package/src/views/ApiKeys.tsx +10 -5
- package/src/views/Dashboard.tsx +2 -2
- package/src/views/Forms.tsx +3 -1
- package/src/views/Settings.tsx +93 -40
- package/src/views/Users.tsx +10 -4
- package/src/views/seo/AuditTab.tsx +6 -6
- package/src/views/seo/ContentTab.tsx +3 -3
- package/src/views/seo/RedirectsTab.tsx +8 -2
- package/src/views/seo/TechnicalTab.tsx +25 -13
- package/src/views/seo/useSeoResource.ts +21 -7
package/src/layout/Sidebar.tsx
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import type
|
|
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
|
-
|
|
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
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
}
|
package/src/lib/seo-service.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
658
|
+
if (isAiUnavailable(res.status)) return { unavailable: true }
|
|
622
659
|
return res.data ?? {}
|
|
623
660
|
}
|
package/src/views/ApiKeys.tsx
CHANGED
|
@@ -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(
|
|
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=
|
|
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=
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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>{' '}
|
package/src/views/Dashboard.tsx
CHANGED
|
@@ -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>
|
package/src/views/Forms.tsx
CHANGED
|
@@ -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.
|
|
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" />
|