@actuate-media/cms-admin 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/dist/AdminRoot.d.ts.map +1 -1
  2. package/dist/AdminRoot.js +4 -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__/lib/seo-service-errors.test.d.ts +2 -0
  7. package/dist/__tests__/lib/seo-service-errors.test.d.ts.map +1 -0
  8. package/dist/__tests__/lib/seo-service-errors.test.js +46 -0
  9. package/dist/__tests__/lib/seo-service-errors.test.js.map +1 -0
  10. package/dist/__tests__/views/use-seo-resource.test.d.ts +2 -0
  11. package/dist/__tests__/views/use-seo-resource.test.d.ts.map +1 -0
  12. package/dist/__tests__/views/use-seo-resource.test.js +56 -0
  13. package/dist/__tests__/views/use-seo-resource.test.js.map +1 -0
  14. package/dist/components/Breadcrumbs.d.ts.map +1 -1
  15. package/dist/components/Breadcrumbs.js +4 -1
  16. package/dist/components/Breadcrumbs.js.map +1 -1
  17. package/dist/components/seo/Drawer.d.ts.map +1 -1
  18. package/dist/components/seo/Drawer.js +5 -1
  19. package/dist/components/seo/Drawer.js.map +1 -1
  20. package/dist/components/seo/SeoEditorDrawer.d.ts.map +1 -1
  21. package/dist/components/seo/SeoEditorDrawer.js +11 -2
  22. package/dist/components/seo/SeoEditorDrawer.js.map +1 -1
  23. package/dist/components/seo/primitives.d.ts.map +1 -1
  24. package/dist/components/seo/primitives.js +2 -1
  25. package/dist/components/seo/primitives.js.map +1 -1
  26. package/dist/layout/Sidebar.d.ts.map +1 -1
  27. package/dist/layout/Sidebar.js +5 -5
  28. package/dist/layout/Sidebar.js.map +1 -1
  29. package/dist/lib/seo-service.d.ts.map +1 -1
  30. package/dist/lib/seo-service.js +40 -4
  31. package/dist/lib/seo-service.js.map +1 -1
  32. package/dist/views/ApiKeys.d.ts +4 -1
  33. package/dist/views/ApiKeys.d.ts.map +1 -1
  34. package/dist/views/ApiKeys.js +5 -3
  35. package/dist/views/ApiKeys.js.map +1 -1
  36. package/dist/views/Dashboard.js +2 -2
  37. package/dist/views/Dashboard.js.map +1 -1
  38. package/dist/views/Settings.d.ts +4 -1
  39. package/dist/views/Settings.d.ts.map +1 -1
  40. package/dist/views/Settings.js +45 -6
  41. package/dist/views/Settings.js.map +1 -1
  42. package/dist/views/Users.d.ts +4 -1
  43. package/dist/views/Users.d.ts.map +1 -1
  44. package/dist/views/Users.js +5 -3
  45. package/dist/views/Users.js.map +1 -1
  46. package/dist/views/seo/AuditTab.js +2 -2
  47. package/dist/views/seo/AuditTab.js.map +1 -1
  48. package/dist/views/seo/ContentTab.js +1 -1
  49. package/dist/views/seo/ContentTab.js.map +1 -1
  50. package/dist/views/seo/RedirectsTab.d.ts.map +1 -1
  51. package/dist/views/seo/RedirectsTab.js +8 -2
  52. package/dist/views/seo/RedirectsTab.js.map +1 -1
  53. package/dist/views/seo/TechnicalTab.d.ts.map +1 -1
  54. package/dist/views/seo/TechnicalTab.js +7 -4
  55. package/dist/views/seo/TechnicalTab.js.map +1 -1
  56. package/dist/views/seo/useSeoResource.d.ts.map +1 -1
  57. package/dist/views/seo/useSeoResource.js +20 -7
  58. package/dist/views/seo/useSeoResource.js.map +1 -1
  59. package/package.json +2 -2
  60. package/src/AdminRoot.tsx +4 -4
  61. package/src/__tests__/layout/sidebar-posts.test.ts +6 -4
  62. package/src/__tests__/lib/seo-service-errors.test.ts +60 -0
  63. package/src/__tests__/views/use-seo-resource.test.tsx +68 -0
  64. package/src/components/Breadcrumbs.tsx +4 -1
  65. package/src/components/seo/Drawer.tsx +4 -0
  66. package/src/components/seo/SeoEditorDrawer.tsx +15 -3
  67. package/src/components/seo/primitives.tsx +4 -1
  68. package/src/layout/Sidebar.tsx +4 -10
  69. package/src/lib/seo-service.ts +41 -4
  70. package/src/views/ApiKeys.tsx +10 -5
  71. package/src/views/Dashboard.tsx +2 -2
  72. package/src/views/Settings.tsx +93 -40
  73. package/src/views/Users.tsx +10 -4
  74. package/src/views/seo/AuditTab.tsx +6 -6
  75. package/src/views/seo/ContentTab.tsx +3 -3
  76. package/src/views/seo/RedirectsTab.tsx +8 -2
  77. package/src/views/seo/TechnicalTab.tsx +25 -13
  78. package/src/views/seo/useSeoResource.ts +21 -7
@@ -1,7 +1,8 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { useEffect, useState } from 'react';
3
+ import { useEffect, useId, useState } from 'react';
4
4
  import { RefreshCw, Map, AlertTriangle, CheckCircle2, ExternalLink } from 'lucide-react';
5
+ import * as Switch from '@radix-ui/react-switch';
5
6
  import { toast } from 'sonner';
6
7
  import { fetchSitemapStatus, regenerateSitemap, fetchCrawlSettings, updateCrawlSettings, fetchRobotsTxt, updateRobotsTxt, validateRobotsTxt, } from '../../lib/seo-service.js';
7
8
  import { SectionCard, SeoLoading, SeoErrorState, btnPrimary, btnSecondary, } from '../../components/seo/primitives.js';
@@ -33,8 +34,10 @@ const CRAWL_TOGGLES = [
33
34
  help: 'Keep thin tag archives out of the index.',
34
35
  },
35
36
  ];
