@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
@@ -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>
@@ -20,21 +20,36 @@ import {
20
20
  RefreshCw,
21
21
  GitPullRequest,
22
22
  Layers,
23
+ Users as UsersIcon,
24
+ KeyRound,
23
25
  } from 'lucide-react'
24
- import { useState, useEffect } from 'react'
26
+ import { useState, useEffect, useCallback, useMemo } from 'react'
25
27
  import { toast } from 'sonner'
26
28
  import { useApiData } from '../lib/useApiData.js'
27
29
  import { cmsApi } from '../lib/api.js'
28
30
  import { useTheme } from '../components/ThemeProvider.js'
29
31
  import { RelationshipField } from '../fields/RelationshipField.js'
30
32
  import { SEOConfigPanel } from '../components/SEOConfigPanel.js'
33
+ import { Users as UsersView } from './Users.js'
34
+ import { ApiKeys as ApiKeysView } from './ApiKeys.js'
31
35
 
32
36
  export interface SettingsProps {
33
37
  onNavigate?: (path: string) => void
34
38
  config?: any
39
+ /** Initial tab when arriving without a `?tab=` query param (e.g. via the
40
+ * legacy `/users` or `/api-keys` routes). */
41
+ initialTab?: string
35
42
  }
36
43
 
37
- export function Settings({ config, ..._props }: SettingsProps = {}) {
44
+ /** Tabs that drive their own persistence (no global "Save Changes" footer). */
45
+ const SELF_MANAGED_TABS = new Set(['users', 'api-keys'])
46
+
47
+ function readSettingsTabFromUrl(): string | null {
48
+ if (typeof window === 'undefined') return null
49
+ return new URLSearchParams(window.location.search).get('tab')
50
+ }
51
+
52
+ export function Settings({ config, onNavigate, initialTab = 'general' }: SettingsProps = {}) {
38
53
  const { data, loading, error, refetch } = useApiData<any>('/globals/settings')
39
54
 
40
55
  const [siteTitle, setSiteTitle] = useState('My CMS')
@@ -48,7 +63,7 @@ export function Settings({ config, ..._props }: SettingsProps = {}) {
48
63
  const [twoFactorEnabled, setTwoFactorEnabled] = useState(false)
49
64
  const [sessionTimeout, setSessionTimeout] = useState(false)
50
65
  const [ipWhitelist, setIpWhitelist] = useState(false)
51
- const [activeTab, setActiveTab] = useState('general')
66
+ const [activeTab, setActiveTab] = useState<string>(() => readSettingsTabFromUrl() ?? initialTab)
52
67
  const [saving, setSaving] = useState(false)
53
68
 
54
69
  // Layout defaults
@@ -64,6 +79,50 @@ export function Settings({ config, ..._props }: SettingsProps = {}) {
64
79
  : []
65
80
  const hasLayoutRegions = layoutRegions.length > 0
66
81
 
82
+ // Valid tab ids, with `layout` only available when the project configures
83
+ // layout regions. Used to normalise deep links and keep the URL honest.
84
+ const availableTabs = useMemo(
85
+ () =>
86
+ [
87
+ 'general',
88
+ 'appearance',
89
+ ...(hasLayoutRegions ? ['layout'] : []),
90
+ 'security',
91
+ 'users',
92
+ 'api-keys',
93
+ 'seo',
94
+ 'ai',
95
+ 'updates',
96
+ ] as const,
97
+ [hasLayoutRegions],
98
+ )
99
+ const normalizeTab = useCallback(
100
+ (tab: string | null | undefined): string =>
101
+ tab && (availableTabs as readonly string[]).includes(tab) ? tab : 'general',
102
+ [availableTabs],
103
+ )
104
+
105
+ // Drive the active tab from the `?tab=` query param so tabs are deep-linkable
106
+ // and survive browser back/forward (the legacy /users and /api-keys routes
107
+ // land here via `initialTab`).
108
+ useEffect(() => {
109
+ function sync() {
110
+ setActiveTab(normalizeTab(readSettingsTabFromUrl() ?? initialTab))
111
+ }
112
+ sync()
113
+ window.addEventListener('popstate', sync)
114
+ return () => window.removeEventListener('popstate', sync)
115
+ }, [normalizeTab, initialTab])
116
+
117
+ const changeTab = useCallback(
118
+ (tab: string) => {
119
+ const next = normalizeTab(tab)
120
+ setActiveTab(next)
121
+ onNavigate?.(`/settings?tab=${next}`)
122
+ },
123
+ [normalizeTab, onNavigate],
124
+ )
125
+
67
126
  // AI settings
68
127
  const [aiProvider, setAiProvider] = useState('anthropic')
69
128
  const [aiApiKey, setAiApiKey] = useState('')
@@ -176,7 +235,7 @@ export function Settings({ config, ..._props }: SettingsProps = {}) {
176
235
  <p className="text-sm text-gray-600">Manage your CMS configuration</p>
177
236
  </div>
178
237
 
179
- <Tabs.Root value={activeTab} onValueChange={setActiveTab}>
238
+ <Tabs.Root value={activeTab} onValueChange={changeTab}>
180
239
  <Tabs.List className="mb-4 flex gap-1 overflow-x-auto border-b border-gray-200">
181
240
  <Tabs.Trigger value="general" className={tabTriggerClass}>
182
241
  General
@@ -195,6 +254,18 @@ export function Settings({ config, ..._props }: SettingsProps = {}) {
195
254
  <Tabs.Trigger value="security" className={tabTriggerClass}>
196
255
  Security
197
256
  </Tabs.Trigger>
257
+ <Tabs.Trigger value="users" className={tabTriggerClass}>
258
+ <span className="flex items-center gap-1.5">
259
+ <UsersIcon className="h-4 w-4" />
260
+ Users
261
+ </span>
262
+ </Tabs.Trigger>
263
+ <Tabs.Trigger value="api-keys" className={tabTriggerClass}>
264
+ <span className="flex items-center gap-1.5">
265
+ <KeyRound className="h-4 w-4" />
266
+ API Keys
267
+ </span>
268
+ </Tabs.Trigger>
198
269
  <Tabs.Trigger value="seo" className={tabTriggerClass}>
199
270
  SEO
200
271
  </Tabs.Trigger>
@@ -204,9 +275,6 @@ export function Settings({ config, ..._props }: SettingsProps = {}) {
204
275
  AI
205
276
  </span>
206
277
  </Tabs.Trigger>
207
- <Tabs.Trigger value="integrations" className={tabTriggerClass}>
208
- Integrations
209
- </Tabs.Trigger>
210
278
  <Tabs.Trigger value="updates" className={tabTriggerClass}>
211
279
  <span className="flex items-center gap-1.5">
212
280
  <Download className="h-4 w-4" />
@@ -628,29 +696,12 @@ export function Settings({ config, ..._props }: SettingsProps = {}) {
628
696
  </div>
629
697
  </Tabs.Content>
630
698
 
631
- <Tabs.Content value="integrations" className="space-y-4">
632
- <div className="rounded-lg border border-gray-200 bg-white p-4">
633
- <h3 className="mb-4 text-sm font-semibold text-gray-900">API Keys</h3>
634
- <div className="space-y-4">
635
- <div>
636
- <label className="mb-1 block text-sm font-medium text-gray-700">API Key</label>
637
- <div className="flex gap-2">
638
- <input
639
- type="text"
640
- value="•••••••••••••••••••••••••"
641
- readOnly
642
- className="flex-1 rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 text-sm"
643
- />
644
- <button
645
- type="button"
646
- className="rounded-lg border border-gray-300 px-4 py-2 text-sm transition-colors hover:bg-gray-50"
647
- >
648
- Regenerate
649
- </button>
650
- </div>
651
- </div>
652
- </div>
653
- </div>
699
+ <Tabs.Content value="users">
700
+ <UsersView onNavigate={onNavigate} embedded />
701
+ </Tabs.Content>
702
+
703
+ <Tabs.Content value="api-keys">
704
+ <ApiKeysView onNavigate={onNavigate} embedded />
654
705
  </Tabs.Content>
655
706
 
656
707
  <Tabs.Content value="updates" className="space-y-4">
@@ -658,16 +709,18 @@ export function Settings({ config, ..._props }: SettingsProps = {}) {
658
709
  </Tabs.Content>
659
710
  </Tabs.Root>
660
711
 
661
- <div className="mt-6 flex justify-end">
662
- <button
663
- type="button"
664
- onClick={handleSave}
665
- disabled={saving}
666
- className="rounded-lg bg-blue-600 px-6 py-2 text-sm text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
667
- >
668
- {saving ? 'Saving...' : 'Save Changes'}
669
- </button>
670
- </div>
712
+ {!SELF_MANAGED_TABS.has(activeTab) && (
713
+ <div className="mt-6 flex justify-end">
714
+ <button
715
+ type="button"
716
+ onClick={handleSave}
717
+ disabled={saving}
718
+ className="rounded-lg bg-blue-600 px-6 py-2 text-sm text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
719
+ >
720
+ {saving ? 'Saving...' : 'Save Changes'}
721
+ </button>
722
+ </div>
723
+ )}
671
724
  </div>
672
725
  )
673
726
  }
@@ -22,9 +22,12 @@ type UserSortKey = 'name' | 'role' | 'status' | 'lastLogin'
22
22
 
23
23
  export interface UsersProps {
24
24
  onNavigate?: (path: string) => void
25
+ /** When rendered inside the Settings tab shell, drop the page padding and
26
+ * demote the heading so it reads as a section rather than a second page. */
27
+ embedded?: boolean
25
28
  }
26
29
 
27
- export function Users({ onNavigate }: UsersProps) {
30
+ export function Users({ onNavigate, embedded = false }: UsersProps = {}) {
28
31
  const { data, loading, error, refetch } = useApiData<any[]>('/users')
29
32
  const [showInviteDialog, setShowInviteDialog] = useState(false)
30
33
  const [inviteEmail, setInviteEmail] = useState('')
@@ -98,16 +101,19 @@ export function Users({ onNavigate }: UsersProps) {
98
101
  )
99
102
  }
100
103
 
104
+ const rootPadding = embedded ? '' : 'p-3 pr-6 sm:p-4 sm:pr-8'
105
+ const Heading = embedded ? 'h2' : 'h1'
106
+
101
107
  if (loading) {
102
108
  return (
103
- <div className="flex h-64 items-center justify-center p-3 pr-6 sm:p-4 sm:pr-8">
109
+ <div className={`flex h-64 items-center justify-center ${rootPadding}`}>
104
110
  <Loader2 className="h-6 w-6 animate-spin text-blue-600" />
105
111
  </div>
106
112
  )
107
113
  }
108
114
 
109
115
  return (
110
- <div className="p-3 pr-6 sm:p-4 sm:pr-8">
116
+ <div className={rootPadding}>
111
117
  {error && (
112
118
  <div className="mb-4 flex items-center gap-3 rounded-lg border border-red-200 bg-red-50 p-3">
113
119
  <AlertTriangle className="h-5 w-5 shrink-0 text-red-600" />
@@ -123,7 +129,7 @@ export function Users({ onNavigate }: UsersProps) {
123
129
 
124
130
  <div className="mb-4 flex items-center justify-between">
125
131
  <div>
126
- <h1 className="mb-1 text-2xl font-semibold text-gray-900">Users</h1>
132
+ <Heading className="mb-1 text-2xl font-semibold text-gray-900">Users</Heading>
127
133
  <p className="text-sm text-gray-600">{filteredAndSorted.length} total users</p>
128
134
  </div>
129
135
  <button
@@ -111,12 +111,12 @@ export function AuditTab({
111
111
 
112
112
  <SectionCard>
113
113
  <div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
114
- <div className="flex flex-wrap gap-1" role="tablist" aria-label="Filter by severity">
114
+ <div className="flex flex-wrap gap-1" role="group" aria-label="Filter by severity">
115
115
  {SEVERITY_FILTERS.map((f) => (
116
116
  <button
117
117
  key={f.id}
118
- role="tab"
119
- aria-selected={severity === f.id}
118
+ type="button"
119
+ aria-pressed={severity === f.id}
120
120
  onClick={() => setSeverity(f.id)}
121
121
  className={`focus-visible:ring-ring rounded-md px-3 py-1.5 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none ${
122
122
  severity === f.id
@@ -128,12 +128,12 @@ export function AuditTab({
128
128
  </button>
129
129
  ))}
130
130
  </div>
131
- <div className="flex flex-wrap gap-1" role="tablist" aria-label="Filter by status">
131
+ <div className="flex flex-wrap gap-1" role="group" aria-label="Filter by status">
132
132
  {STATUS_FILTERS.map((f) => (
133
133
  <button
134
134
  key={f.id}
135
- role="tab"
136
- aria-selected={status === f.id}
135
+ type="button"
136
+ aria-pressed={status === f.id}
137
137
  onClick={() => setStatus(f.id)}
138
138
  className={`focus-visible:ring-ring rounded-md px-3 py-1.5 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none ${
139
139
  status === f.id
@@ -62,12 +62,12 @@ export function ContentTab({
62
62
  aria-label="Search content"
63
63
  />
64
64
  </div>
65
- <div className="flex gap-1" role="tablist" aria-label="Content type filter">
65
+ <div className="flex gap-1" role="group" aria-label="Content type filter">
66
66
  {FILTERS.map((f) => (
67
67
  <button
68
68
  key={f.id}
69
- role="tab"
70
- aria-selected={filter === f.id}
69
+ type="button"
70
+ aria-pressed={filter === f.id}
71
71
  onClick={() => setFilter(f.id)}
72
72
  className={`focus-visible:ring-ring rounded-md px-3 py-1.5 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none ${
73
73
  filter === f.id
@@ -238,11 +238,17 @@ export function RedirectsTab() {
238
238
  const res = await exportRedirectsCsv()
239
239
  if (res.error || !res.csv) return toast.error(res.error ?? 'Export failed.')
240
240
  const blob = new Blob([res.csv], { type: 'text/csv' })
241
+ const url = URL.createObjectURL(blob)
241
242
  const link = document.createElement('a')
242
- link.href = URL.createObjectURL(blob)
243
+ link.href = url
243
244
  link.download = 'redirects.csv'
245
+ // Firefox requires the anchor to be in the document for programmatic clicks.
246
+ document.body.appendChild(link)
244
247
  link.click()
245
- URL.revokeObjectURL(link.href)
248
+ document.body.removeChild(link)
249
+ // Defer revocation so the browser has a chance to start the download —
250
+ // revoking synchronously can cancel it before it begins.
251
+ setTimeout(() => URL.revokeObjectURL(url), 0)
246
252
  }
247
253
 
248
254
  async function handleImportFile(file: File) {
@@ -1,7 +1,8 @@
1
1
  'use client'
2
2
 
3
- import { useEffect, useState } from 'react'
4
- import { RefreshCw, Map, FileCode2, AlertTriangle, CheckCircle2, ExternalLink } from 'lucide-react'
3
+ import { useEffect, useId, useState } from 'react'
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 {
7
8
  fetchSitemapStatus,
@@ -55,27 +56,37 @@ function Toggle({
55
56
  onChange,
56
57
  label,
57
58
  help,
59
+ disabled,
58
60
  }: {
59
61
  checked: boolean
60
62
  onChange: (v: boolean) => void
61
63
  label: string
62
64
  help: string
65
+ disabled?: boolean
63
66
  }) {
67
+ const labelId = useId()
68
+ const helpId = useId()
64
69
  return (
65
- <label className="flex items-center justify-between gap-3 py-2.5">
70
+ <div className="flex items-center justify-between gap-3 py-2.5">
66
71
  <span className="min-w-0">
67
- <span className="text-foreground block text-sm font-medium">{label}</span>
68
- <span className="text-muted-foreground block text-sm">{help}</span>
72
+ <span id={labelId} className="text-foreground block text-sm font-medium">
73
+ {label}
74
+ </span>
75
+ <span id={helpId} className="text-muted-foreground block text-sm">
76
+ {help}
77
+ </span>
69
78
  </span>
70
- <input
71
- type="checkbox"
72
- role="switch"
73
- aria-checked={checked}
79
+ <Switch.Root
74
80
  checked={checked}
75
- onChange={(e) => onChange(e.target.checked)}
76
- className="accent-primary h-4 w-4 shrink-0"
77
- />
78
- </label>
81
+ onCheckedChange={onChange}
82
+ disabled={disabled}
83
+ aria-labelledby={labelId}
84
+ aria-describedby={helpId}
85
+ 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"
86
+ >
87
+ <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]" />
88
+ </Switch.Root>
89
+ </div>
79
90
  )
80
91
  }
81
92
 
@@ -190,6 +201,7 @@ function CrawlCard() {
190
201
  label={t.label}
191
202
  help={t.help}
192
203
  checked={settings[t.key]}
204
+ disabled={saving}
193
205
  onChange={(v) => save({ ...settings, [t.key]: v }, t.key)}
194
206
  />
195
207
  ))}
@@ -21,31 +21,45 @@ export function useSeoResource<T>(
21
21
  const [data, setData] = useState<T | null>(null)
22
22
  const [loading, setLoading] = useState(true)
23
23
  const [error, setError] = useState<string | null>(null)
24
- const aliveRef = useRef(true)
24
+ // Tracks whether the component is still mounted (guards setState after
25
+ // unmount). Set once on mount; never toggled by dependency-driven reloads.
26
+ const mountedRef = useRef(true)
27
+ // Monotonic id of the most recently *started* request. A response is only
28
+ // applied when its id still matches — otherwise a slow earlier request that
29
+ // resolves after a faster later one (rapid filter switches) would clobber
30
+ // the newer, correct data.
31
+ const requestIdRef = useRef(0)
25
32
  // eslint-disable-next-line react-hooks/exhaustive-deps
26
33
  const stableFetcher = useCallback(fetcher, deps)
27
34
 
28
35
  const load = useCallback(async () => {
36
+ const requestId = ++requestIdRef.current
37
+ const isCurrent = () => mountedRef.current && requestId === requestIdRef.current
29
38
  setLoading(true)
30
39
  setError(null)
31
40
  try {
32
41
  const result = await stableFetcher()
33
- if (aliveRef.current) setData(result)
42
+ if (isCurrent()) setData(result)
34
43
  } catch (err) {
35
- if (aliveRef.current) {
44
+ if (isCurrent()) {
36
45
  setError(err instanceof Error ? err.message : 'Failed to load data')
37
46
  }
38
47
  } finally {
39
- if (aliveRef.current) setLoading(false)
48
+ if (isCurrent()) setLoading(false)
40
49
  }
41
50
  }, [stableFetcher])
42
51
 
52
+ // Mount/unmount guard only — independent of `load` so a dependency change
53
+ // (which recreates `load`) never flips `mountedRef` to false mid-flight.
43
54
  useEffect(() => {
44
- aliveRef.current = true
45
- load()
55
+ mountedRef.current = true
46
56
  return () => {
47
- aliveRef.current = false
57
+ mountedRef.current = false
48
58
  }
59
+ }, [])
60
+
61
+ useEffect(() => {
62
+ load()
49
63
  }, [load])
50
64
 
51
65
  return { data, loading, error, refetch: load }