@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/views/Settings.tsx
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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={
|
|
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="
|
|
632
|
-
<
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
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
|
-
|
|
662
|
-
<
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
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
|
}
|
package/src/views/Users.tsx
CHANGED
|
@@ -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=
|
|
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=
|
|
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
|
-
<
|
|
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="
|
|
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
|
-
|
|
119
|
-
aria-
|
|
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="
|
|
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
|
-
|
|
136
|
-
aria-
|
|
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="
|
|
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
|
-
|
|
70
|
-
aria-
|
|
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 =
|
|
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
|
-
|
|
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,
|
|
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
|
-
<
|
|
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">
|
|
68
|
-
|
|
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
|
-
<
|
|
71
|
-
type="checkbox"
|
|
72
|
-
role="switch"
|
|
73
|
-
aria-checked={checked}
|
|
79
|
+
<Switch.Root
|
|
74
80
|
checked={checked}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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 (
|
|
42
|
+
if (isCurrent()) setData(result)
|
|
34
43
|
} catch (err) {
|
|
35
|
-
if (
|
|
44
|
+
if (isCurrent()) {
|
|
36
45
|
setError(err instanceof Error ? err.message : 'Failed to load data')
|
|
37
46
|
}
|
|
38
47
|
} finally {
|
|
39
|
-
if (
|
|
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
|
-
|
|
45
|
-
load()
|
|
55
|
+
mountedRef.current = true
|
|
46
56
|
return () => {
|
|
47
|
-
|
|
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 }
|