36
- function Toggle({ checked, onChange, label, help, }) {
37
- return (_jsxs("label", { className: "flex items-center justify-between gap-3 py-2.5", children: [_jsxs("span", { className: "min-w-0", children: [_jsx("span", { className: "text-foreground block text-sm font-medium", children: label }), _jsx("span", { className: "text-muted-foreground block text-sm", children: help })] }), _jsx("input", { type: "checkbox", role: "switch", "aria-checked": checked, checked: checked, onChange: (e) => onChange(e.target.checked), className: "accent-primary h-4 w-4 shrink-0" })] }));
37
+ function Toggle({ checked, onChange, label, help, disabled, }) {
38
+ const labelId = useId();
39
+ const helpId = useId();
40
+ return (_jsxs("div", { className: "flex items-center justify-between gap-3 py-2.5", children: [_jsxs("span", { className: "min-w-0", children: [_jsx("span", { id: labelId, className: "text-foreground block text-sm font-medium", children: label }), _jsx("span", { id: helpId, className: "text-muted-foreground block text-sm", children: help })] }), _jsx(Switch.Root, { checked: checked, onCheckedChange: onChange, disabled: disabled, "aria-labelledby": labelId, "aria-describedby": helpId, className: "bg-input-background data-[state=checked]:bg-primary focus-visible:ring-ring relative h-[18px] w-8 shrink-0 rounded-full transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50", children: _jsx(Switch.Thumb, { className: "bg-background block h-3.5 w-3.5 translate-x-0.5 rounded-full shadow-sm transition-transform data-[state=checked]:translate-x-[14px]" }) })] }));
38
41
  }
39
42
  function SitemapCard() {
40
43
  const { data, loading, error, refetch } = useSeoResource(fetchSitemapStatus, []);
@@ -70,7 +73,7 @@ function CrawlCard() {
70
73
  refetch();
71
74
  }
72
75
  }
73
- return (_jsx(SectionCard, { title: "Crawl Settings", children: loading ? (_jsx(SeoLoading, { label: "Loading crawl settings\u2026" })) : error ? (_jsx(SeoErrorState, { message: error, onRetry: refetch })) : !settings ? (_jsx("p", { className: "text-muted-foreground text-sm", children: "Crawl settings unavailable." })) : (_jsx("div", { className: "divide-border divide-y", "aria-busy": saving, children: CRAWL_TOGGLES.map((t) => (_jsx(Toggle, { label: t.label, help: t.help, checked: settings[t.key], onChange: (v) => save({ ...settings, [t.key]: v }, t.key) }, t.key))) })) }));
76
+ return (_jsx(SectionCard, { title: "Crawl Settings", children: loading ? (_jsx(SeoLoading, { label: "Loading crawl settings\u2026" })) : error ? (_jsx(SeoErrorState, { message: error, onRetry: refetch })) : !settings ? (_jsx("p", { className: "text-muted-foreground text-sm", children: "Crawl settings unavailable." })) : (_jsx("div", { className: "divide-border divide-y", "aria-busy": saving, children: CRAWL_TOGGLES.map((t) => (_jsx(Toggle, { label: t.label, help: t.help, checked: settings[t.key], disabled: saving, onChange: (v) => save({ ...settings, [t.key]: v }, t.key) }, t.key))) })) }));
74
77
  }
75
78
  function RobotsCard() {
76
79
  const { data, loading, error, refetch } = useSeoResource(fetchRobotsTxt, []);
@@ -1 +1 @@
1
- {"version":3,"file":"TechnicalTab.js","sourceRoot":"","sources":["../../../src/views/seo/TechnicalTab.tsx"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"TechnicalTab.js","sourceRoot":"","sources":["../../../src/views/seo/TechnicalTab.tsx"],"names":[],"mappings":"AAAA,YAAY,CAAA;;AAEZ,OAAO,EAAE,SAAS,EAAE,KAAK,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAA;AAClD,OAAO,EAAE,SAAS,EAAE,GAAG,EAAE,aAAa,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,cAAc,CAAA;AACxF,OAAO,KAAK,MAAM,MAAM,wBAAwB,CAAA;AAChD,OAAO,EAAE,KAAK,EAAE,MAAM,QAAQ,CAAA;AAC9B,OAAO,EACL,kBAAkB,EAClB,iBAAiB,EACjB,kBAAkB,EAClB,mBAAmB,EACnB,cAAc,EACd,eAAe,EACf,iBAAiB,GAElB,MAAM,0BAA0B,CAAA;AACjC,OAAO,EACL,WAAW,EACX,UAAU,EACV,aAAa,EACb,UAAU,EACV,YAAY,GACb,MAAM,oCAAoC,CAAA;AAC3C,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAA;AAEpD,MAAM,aAAa,GAAgE;IACjF;QACE,GAAG,EAAE,iBAAiB;QACtB,KAAK,EAAE,mBAAmB;QAC1B,IAAI,EAAE,iDAAiD;KACxD;IACD;QACE,GAAG,EAAE,wBAAwB;QAC7B,KAAK,EAAE,2BAA2B;QAClC,IAAI,EAAE,uCAAuC;KAC9C;IACD;QACE,GAAG,EAAE,uBAAuB;QAC5B,KAAK,EAAE,0BAA0B;QACjC,IAAI,EAAE,qCAAqC;KAC5C;IACD;QACE,GAAG,EAAE,0BAA0B;QAC/B,KAAK,EAAE,6BAA6B;QACpC,IAAI,EAAE,+CAA+C;KACtD;IACD;QACE,GAAG,EAAE,iBAAiB;QACtB,KAAK,EAAE,oBAAoB;QAC3B,IAAI,EAAE,0CAA0C;KACjD;CACF,CAAA;AAED,SAAS,MAAM,CAAC,EACd,OAAO,EACP,QAAQ,EACR,KAAK,EACL,IAAI,EACJ,QAAQ,GAOT;IACC,MAAM,OAAO,GAAG,KAAK,EAAE,CAAA;IACvB,MAAM,MAAM,GAAG,KAAK,EAAE,CAAA;IACtB,OAAO,CACL,eAAK,SAAS,EAAC,gDAAgD,aAC7D,gBAAM,SAAS,EAAC,SAAS,aACvB,eAAM,EAAE,EAAE,OAAO,EAAE,SAAS,EAAC,2CAA2C,YACrE,KAAK,GACD,EACP,eAAM,EAAE,EAAE,MAAM,EAAE,SAAS,EAAC,qCAAqC,YAC9D,IAAI,GACA,IACF,EACP,KAAC,MAAM,CAAC,IAAI,IACV,OAAO,EAAE,OAAO,EAChB,eAAe,EAAE,QAAQ,EACzB,QAAQ,EAAE,QAAQ,qBACD,OAAO,sBACN,MAAM,EACxB,SAAS,EAAC,2OAA2O,YAErP,KAAC,MAAM,CAAC,KAAK,IAAC,SAAS,EAAC,qIAAqI,GAAG,GACpJ,IACV,CACP,CAAA;AACH,CAAC;AAED,SAAS,WAAW;IAClB,MAAM,EAAE,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,OAAO,EAAE,GAAG,cAAc,CAAC,kBAAkB,EAAE,EAAE,CAAC,CAAA;IAChF,MAAM,CAAC,IAAI,EAAE,OAAO,CAAC,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAA;IAEvC,KAAK,UAAU,gBAAgB;QAC7B,OAAO,CAAC,IAAI,CAAC,CAAA;QACb,MAAM,GAAG,GAAG,MAAM,iBAAiB,EAAE,CAAA;QACrC,OAAO,CAAC,KAAK,CAAC,CAAA;QACd,IAAI,GAAG,CAAC,KAAK,EAAE,CAAC;YACd,KAAK,CAAC,KAAK,CAAC,GAAG,CAAC,KAAK,CAAC,CAAA;YACtB,OAAM;QACR,CAAC;QACD,KAAK,CAAC,OAAO,CAAC,sBAAsB,CAAC,CAAA;QACrC,OAAO,EAAE,CAAA;IACX,CAAC;IAED,OAAO,CACL,KAAC,WAAW,IACV,KAAK,EAAC,SAAS,EACf,MAAM,EACJ,kBAAQ,SAAS,EAAE,YAAY,EAAE,OAAO,EAAE,gBAAgB,EAAE,QAAQ,EAAE,IAAI,IAAI,OAAO,aACnF,KAAC,SAAS,IAAC,SAAS,EAAE,WAAW,IAAI,CAAC,CAAC,CAAC,cAAc,CAAC,CAAC,CAAC,EAAE,EAAE,wBAAgB,kBAEtE,YAGV,OAAO,CAAC,CAAC,CAAC,CACT,KAAC,UAAU,IAAC,KAAK,EAAC,8BAAyB,GAAG,CAC/C,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CACV,KAAC,aAAa,IAAC,OAAO,EAAE,KAAK,EAAE,OAAO,EAAE,OAAO,GAAI,CACpD,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CACV,YAAG,SAAS,EAAC,+BAA+B,4CAAgC,CAC7E,CAAC,CAAC,CAAC,CACF,eAAK,SAAS,EAAC,WAAW,aACxB,aACE,IAAI,EAAE,IAAI,CAAC,UAAU,EACrB,MAAM,EAAC,QAAQ,EACf,GAAG,EAAC,qBAAqB,EACzB,SAAS,EAAC,qEAAqE,aAE/E,KAAC,GAAG,IAAC,SAAS,EAAC,SAAS,wBAAe,EACtC,IAAI,CAAC,UAAU,EAChB,KAAC,YAAY,IAAC,SAAS,EAAC,aAAa,wBAAe,IAClD,EACJ,cAAI,SAAS,EAAC,gCAAgC,aAC5C,0BACE,aAAI,SAAS,EAAC,uBAAuB,+BAAoB,EACzD,aAAI,SAAS,EAAC,0CAA0C,YAAE,IAAI,CAAC,aAAa,GAAM,IAC9E,EACN,0BACE,aAAI,SAAS,EAAC,uBAAuB,+BAAoB,EACzD,aAAI,SAAS,EAAC,0CAA0C,YAAE,IAAI,CAAC,aAAa,GAAM,IAC9E,EACN,eAAK,SAAS,EAAC,YAAY,aACzB,aAAI,SAAS,EAAC,uBAAuB,+BAAoB,EACzD,aAAI,SAAS,EAAC,iBAAiB,YAC5B,IAAI,CAAC,eAAe,CAAC,CAAC,CAAC,IAAI,IAAI,CAAC,IAAI,CAAC,eAAe,CAAC,CAAC,cAAc,EAAE,CAAC,CAAC,CAAC,OAAO,GAC9E,IACD,IACH,EACJ,IAAI,CAAC,QAAQ,CAAC,MAAM,GAAG,CAAC,IAAI,CAC3B,aAAI,SAAS,EAAC,WAAW,YACtB,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAC3B,cAAY,SAAS,EAAC,gDAAgD,aACpE,KAAC,aAAa,IAAC,SAAS,EAAC,aAAa,wBAAe,OAAE,CAAC,KADjD,CAAC,CAEL,CACN,CAAC,GACC,CACN,IACG,CACP,GACW,CACf,CAAA;AACH,CAAC;AAED,SAAS,SAAS;IAChB,MAAM,EAAE,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,OAAO,EAAE,GAAG,cAAc,CAAC,kBAAkB,EAAE,EAAE,CAAC,CAAA;IAChF,MAAM,CAAC,QAAQ,EAAE,WAAW,CAAC,GAAG,QAAQ,CAAuB,IAAI,CAAC,CAAA;IACpE,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAA;IAE3C,SAAS,CAAC,GAAG,EAAE;QACb,IAAI,IAAI;YAAE,WAAW,CAAC,IAAI,CAAC,CAAA;IAC7B,CAAC,EAAE,CAAC,IAAI,CAAC,CAAC,CAAA;IAEV,KAAK,UAAU,IAAI,CAAC,IAAmB,EAAE,OAA4B;QACnE,WAAW,CAAC,IAAI,CAAC,CAAA;QACjB,SAAS,CAAC,IAAI,CAAC,CAAA;QACf,MAAM,GAAG,GAAG,MAAM,mBAAmB,CAAC,EAAE,CAAC,OAAO,CAAC,EAAE,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC,CAAA;QACnE,SAAS,CAAC,KAAK,CAAC,CAAA;QAChB,IAAI,GAAG,CAAC,KAAK,EAAE,CAAC;YACd,KAAK,CAAC,KAAK,CAAC,GAAG,CAAC,KAAK,CAAC,CAAA;YACtB,OAAO,EAAE,CAAA;QACX,CAAC;IACH,CAAC;IAED,OAAO,CACL,KAAC,WAAW,IAAC,KAAK,EAAC,gBAAgB,YAChC,OAAO,CAAC,CAAC,CAAC,CACT,KAAC,UAAU,IAAC,KAAK,EAAC,8BAAyB,GAAG,CAC/C,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CACV,KAAC,aAAa,IAAC,OAAO,EAAE,KAAK,EAAE,OAAO,EAAE,OAAO,GAAI,CACpD,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,CACd,YAAG,SAAS,EAAC,+BAA+B,4CAAgC,CAC7E,CAAC,CAAC,CAAC,CACF,cAAK,SAAS,EAAC,wBAAwB,eAAY,MAAM,YACtD,aAAa,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CACxB,KAAC,MAAM,IAEL,KAAK,EAAE,CAAC,CAAC,KAAK,EACd,IAAI,EAAE,CAAC,CAAC,IAAI,EACZ,OAAO,EAAE,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,EACxB,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,EAAE,GAAG,QAAQ,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,GAAG,CAAC,IALpD,CAAC,CAAC,GAAG,CAMV,CACH,CAAC,GACE,CACP,GACW,CACf,CAAA;AACH,CAAC;AAED,SAAS,UAAU;IACjB,MAAM,EAAE,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,OAAO,EAAE,GAAG,cAAc,CAAC,cAAc,EAAE,EAAE,CAAC,CAAA;IAC5E,MAAM,CAAC,IAAI,EAAE,OAAO,CAAC,GAAG,QAAQ,CAAC,EAAE,CAAC,CAAA;IACpC,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAA;IAE3C,SAAS,CAAC,GAAG,EAAE;QACb,IAAI,IAAI;YAAE,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;IAC9B,CAAC,EAAE,CAAC,IAAI,CAAC,CAAC,CAAA;IAEV,MAAM,UAAU,GAAG,iBAAiB,CAAC,IAAI,CAAC,CAAA;IAE1C,KAAK,UAAU,UAAU;QACvB,SAAS,CAAC,IAAI,CAAC,CAAA;QACf,MAAM,GAAG,GAAG,MAAM,eAAe,CAAC,IAAI,CAAC,CAAA;QACvC,SAAS,CAAC,KAAK,CAAC,CAAA;QAChB,IAAI,GAAG,CAAC,KAAK,EAAE,CAAC;YACd,KAAK,CAAC,KAAK,CAAC,GAAG,CAAC,KAAK,CAAC,CAAA;YACtB,OAAM;QACR,CAAC;QACD,KAAK,CAAC,OAAO,CAAC,mBAAmB,CAAC,CAAA;QAClC,OAAO,EAAE,CAAA;IACX,CAAC;IAED,OAAO,CACL,KAAC,WAAW,IACV,KAAK,EAAC,YAAY,EAClB,WAAW,EAAC,+CAA+C,EAC3D,MAAM,EACJ,iBACE,SAAS,EAAE,UAAU,EACrB,OAAO,EAAE,UAAU,EACnB,QAAQ,EAAE,MAAM,IAAI,OAAO,IAAI,CAAC,UAAU,CAAC,KAAK,YAE/C,MAAM,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,MAAM,GACrB,YAGV,OAAO,CAAC,CAAC,CAAC,CACT,KAAC,UAAU,IAAC,KAAK,EAAC,0BAAqB,GAAG,CAC3C,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CACV,KAAC,aAAa,IAAC,OAAO,EAAE,KAAK,EAAE,OAAO,EAAE,OAAO,GAAI,CACpD,CAAC,CAAC,CAAC,CACF,eAAK,SAAS,EAAC,WAAW,aACvB,UAAU,CAAC,gBAAgB,IAAI,CAC9B,eACE,SAAS,EAAC,sFAAsF,EAChG,IAAI,EAAC,OAAO,aAEZ,KAAC,aAAa,IAAC,SAAS,EAAC,0CAA0C,wBAAe,EAClF,YAAG,SAAS,EAAC,0BAA0B,+HAGnC,IACA,CACP,EACD,gBAAO,SAAS,EAAC,SAAS,EAAC,OAAO,EAAC,eAAe,oCAE1C,EACR,mBACE,EAAE,EAAC,eAAe,EAClB,KAAK,EAAE,IAAI,EACX,QAAQ,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,EACxC,UAAU,EAAE,KAAK,EACjB,SAAS,EAAC,mLAAmL,GAC7L,EACD,UAAU,CAAC,MAAM,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,CAC9B,aAAI,SAAS,EAAC,WAAW,YACtB,UAAU,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAC/B,cAAY,SAAS,EAAC,oDAAoD,aACxE,KAAC,aAAa,IAAC,SAAS,EAAC,aAAa,wBAAe,OAAE,CAAC,KADjD,CAAC,CAEL,CACN,CAAC,GACC,CACN,CAAC,CAAC,CAAC,CACF,aAAG,SAAS,EAAC,gDAAgD,aAC3D,KAAC,YAAY,IAAC,SAAS,EAAC,aAAa,wBAAe,yBAClD,CACL,EACA,UAAU,CAAC,QAAQ;qBACjB,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,UAAU,CAAC,gBAAgB,IAAI,CAAC,CAAC,CAAC,QAAQ,CAAC,wBAAwB,CAAC,CAAC;qBACpF,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CACb,aAAW,SAAS,EAAC,gDAAgD,aACnE,KAAC,aAAa,IAAC,SAAS,EAAC,aAAa,wBAAe,OAAE,CAAC,KADlD,CAAC,CAEL,CACL,CAAC,IACA,CACP,GACW,CACf,CAAA;AACH,CAAC;AAED,MAAM,UAAU,YAAY;IAC1B,OAAO,CACL,eAAK,SAAS,EAAC,WAAW,aACxB,eAAK,SAAS,EAAC,uCAAuC,aACpD,KAAC,WAAW,KAAG,EACf,KAAC,SAAS,KAAG,IACT,EACN,KAAC,UAAU,KAAG,IACV,CACP,CAAA;AACH,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"useSeoResource.d.ts","sourceRoot":"","sources":["../../../src/views/seo/useSeoResource.ts"],"names":[],"mappings":"AASA,MAAM,WAAW,WAAW,CAAC,CAAC;IAC5B,IAAI,EAAE,CAAC,GAAG,IAAI,CAAA;IACd,OAAO,EAAE,OAAO,CAAA;IAChB,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;IACpB,OAAO,EAAE,MAAM,IAAI,CAAA;CACpB;AAED,wBAAgB,cAAc,CAAC,CAAC,EAC9B,OAAO,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,EACzB,IAAI,GAAE,aAAa,CAAC,OAAO,CAAM,GAChC,WAAW,CAAC,CAAC,CAAC,CAgChB"}
1
+ {"version":3,"file":"useSeoResource.d.ts","sourceRoot":"","sources":["../../../src/views/seo/useSeoResource.ts"],"names":[],"mappings":"AASA,MAAM,WAAW,WAAW,CAAC,CAAC;IAC5B,IAAI,EAAE,CAAC,GAAG,IAAI,CAAA;IACd,OAAO,EAAE,OAAO,CAAA;IAChB,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;IACpB,OAAO,EAAE,MAAM,IAAI,CAAA;CACpB;AAED,wBAAgB,cAAc,CAAC,CAAC,EAC9B,OAAO,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,EACzB,IAAI,GAAE,aAAa,CAAC,OAAO,CAAM,GAChC,WAAW,CAAC,CAAC,CAAC,CA8ChB"}
@@ -9,33 +9,46 @@ export function useSeoResource(fetcher, deps = []) {
9
9
  const [data, setData] = useState(null);
10
10
  const [loading, setLoading] = useState(true);
11
11
  const [error, setError] = useState(null);
12
- const aliveRef = useRef(true);
12
+ // Tracks whether the component is still mounted (guards setState after
13
+ // unmount). Set once on mount; never toggled by dependency-driven reloads.
14
+ const mountedRef = useRef(true);
15
+ // Monotonic id of the most recently *started* request. A response is only
16
+ // applied when its id still matches — otherwise a slow earlier request that
17
+ // resolves after a faster later one (rapid filter switches) would clobber
18
+ // the newer, correct data.
19
+ const requestIdRef = useRef(0);
13
20
  // eslint-disable-next-line react-hooks/exhaustive-deps
14
21
  const stableFetcher = useCallback(fetcher, deps);
15
22
  const load = useCallback(async () => {
23
+ const requestId = ++requestIdRef.current;
24
+ const isCurrent = () => mountedRef.current && requestId === requestIdRef.current;
16
25
  setLoading(true);
17
26
  setError(null);
18
27
  try {
19
28
  const result = await stableFetcher();
20
- if (aliveRef.current)
29
+ if (isCurrent())
21
30
  setData(result);
22
31
  }
23
32
  catch (err) {
24
- if (aliveRef.current) {
33
+ if (isCurrent()) {
25
34
  setError(err instanceof Error ? err.message : 'Failed to load data');
26
35
  }
27
36
  }
28
37
  finally {
29
- if (aliveRef.current)
38
+ if (isCurrent())
30
39
  setLoading(false);
31
40
  }
32
41
  }, [stableFetcher]);
42
+ // Mount/unmount guard only — independent of `load` so a dependency change
43
+ // (which recreates `load`) never flips `mountedRef` to false mid-flight.
33
44
  useEffect(() => {
34
- aliveRef.current = true;
35
- load();
45
+ mountedRef.current = true;
36
46
  return () => {
37
- aliveRef.current = false;
47
+ mountedRef.current = false;
38
48
  };
49
+ }, []);
50
+ useEffect(() => {
51
+ load();
39
52
  }, [load]);
40
53
  return { data, loading, error, refetch: load };
41
54
  }
@@ -1 +1 @@
1
- {"version":3,"file":"useSeoResource.js","sourceRoot":"","sources":["../../../src/views/seo/useSeoResource.ts"],"names":[],"mappings":"AAAA,YAAY,CAAA;AAEZ;;;;GAIG;AACH,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAA;AAShE,MAAM,UAAU,cAAc,CAC5B,OAAyB,EACzB,OAA+B,EAAE;IAEjC,MAAM,CAAC,IAAI,EAAE,OAAO,CAAC,GAAG,QAAQ,CAAW,IAAI,CAAC,CAAA;IAChD,MAAM,CAAC,OAAO,EAAE,UAAU,CAAC,GAAG,QAAQ,CAAC,IAAI,CAAC,CAAA;IAC5C,MAAM,CAAC,KAAK,EAAE,QAAQ,CAAC,GAAG,QAAQ,CAAgB,IAAI,CAAC,CAAA;IACvD,MAAM,QAAQ,GAAG,MAAM,CAAC,IAAI,CAAC,CAAA;IAC7B,uDAAuD;IACvD,MAAM,aAAa,GAAG,WAAW,CAAC,OAAO,EAAE,IAAI,CAAC,CAAA;IAEhD,MAAM,IAAI,GAAG,WAAW,CAAC,KAAK,IAAI,EAAE;QAClC,UAAU,CAAC,IAAI,CAAC,CAAA;QAChB,QAAQ,CAAC,IAAI,CAAC,CAAA;QACd,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,aAAa,EAAE,CAAA;YACpC,IAAI,QAAQ,CAAC,OAAO;gBAAE,OAAO,CAAC,MAAM,CAAC,CAAA;QACvC,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,IAAI,QAAQ,CAAC,OAAO,EAAE,CAAC;gBACrB,QAAQ,CAAC,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,qBAAqB,CAAC,CAAA;YACtE,CAAC;QACH,CAAC;gBAAS,CAAC;YACT,IAAI,QAAQ,CAAC,OAAO;gBAAE,UAAU,CAAC,KAAK,CAAC,CAAA;QACzC,CAAC;IACH,CAAC,EAAE,CAAC,aAAa,CAAC,CAAC,CAAA;IAEnB,SAAS,CAAC,GAAG,EAAE;QACb,QAAQ,CAAC,OAAO,GAAG,IAAI,CAAA;QACvB,IAAI,EAAE,CAAA;QACN,OAAO,GAAG,EAAE;YACV,QAAQ,CAAC,OAAO,GAAG,KAAK,CAAA;QAC1B,CAAC,CAAA;IACH,CAAC,EAAE,CAAC,IAAI,CAAC,CAAC,CAAA;IAEV,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,CAAA;AAChD,CAAC"}
1
+ {"version":3,"file":"useSeoResource.js","sourceRoot":"","sources":["../../../src/views/seo/useSeoResource.ts"],"names":[],"mappings":"AAAA,YAAY,CAAA;AAEZ;;;;GAIG;AACH,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAA;AAShE,MAAM,UAAU,cAAc,CAC5B,OAAyB,EACzB,OAA+B,EAAE;IAEjC,MAAM,CAAC,IAAI,EAAE,OAAO,CAAC,GAAG,QAAQ,CAAW,IAAI,CAAC,CAAA;IAChD,MAAM,CAAC,OAAO,EAAE,UAAU,CAAC,GAAG,QAAQ,CAAC,IAAI,CAAC,CAAA;IAC5C,MAAM,CAAC,KAAK,EAAE,QAAQ,CAAC,GAAG,QAAQ,CAAgB,IAAI,CAAC,CAAA;IACvD,uEAAuE;IACvE,2EAA2E;IAC3E,MAAM,UAAU,GAAG,MAAM,CAAC,IAAI,CAAC,CAAA;IAC/B,0EAA0E;IAC1E,4EAA4E;IAC5E,0EAA0E;IAC1E,2BAA2B;IAC3B,MAAM,YAAY,GAAG,MAAM,CAAC,CAAC,CAAC,CAAA;IAC9B,uDAAuD;IACvD,MAAM,aAAa,GAAG,WAAW,CAAC,OAAO,EAAE,IAAI,CAAC,CAAA;IAEhD,MAAM,IAAI,GAAG,WAAW,CAAC,KAAK,IAAI,EAAE;QAClC,MAAM,SAAS,GAAG,EAAE,YAAY,CAAC,OAAO,CAAA;QACxC,MAAM,SAAS,GAAG,GAAG,EAAE,CAAC,UAAU,CAAC,OAAO,IAAI,SAAS,KAAK,YAAY,CAAC,OAAO,CAAA;QAChF,UAAU,CAAC,IAAI,CAAC,CAAA;QAChB,QAAQ,CAAC,IAAI,CAAC,CAAA;QACd,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,aAAa,EAAE,CAAA;YACpC,IAAI,SAAS,EAAE;gBAAE,OAAO,CAAC,MAAM,CAAC,CAAA;QAClC,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,IAAI,SAAS,EAAE,EAAE,CAAC;gBAChB,QAAQ,CAAC,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,qBAAqB,CAAC,CAAA;YACtE,CAAC;QACH,CAAC;gBAAS,CAAC;YACT,IAAI,SAAS,EAAE;gBAAE,UAAU,CAAC,KAAK,CAAC,CAAA;QACpC,CAAC;IACH,CAAC,EAAE,CAAC,aAAa,CAAC,CAAC,CAAA;IAEnB,0EAA0E;IAC1E,yEAAyE;IACzE,SAAS,CAAC,GAAG,EAAE;QACb,UAAU,CAAC,OAAO,GAAG,IAAI,CAAA;QACzB,OAAO,GAAG,EAAE;YACV,UAAU,CAAC,OAAO,GAAG,KAAK,CAAA;QAC5B,CAAC,CAAA;IACH,CAAC,EAAE,EAAE,CAAC,CAAA;IAEN,SAAS,CAAC,GAAG,EAAE;QACb,IAAI,EAAE,CAAA;IACR,CAAC,EAAE,CAAC,IAAI,CAAC,CAAC,CAAA;IAEV,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,CAAA;AAChD,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@actuate-media/cms-admin",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/actuate-media/actuatecms.git",
@@ -87,7 +87,7 @@
87
87
  "tailwindcss": "^4.0.0",
88
88
  "typescript": "^5.7.0",
89
89
  "vitest": "^3.0.0",
90
- "@actuate-media/cms-core": "0.29.0",
90
+ "@actuate-media/cms-core": "0.30.0",
91
91
  "@actuate-media/component-blocks": "0.2.0"
92
92
  },
93
93
  "scripts": {
package/src/AdminRoot.tsx CHANGED
@@ -13,8 +13,6 @@ import { Settings } from './views/Settings.js'
13
13
  import { Forms } from './views/Forms.js'
14
14
  import { FormEditor } from './views/FormEditor.js'
15
15
  import { FormSubmissions } from './views/FormSubmissions.js'
16
- import { Users } from './views/Users.js'
17
- import { ApiKeys } from './views/ApiKeys.js'
18
16
  import { SEO } from './views/SEO.js'
19
17
  import { ScriptTags } from './views/ScriptTags.js'
20
18
  import { ScriptTagEditor } from './views/ScriptTagEditor.js'
@@ -419,12 +417,14 @@ function AdminShell({
419
417
  return <Widgets onNavigate={navigate} />
420
418
  }
421
419
 
420
+ // Users + API Keys moved into the Settings view as tabs. The legacy
421
+ // top-level routes still resolve — they just land on the matching tab.
422
422
  if (matchRoute('/users')) {
423
- return <Users onNavigate={navigate} />
423
+ return <Settings onNavigate={navigate} config={config} initialTab="users" />
424
424
  }
425
425
 
426
426
  if (matchRoute('/api-keys')) {
427
- return <ApiKeys onNavigate={navigate} />
427
+ return <Settings onNavigate={navigate} config={config} initialTab="api-keys" />
428
428
  }
429
429
 
430
430
  if (matchRoute('/settings')) {
@@ -156,9 +156,9 @@ describe('buildNavItems', () => {
156
156
  // Lands after the static Content destinations, before Settings.
157
157
  const menusIdx = items.findIndex((i) => i.path === '/navigations')
158
158
  const scriptIdx = items.findIndex((i) => i.path === '/script-tags')
159
- const usersIdx = items.findIndex((i) => i.path === '/users')
159
+ const settingsIdx = items.findIndex((i) => i.path === '/settings')
160
160
  expect(menusIdx).toBeGreaterThan(scriptIdx)
161
- expect(menusIdx).toBeLessThan(usersIdx)
161
+ expect(menusIdx).toBeLessThan(settingsIdx)
162
162
  })
163
163
 
164
164
  it('keeps standard Content/Settings entries after collections', () => {
@@ -172,8 +172,10 @@ describe('buildNavItems', () => {
172
172
  expect(paths).toContain('/forms')
173
173
  expect(paths).toContain('/seo')
174
174
  expect(paths).toContain('/script-tags')
175
- expect(paths).toContain('/users')
176
- expect(paths).toContain('/api-keys')
177
175
  expect(paths).toContain('/settings')
176
+ // Users + API Keys moved into Settings tabs — they are no longer
177
+ // standalone sidebar destinations.
178
+ expect(paths).not.toContain('/users')
179
+ expect(paths).not.toContain('/api-keys')
178
180
  })
179
181
  })
@@ -0,0 +1,60 @@
1
+ // @vitest-environment node
2
+ import { afterEach, describe, expect, it, vi } from 'vitest'
3
+
4
+ const cmsApi = vi.fn()
5
+ vi.mock('../../lib/api.js', () => ({
6
+ cmsApi: (...args: unknown[]) => cmsApi(...args),
7
+ }))
8
+
9
+ const {
10
+ fetchSeoOverview,
11
+ fetchSeoContentRecords,
12
+ fetchSeoIssues,
13
+ fetchRedirectRules,
14
+ updateSeoFields,
15
+ } = await import('../../lib/seo-service.js')
16
+
17
+ afterEach(() => {
18
+ cmsApi.mockReset()
19
+ })
20
+
21
+ /**
22
+ * Regression tests for the "swallowed read errors" blocker. `cmsApi` never
23
+ * throws — it returns `{ error }` on failure — so the SEO read fetchers must
24
+ * re-throw, otherwise `useSeoResource` can't distinguish "request failed" from
25
+ * "empty result" and renders a misleading fallback (e.g. a fake score of 0 /
26
+ * grade "Critical") with the error/retry UI permanently unreachable.
27
+ */
28
+ describe('SEO service read fetchers surface errors', () => {
29
+ it('fetchSeoOverview throws when the request fails (instead of returning a fake 0 score)', async () => {
30
+ cmsApi.mockResolvedValue({ error: 'Request failed (500)', status: 500 })
31
+ await expect(fetchSeoOverview()).rejects.toThrow('Request failed (500)')
32
+ })
33
+
34
+ it('fetchSeoContentRecords throws on error', async () => {
35
+ cmsApi.mockResolvedValue({ error: 'Network error', status: 0 })
36
+ await expect(fetchSeoContentRecords()).rejects.toThrow('Network error')
37
+ })
38
+
39
+ it('fetchSeoIssues throws on error', async () => {
40
+ cmsApi.mockResolvedValue({ error: 'Request failed (503)', status: 503 })
41
+ await expect(fetchSeoIssues()).rejects.toThrow('Request failed (503)')
42
+ })
43
+
44
+ it('fetchRedirectRules throws on error', async () => {
45
+ cmsApi.mockResolvedValue({ error: 'boom', status: 500 })
46
+ await expect(fetchRedirectRules()).rejects.toThrow('boom')
47
+ })
48
+
49
+ it('returns data normally on success', async () => {
50
+ cmsApi.mockResolvedValue({ data: { records: [{ id: 'r1' }] }, status: 200 })
51
+ await expect(fetchSeoContentRecords()).resolves.toEqual([{ id: 'r1' }])
52
+ })
53
+
54
+ it('mutations still return { error } rather than throwing', async () => {
55
+ cmsApi.mockResolvedValue({ error: 'forbidden', status: 403 })
56
+ await expect(updateSeoFields('page', 'doc-1', { metaTitle: 'x' })).resolves.toEqual({
57
+ error: 'forbidden',
58
+ })
59
+ })
60
+ })
@@ -0,0 +1,68 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, expect, it } from 'vitest'
3
+ import { act, render, screen, waitFor } from '@testing-library/react'
4
+
5
+ import { useSeoResource } from '../../views/seo/useSeoResource.js'
6
+
7
+ /**
8
+ * Regression test for the `useSeoResource` stale-response race: when the
9
+ * dependency (and therefore the fetcher) changes rapidly, a slow earlier
10
+ * request that resolves AFTER a faster later one must not clobber the newer
11
+ * result. We drive this with two deferred promises whose resolution order is
12
+ * inverted relative to the request order.
13
+ */
14
+
15
+ function Probe({ dep, fetcher }: { dep: number; fetcher: () => Promise<string> }) {
16
+ const { data, loading, error } = useSeoResource<string>(fetcher, [dep])
17
+ return (
18
+ <div>
19
+ <span data-testid="state">{loading ? 'loading' : (error ?? data ?? 'empty')}</span>
20
+ </div>
21
+ )
22
+ }
23
+
24
+ function deferred<T>() {
25
+ let resolve!: (value: T) => void
26
+ let reject!: (reason?: unknown) => void
27
+ const promise = new Promise<T>((res, rej) => {
28
+ resolve = res
29
+ reject = rej
30
+ })
31
+ return { promise, resolve, reject }
32
+ }
33
+
34
+ describe('useSeoResource', () => {
35
+ it('ignores a stale (earlier) response that resolves after the latest one', async () => {
36
+ const first = deferred<string>()
37
+ const second = deferred<string>()
38
+
39
+ // First render uses the "first" fetcher (slow), then we switch deps so the
40
+ // hook re-runs with the "second" fetcher (fast).
41
+ const firstFetcher = () => first.promise
42
+ const secondFetcher = () => second.promise
43
+
44
+ const { rerender } = render(<Probe dep={1} fetcher={firstFetcher} />)
45
+ rerender(<Probe dep={2} fetcher={secondFetcher} />)
46
+
47
+ // The newer request resolves FIRST.
48
+ await act(async () => {
49
+ second.resolve('NEW')
50
+ })
51
+ await waitFor(() => expect(screen.getByTestId('state').textContent).toBe('NEW'))
52
+
53
+ // The stale earlier request resolves LATER — it must be ignored.
54
+ await act(async () => {
55
+ first.resolve('OLD')
56
+ })
57
+ expect(screen.getByTestId('state').textContent).toBe('NEW')
58
+ })
59
+
60
+ it('surfaces fetcher errors', async () => {
61
+ const d = deferred<string>()
62
+ render(<Probe dep={1} fetcher={() => d.promise} />)
63
+ await act(async () => {
64
+ d.reject(new Error('boom'))
65
+ })
66
+ await waitFor(() => expect(screen.getByTestId('state').textContent).toBe('boom'))
67
+ })
68
+ })
@@ -36,7 +36,10 @@ export interface BreadcrumbsProps {
36
36
  }
37
37
 
38
38
  export function Breadcrumbs({ currentPath, onNavigate }: BreadcrumbsProps) {
39
- const segments = currentPath.split('/').filter(Boolean)
39
+ // Strip query string + hash so tab-driven routes (e.g. `/settings?tab=users`,
40
+ // `/seo?tab=audit`) don't leak the raw query into a crumb label.
41
+ const cleanPath = currentPath.split('?')[0]!.split('#')[0]!
42
+ const segments = cleanPath.split('/').filter(Boolean)
40
43
 
41
44
  if (segments.length === 0) return null
42
45
 
@@ -31,6 +31,10 @@ export function Drawer({
31
31
  <Dialog.Portal>
32
32
  <Dialog.Overlay className="bg-foreground/40 motion-safe:animate-in motion-safe:fade-in fixed inset-0 z-50" />
33
33
  <Dialog.Content
34
+ // Radix auto-associates `aria-describedby` with the Dialog.Description
35
+ // when one is present; when it isn't, explicitly clear the attribute so
36
+ // Radix doesn't emit its "Missing Description" console warning.
37
+ {...(description ? {} : { 'aria-describedby': undefined })}
34
38
  className={`bg-card text-foreground fixed inset-y-0 right-0 z-50 flex w-full ${width} motion-safe:animate-in motion-safe:slide-in-from-right flex-col shadow-xl outline-none`}
35
39
  >
36
40
  <header className="border-border flex items-start justify-between gap-3 border-b px-5 py-4">
@@ -1,6 +1,6 @@
1
1
  'use client'
2
2
 
3
- import { useEffect, useState } from 'react'
3
+ import { cloneElement, isValidElement, useEffect, useId, useState, type ReactElement } from 'react'
4
4
  import { Sparkles, Check } from 'lucide-react'
5
5
  import { toast } from 'sonner'
6
6
  import {
@@ -36,13 +36,25 @@ function Field({
36
36
  children: React.ReactNode
37
37
  hint?: React.ReactNode
38
38
  }) {
39
+ // Associate the visible label with its control for screen readers. We inject
40
+ // a generated id onto the single child input/textarea (unless it already has
41
+ // one) and point the <label htmlFor> at it.
42
+ const generatedId = useId()
43
+ let control = children
44
+ if (isValidElement(children) && (children.props as { id?: string }).id == null) {
45
+ control = cloneElement(children as ReactElement<{ id?: string }>, { id: generatedId })
46
+ }
47
+ const controlId = isValidElement(control) ? (control.props as { id?: string }).id : undefined
48
+
39
49
  return (
40
50
  <div className="space-y-1.5">
41
51
  <div className="flex items-center justify-between">
42
- <label className="text-foreground text-sm font-medium">{label}</label>
52
+ <label htmlFor={controlId} className="text-foreground text-sm font-medium">
53
+ {label}
54
+ </label>
43
55
  {hint}
44
56
  </div>
45
- {children}
57
+ {control}
46
58
  </div>
47
59
  )
48
60
  }
@@ -112,13 +112,16 @@ export function SeoStatusBadge({
112
112
  export function SeoScoreBar({ score, className = '' }: { score: number; className?: string }) {
113
113
  const grade = gradeFromScore(score)
114
114
  const fill = grade === 'good' ? 'bg-success' : grade === 'fair' ? 'bg-warning' : 'bg-destructive'
115
+ const clamped = Math.max(0, Math.min(100, Math.round(score)))
115
116
  return (
116
117
  <div
117
118
  className={`bg-muted h-2 w-full overflow-hidden rounded-full ${className}`}
118
119
  role="progressbar"
119
- aria-valuenow={score}
120
+ aria-label="SEO score"
121
+ aria-valuenow={clamped}
120
122
  aria-valuemin={0}
121
123
  aria-valuemax={100}
124
+ aria-valuetext={`${clamped} out of 100`}
122
125
  >
123
126
  <div
124
127
  className={`h-full rounded-full transition-[width] duration-300 motion-reduce:transition-none ${fill}`}
@@ -9,7 +9,6 @@ import {
9
9
  Settings,
10
10
  ChevronLeft,
11
11
  ClipboardList,
12
- Users,
13
12
  Search as SearchIcon,
14
13
  Briefcase,
15
14
  FolderOpen,
@@ -22,7 +21,6 @@ import {
22
21
  LayoutGrid,
23
22
  Code2,
24
23
  Library,
25
- KeyRound,
26
24
  ListTree,
27
25
  Menu as MenuIcon,
28
26
  PanelLeftClose,
@@ -156,8 +154,6 @@ const defaultNavItems: NavItem[] = [
156
154
  { path: '/forms', label: 'Forms', icon: ClipboardList, group: 'Content' },
157
155
  { path: '/seo', label: 'SEO', icon: SearchIcon, group: 'Content' },
158
156
  { 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
157
  { path: '/settings', label: 'Settings', icon: Settings, group: 'Settings' },
162
158
  ]
163
159
 
@@ -460,12 +456,10 @@ export function buildNavItems(config: any): NavItem[] {
460
456
  }
461
457
  for (const bucket of groupBuckets.values()) items.push(...bucket)
462
458
 
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
- )
459
+ // Settings destinations — always last. Users + API Keys now live inside
460
+ // the Settings view as tabs (see Settings.tsx), so only Settings is anchored
461
+ // here.
462
+ items.push({ path: '/settings', label: 'Settings', icon: Settings, group: 'Settings' })
469
463
 
470
464
  return items
471
465
  }
@@ -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
  }