@actuate-media/cms-admin 0.9.0 → 0.11.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.
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +8 -5
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/layout/primitives.test.d.ts +2 -0
- package/dist/__tests__/layout/primitives.test.d.ts.map +1 -0
- package/dist/__tests__/layout/primitives.test.js +34 -0
- package/dist/__tests__/layout/primitives.test.js.map +1 -0
- package/dist/__tests__/lib/cv.test.d.ts +2 -0
- package/dist/__tests__/lib/cv.test.d.ts.map +1 -0
- package/dist/__tests__/lib/cv.test.js +66 -0
- package/dist/__tests__/lib/cv.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/assets/actuate-logo.d.ts +36 -0
- package/dist/assets/actuate-logo.d.ts.map +1 -0
- package/dist/assets/actuate-logo.js +15 -0
- package/dist/assets/actuate-logo.js.map +1 -0
- package/dist/components/Breadcrumbs.js +2 -2
- package/dist/components/CommandPalette.js +10 -10
- package/dist/components/ContentOverviewChart.js +3 -3
- package/dist/components/ErrorBoundary.js +1 -1
- package/dist/components/FocalPointPicker.js +2 -2
- package/dist/components/FolderTree.js +20 -20
- package/dist/components/LivePreview.js +3 -3
- package/dist/components/LocaleSwitcher.js +1 -1
- package/dist/components/MediaPickerModal.js +4 -4
- package/dist/components/PresenceIndicator.js +1 -1
- package/dist/components/SEOConfigPanel.d.ts +2 -0
- package/dist/components/SEOConfigPanel.d.ts.map +1 -0
- package/dist/components/SEOConfigPanel.js +174 -0
- package/dist/components/SEOConfigPanel.js.map +1 -0
- package/dist/components/SEOPanel.js +9 -9
- package/dist/components/SEOPerformance.js +2 -2
- package/dist/components/SchedulePublishDialog.d.ts +18 -0
- package/dist/components/SchedulePublishDialog.d.ts.map +1 -0
- package/dist/components/SchedulePublishDialog.js +106 -0
- package/dist/components/SchedulePublishDialog.js.map +1 -0
- package/dist/components/SharePreviewLinkDialog.d.ts +17 -0
- package/dist/components/SharePreviewLinkDialog.d.ts.map +1 -0
- package/dist/components/SharePreviewLinkDialog.js +83 -0
- package/dist/components/SharePreviewLinkDialog.js.map +1 -0
- package/dist/components/TipTapEditor.js +5 -5
- package/dist/components/VersionHistory.js +2 -2
- package/dist/components/ui/Badge.d.ts +33 -3
- package/dist/components/ui/Badge.d.ts.map +1 -1
- package/dist/components/ui/Badge.js +42 -8
- package/dist/components/ui/Badge.js.map +1 -1
- package/dist/components/ui/Button.d.ts +19 -8
- package/dist/components/ui/Button.d.ts.map +1 -1
- package/dist/components/ui/Button.js +35 -14
- package/dist/components/ui/Button.js.map +1 -1
- package/dist/components/ui/Card.d.ts +26 -0
- package/dist/components/ui/Card.d.ts.map +1 -0
- package/dist/components/ui/Card.js +45 -0
- package/dist/components/ui/Card.js.map +1 -0
- package/dist/components/ui/DataTable.js +1 -1
- package/dist/components/ui/Input.d.ts +15 -0
- package/dist/components/ui/Input.d.ts.map +1 -0
- package/dist/components/ui/Input.js +23 -0
- package/dist/components/ui/Input.js.map +1 -0
- package/dist/components/ui/SearchInput.js +1 -1
- package/dist/components/ui/Select.d.ts +16 -0
- package/dist/components/ui/Select.d.ts.map +1 -0
- package/dist/components/ui/Select.js +25 -0
- package/dist/components/ui/Select.js.map +1 -0
- package/dist/components/ui/Toast.js +1 -1
- package/dist/components/ui/index.d.ts +10 -4
- package/dist/components/ui/index.d.ts.map +1 -1
- package/dist/components/ui/index.js +5 -2
- package/dist/components/ui/index.js.map +1 -1
- package/dist/fields/BlockBuilderField.js +3 -3
- package/dist/fields/DateField.js +1 -1
- package/dist/fields/RelationshipField.js +3 -3
- package/dist/fields/TextField.js +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/layout/Header.js +1 -1
- package/dist/layout/Layout.d.ts +14 -0
- package/dist/layout/Layout.d.ts.map +1 -1
- package/dist/layout/Layout.js +17 -11
- package/dist/layout/Layout.js.map +1 -1
- package/dist/layout/Sidebar.d.ts.map +1 -1
- package/dist/layout/Sidebar.js +21 -11
- package/dist/layout/Sidebar.js.map +1 -1
- package/dist/layout/primitives/AdminShell.d.ts +43 -0
- package/dist/layout/primitives/AdminShell.d.ts.map +1 -0
- package/dist/layout/primitives/AdminShell.js +51 -0
- package/dist/layout/primitives/AdminShell.js.map +1 -0
- package/dist/layout/primitives/Box.d.ts +19 -0
- package/dist/layout/primitives/Box.d.ts.map +1 -0
- package/dist/layout/primitives/Box.js +12 -0
- package/dist/layout/primitives/Box.js.map +1 -0
- package/dist/layout/primitives/Cluster.d.ts +27 -0
- package/dist/layout/primitives/Cluster.d.ts.map +1 -0
- package/dist/layout/primitives/Cluster.js +37 -0
- package/dist/layout/primitives/Cluster.js.map +1 -0
- package/dist/layout/primitives/Grid.d.ts +45 -0
- package/dist/layout/primitives/Grid.d.ts.map +1 -0
- package/dist/layout/primitives/Grid.js +59 -0
- package/dist/layout/primitives/Grid.js.map +1 -0
- package/dist/layout/primitives/PageContainer.d.ts +36 -0
- package/dist/layout/primitives/PageContainer.d.ts.map +1 -0
- package/dist/layout/primitives/PageContainer.js +41 -0
- package/dist/layout/primitives/PageContainer.js.map +1 -0
- package/dist/layout/primitives/Split.d.ts +34 -0
- package/dist/layout/primitives/Split.d.ts.map +1 -0
- package/dist/layout/primitives/Split.js +27 -0
- package/dist/layout/primitives/Split.js.map +1 -0
- package/dist/layout/primitives/Stack.d.ts +23 -0
- package/dist/layout/primitives/Stack.d.ts.map +1 -0
- package/dist/layout/primitives/Stack.js +34 -0
- package/dist/layout/primitives/Stack.js.map +1 -0
- package/dist/layout/primitives/index.d.ts +30 -0
- package/dist/layout/primitives/index.d.ts.map +1 -0
- package/dist/layout/primitives/index.js +22 -0
- package/dist/layout/primitives/index.js.map +1 -0
- package/dist/layout/primitives/tokens.d.ts +48 -0
- package/dist/layout/primitives/tokens.d.ts.map +1 -0
- package/dist/layout/primitives/tokens.js +54 -0
- package/dist/layout/primitives/tokens.js.map +1 -0
- package/dist/lib/cv.d.ts +53 -0
- package/dist/lib/cv.d.ts.map +1 -0
- package/dist/lib/cv.js +39 -0
- package/dist/lib/cv.js.map +1 -0
- package/dist/views/ApiKeys.d.ts.map +1 -1
- package/dist/views/ApiKeys.js +13 -11
- package/dist/views/ApiKeys.js.map +1 -1
- package/dist/views/CollectionList.js +8 -8
- package/dist/views/Dashboard.d.ts.map +1 -1
- package/dist/views/Dashboard.js +333 -78
- package/dist/views/Dashboard.js.map +1 -1
- package/dist/views/DocumentEdit.d.ts.map +1 -1
- package/dist/views/DocumentEdit.js +17 -5
- package/dist/views/DocumentEdit.js.map +1 -1
- package/dist/views/ForgotPassword.js +2 -2
- package/dist/views/FormEditor.js +5 -5
- package/dist/views/FormSubmissions.js +6 -6
- package/dist/views/Forms.js +2 -2
- package/dist/views/Login.d.ts +16 -1
- package/dist/views/Login.d.ts.map +1 -1
- package/dist/views/Login.js +17 -7
- package/dist/views/Login.js.map +1 -1
- package/dist/views/MediaBrowser.js +16 -16
- package/dist/views/PageEditor.js +2 -2
- package/dist/views/Pages.js +10 -10
- package/dist/views/PostEditor.js +2 -2
- package/dist/views/Posts.js +4 -4
- package/dist/views/Redirects.js +4 -4
- package/dist/views/ResetPassword.js +2 -2
- package/dist/views/SEO.js +6 -6
- package/dist/views/ScriptTagEditor.js +4 -4
- package/dist/views/ScriptTags.js +2 -2
- package/dist/views/Settings.d.ts.map +1 -1
- package/dist/views/Settings.js +9 -8
- package/dist/views/Settings.js.map +1 -1
- package/dist/views/SetupWizard.js +2 -2
- package/dist/views/Users.js +4 -4
- package/dist/views/page-builder/AIBlockAssist.js +1 -1
- package/dist/views/page-builder/AIGenerateDialog.js +10 -10
- package/dist/views/page-builder/BlockEditor.js +10 -10
- package/dist/views/page-builder/BlockPicker.js +4 -4
- package/dist/views/page-builder/BottomBar.js +1 -1
- package/dist/views/page-builder/BuilderToolbar.js +2 -2
- package/dist/views/page-builder/ContextPanel.js +2 -2
- package/dist/views/page-builder/DesignScore.js +9 -9
- package/dist/views/page-builder/NodeSettings.js +8 -8
- package/dist/views/page-builder/PageBuilder.js +3 -3
- package/dist/views/page-builder/PageSettings.js +1 -1
- package/dist/views/page-builder/PageTemplates.js +2 -2
- package/dist/views/page-builder/SEOPanel.js +13 -13
- package/dist/views/page-builder/SavedSections.js +5 -5
- package/dist/views/page-builder/TemplatePicker.js +2 -2
- package/dist/views/page-builder/block-renderers/CTAPreview.js +5 -5
- package/dist/views/page-builder/block-renderers/CardsPreview.js +1 -1
- package/dist/views/page-builder/block-renderers/CodePreview.js +1 -1
- package/dist/views/page-builder/block-renderers/FAQPreview.js +3 -3
- package/dist/views/page-builder/block-renderers/FallbackPreview.js +1 -1
- package/dist/views/page-builder/block-renderers/FormPreview.js +3 -3
- package/dist/views/page-builder/block-renderers/GalleryPreview.js +5 -5
- package/dist/views/page-builder/block-renderers/HeroPreview.js +3 -3
- package/dist/views/page-builder/block-renderers/ImagePreview.js +3 -3
- package/dist/views/page-builder/block-renderers/TextPreview.js +3 -3
- package/dist/views/page-builder/block-renderers/VideoPreview.js +4 -4
- package/dist/views/page-builder/canvas/BlockRenderer.js +1 -1
- package/dist/views/page-builder/canvas/BuilderCanvas.js +3 -3
- package/dist/views/page-builder/canvas/ColumnRenderer.js +2 -2
- package/dist/views/page-builder/canvas/ContainerRenderer.js +2 -2
- package/dist/views/page-builder/canvas/RowRenderer.js +2 -2
- package/dist/views/page-builder/canvas/SectionRenderer.js +2 -2
- package/package.json +6 -2
- package/src/AdminRoot.tsx +21 -11
- package/src/__tests__/layout/primitives.test.ts +37 -0
- package/src/__tests__/lib/cv.test.ts +74 -0
- package/src/assets/actuate-logo.tsx +72 -0
- package/src/components/Breadcrumbs.tsx +6 -6
- package/src/components/CommandPalette.tsx +34 -34
- package/src/components/ContentOverviewChart.tsx +3 -3
- package/src/components/ErrorBoundary.tsx +3 -3
- package/src/components/FocalPointPicker.tsx +4 -4
- package/src/components/FolderTree.tsx +38 -38
- package/src/components/LivePreview.tsx +16 -16
- package/src/components/LocaleSwitcher.tsx +7 -7
- package/src/components/MediaPickerModal.tsx +21 -21
- package/src/components/PresenceIndicator.tsx +2 -2
- package/src/components/SEOConfigPanel.tsx +582 -0
- package/src/components/SEOPanel.tsx +46 -46
- package/src/components/SEOPerformance.tsx +21 -21
- package/src/components/SchedulePublishDialog.tsx +241 -0
- package/src/components/SharePreviewLinkDialog.tsx +227 -0
- package/src/components/TipTapEditor.tsx +33 -33
- package/src/components/VersionHistory.tsx +16 -16
- package/src/components/ui/Badge.tsx +66 -14
- package/src/components/ui/Button.tsx +70 -33
- package/src/components/ui/Card.tsx +101 -0
- package/src/components/ui/DataTable.tsx +1 -1
- package/src/components/ui/Input.tsx +35 -0
- package/src/components/ui/SearchInput.tsx +4 -4
- package/src/components/ui/Select.tsx +56 -0
- package/src/components/ui/Toast.tsx +1 -1
- package/src/components/ui/index.ts +18 -4
- package/src/fields/BlockBuilderField.tsx +3 -3
- package/src/fields/DateField.tsx +1 -1
- package/src/fields/RelationshipField.tsx +10 -10
- package/src/fields/TextField.tsx +1 -1
- package/src/index.ts +32 -0
- package/src/layout/Header.tsx +28 -28
- package/src/layout/Layout.tsx +39 -46
- package/src/layout/Sidebar.tsx +37 -64
- package/src/layout/primitives/AdminShell.tsx +118 -0
- package/src/layout/primitives/Box.tsx +30 -0
- package/src/layout/primitives/Cluster.tsx +74 -0
- package/src/layout/primitives/Grid.tsx +120 -0
- package/src/layout/primitives/PageContainer.tsx +96 -0
- package/src/layout/primitives/Split.tsx +73 -0
- package/src/layout/primitives/Stack.tsx +67 -0
- package/src/layout/primitives/index.ts +36 -0
- package/src/layout/primitives/tokens.ts +76 -0
- package/src/lib/cv.ts +96 -0
- package/src/styles/build-input.css +1 -1
- package/src/views/ApiKeys.tsx +57 -57
- package/src/views/CollectionList.tsx +30 -30
- package/src/views/Dashboard.tsx +737 -186
- package/src/views/DocumentEdit.tsx +90 -10
- package/src/views/ForgotPassword.tsx +18 -18
- package/src/views/FormEditor.tsx +75 -75
- package/src/views/FormSubmissions.tsx +76 -76
- package/src/views/Forms.tsx +27 -27
- package/src/views/Login.tsx +65 -25
- package/src/views/MediaBrowser.tsx +127 -127
- package/src/views/PageEditor.tsx +25 -25
- package/src/views/Pages.tsx +59 -59
- package/src/views/PostEditor.tsx +37 -37
- package/src/views/Posts.tsx +48 -48
- package/src/views/Redirects.tsx +21 -21
- package/src/views/ResetPassword.tsx +28 -28
- package/src/views/SEO.tsx +144 -144
- package/src/views/ScriptTagEditor.tsx +24 -24
- package/src/views/ScriptTags.tsx +10 -10
- package/src/views/Settings.tsx +88 -80
- package/src/views/SetupWizard.tsx +28 -28
- package/src/views/Users.tsx +20 -20
- package/src/views/page-builder/AIBlockAssist.tsx +1 -1
- package/src/views/page-builder/AIGenerateDialog.tsx +63 -63
- package/src/views/page-builder/BlockEditor.tsx +26 -26
- package/src/views/page-builder/BlockPicker.tsx +22 -22
- package/src/views/page-builder/BottomBar.tsx +8 -8
- package/src/views/page-builder/BuilderToolbar.tsx +17 -17
- package/src/views/page-builder/ContextPanel.tsx +3 -3
- package/src/views/page-builder/DesignScore.tsx +21 -21
- package/src/views/page-builder/NodeSettings.tsx +27 -27
- package/src/views/page-builder/PageBuilder.tsx +11 -11
- package/src/views/page-builder/PageSettings.tsx +4 -4
- package/src/views/page-builder/PageTemplates.tsx +18 -18
- package/src/views/page-builder/SEOPanel.tsx +53 -53
- package/src/views/page-builder/SavedSections.tsx +37 -37
- package/src/views/page-builder/TemplatePicker.tsx +17 -17
- package/src/views/page-builder/block-renderers/CTAPreview.tsx +13 -13
- package/src/views/page-builder/block-renderers/CardsPreview.tsx +5 -5
- package/src/views/page-builder/block-renderers/CodePreview.tsx +6 -6
- package/src/views/page-builder/block-renderers/FAQPreview.tsx +13 -13
- package/src/views/page-builder/block-renderers/FallbackPreview.tsx +3 -3
- package/src/views/page-builder/block-renderers/FormPreview.tsx +20 -20
- package/src/views/page-builder/block-renderers/GalleryPreview.tsx +8 -8
- package/src/views/page-builder/block-renderers/HeroPreview.tsx +16 -16
- package/src/views/page-builder/block-renderers/ImagePreview.tsx +4 -4
- package/src/views/page-builder/block-renderers/TextPreview.tsx +14 -14
- package/src/views/page-builder/block-renderers/VideoPreview.tsx +12 -12
- package/src/views/page-builder/canvas/BlockRenderer.tsx +4 -4
- package/src/views/page-builder/canvas/BuilderCanvas.tsx +6 -6
- package/src/views/page-builder/canvas/ColumnRenderer.tsx +3 -3
- package/src/views/page-builder/canvas/ContainerRenderer.tsx +2 -2
- package/src/views/page-builder/canvas/RowRenderer.tsx +2 -2
- package/src/views/page-builder/canvas/SectionRenderer.tsx +2 -2
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Admin UI for editing the site-wide + per-collection SEO defaults that are
|
|
5
|
+
* normally defined in `actuate.config.ts`. The CMS merges code-level config
|
|
6
|
+
* with these DB-stored overrides, so any field left blank here falls back to
|
|
7
|
+
* the static config (shown as the placeholder).
|
|
8
|
+
*
|
|
9
|
+
* Wired in `Settings.tsx` as the "SEO" tab. Talks to `/seo/config` (GET + PUT).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { Loader2, RefreshCw, Save, Globe, FileText } from 'lucide-react'
|
|
13
|
+
import { useEffect, useMemo, useState } from 'react'
|
|
14
|
+
import { toast } from 'sonner'
|
|
15
|
+
import { cmsApi } from '../lib/api.js'
|
|
16
|
+
|
|
17
|
+
interface RobotsSettings {
|
|
18
|
+
noIndex?: boolean
|
|
19
|
+
noFollow?: boolean
|
|
20
|
+
noArchive?: boolean
|
|
21
|
+
noSnippet?: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface SiteSEO {
|
|
25
|
+
siteUrl?: string
|
|
26
|
+
siteName?: string
|
|
27
|
+
defaultOgImage?: string
|
|
28
|
+
twitterHandle?: string
|
|
29
|
+
robots?: { blockAIBots?: boolean; disabled?: boolean }
|
|
30
|
+
sitemap?: {
|
|
31
|
+
disabled?: boolean
|
|
32
|
+
defaultChangeFreq?: string
|
|
33
|
+
defaultPriority?: number
|
|
34
|
+
}
|
|
35
|
+
ogImage?: { disabled?: boolean; theme?: 'light' | 'dark' }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface CollectionSEO {
|
|
39
|
+
archivePath?: string
|
|
40
|
+
defaultSchemaType?: string
|
|
41
|
+
sitemapPriority?: number
|
|
42
|
+
sitemapChangeFreq?: string
|
|
43
|
+
excludeFromSitemap?: boolean
|
|
44
|
+
defaultRobots?: RobotsSettings
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface ConfigCollection {
|
|
48
|
+
slug: string
|
|
49
|
+
label: string
|
|
50
|
+
type: 'page' | 'post' | string
|
|
51
|
+
urlPrefix?: string
|
|
52
|
+
staticSeo: CollectionSEO | null
|
|
53
|
+
effectiveSeo: CollectionSEO | null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface ConfigResponse {
|
|
57
|
+
static: { site: SiteSEO | null }
|
|
58
|
+
overrides: {
|
|
59
|
+
site?: Partial<SiteSEO>
|
|
60
|
+
collections?: Record<string, Partial<CollectionSEO>>
|
|
61
|
+
} | null
|
|
62
|
+
effective: { site: SiteSEO | null }
|
|
63
|
+
collections: ConfigCollection[]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const SCHEMA_TYPES = [
|
|
67
|
+
'',
|
|
68
|
+
'WebPage',
|
|
69
|
+
'Article',
|
|
70
|
+
'BlogPosting',
|
|
71
|
+
'NewsArticle',
|
|
72
|
+
'Product',
|
|
73
|
+
'Service',
|
|
74
|
+
'LocalBusiness',
|
|
75
|
+
'FAQPage',
|
|
76
|
+
'JobPosting',
|
|
77
|
+
'Event',
|
|
78
|
+
'Recipe',
|
|
79
|
+
'Person',
|
|
80
|
+
'Organization',
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
const CHANGE_FREQS = ['', 'always', 'hourly', 'daily', 'weekly', 'monthly', 'yearly', 'never']
|
|
84
|
+
|
|
85
|
+
export function SEOConfigPanel() {
|
|
86
|
+
const [data, setData] = useState<ConfigResponse | null>(null)
|
|
87
|
+
const [loading, setLoading] = useState(true)
|
|
88
|
+
const [saving, setSaving] = useState(false)
|
|
89
|
+
const [error, setError] = useState<string | null>(null)
|
|
90
|
+
|
|
91
|
+
const [site, setSite] = useState<Partial<SiteSEO>>({})
|
|
92
|
+
const [collections, setCollections] = useState<Record<string, Partial<CollectionSEO>>>({})
|
|
93
|
+
|
|
94
|
+
async function load() {
|
|
95
|
+
setLoading(true)
|
|
96
|
+
setError(null)
|
|
97
|
+
const res = await cmsApi<ConfigResponse>('/seo/config', { method: 'GET' })
|
|
98
|
+
if (res.error || !res.data) {
|
|
99
|
+
setError(res.error ?? 'Failed to load SEO config')
|
|
100
|
+
setLoading(false)
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
setData(res.data)
|
|
104
|
+
setSite(res.data.overrides?.site ?? {})
|
|
105
|
+
setCollections(res.data.overrides?.collections ?? {})
|
|
106
|
+
setLoading(false)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
void load()
|
|
111
|
+
}, [])
|
|
112
|
+
|
|
113
|
+
// Field-level placeholder = the static value from actuate.config.ts. The
|
|
114
|
+
// input value is whatever the admin has typed (or the saved override). Empty
|
|
115
|
+
// input + empty override means "fall back to static", which is the desired
|
|
116
|
+
// UX for an additive overrides panel.
|
|
117
|
+
const staticSite = data?.static?.site ?? {}
|
|
118
|
+
|
|
119
|
+
async function handleSave() {
|
|
120
|
+
setSaving(true)
|
|
121
|
+
// Strip empty strings so they unset back to the static default rather
|
|
122
|
+
// than persisting "" — the store treats "" as "no override".
|
|
123
|
+
const cleanSite = stripEmpty(site)
|
|
124
|
+
const cleanCollections: Record<string, Partial<CollectionSEO>> = {}
|
|
125
|
+
for (const [slug, c] of Object.entries(collections)) {
|
|
126
|
+
const cleaned = stripEmpty(c)
|
|
127
|
+
if (Object.keys(cleaned).length > 0) cleanCollections[slug] = cleaned
|
|
128
|
+
}
|
|
129
|
+
const res = await cmsApi('/seo/config', {
|
|
130
|
+
method: 'PUT',
|
|
131
|
+
body: JSON.stringify({ site: cleanSite, collections: cleanCollections }),
|
|
132
|
+
})
|
|
133
|
+
if (res.error) {
|
|
134
|
+
toast.error(res.error)
|
|
135
|
+
} else {
|
|
136
|
+
toast.success('SEO defaults saved')
|
|
137
|
+
await load()
|
|
138
|
+
}
|
|
139
|
+
setSaving(false)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function patchSite<K extends keyof SiteSEO>(key: K, value: SiteSEO[K]) {
|
|
143
|
+
setSite((prev) => ({ ...prev, [key]: value }))
|
|
144
|
+
}
|
|
145
|
+
function patchSiteRobots(key: 'blockAIBots' | 'disabled', value: boolean) {
|
|
146
|
+
setSite((prev) => ({ ...prev, robots: { ...(prev.robots ?? {}), [key]: value } }))
|
|
147
|
+
}
|
|
148
|
+
function patchSiteSitemap<K extends keyof NonNullable<SiteSEO['sitemap']>>(
|
|
149
|
+
key: K,
|
|
150
|
+
value: NonNullable<SiteSEO['sitemap']>[K],
|
|
151
|
+
) {
|
|
152
|
+
setSite((prev) => ({ ...prev, sitemap: { ...(prev.sitemap ?? {}), [key]: value } }))
|
|
153
|
+
}
|
|
154
|
+
function patchSiteOg<K extends keyof NonNullable<SiteSEO['ogImage']>>(
|
|
155
|
+
key: K,
|
|
156
|
+
value: NonNullable<SiteSEO['ogImage']>[K],
|
|
157
|
+
) {
|
|
158
|
+
setSite((prev) => ({ ...prev, ogImage: { ...(prev.ogImage ?? {}), [key]: value } }))
|
|
159
|
+
}
|
|
160
|
+
function patchCollection<K extends keyof CollectionSEO>(
|
|
161
|
+
slug: string,
|
|
162
|
+
key: K,
|
|
163
|
+
value: CollectionSEO[K],
|
|
164
|
+
) {
|
|
165
|
+
setCollections((prev) => ({
|
|
166
|
+
...prev,
|
|
167
|
+
[slug]: { ...(prev[slug] ?? {}), [key]: value },
|
|
168
|
+
}))
|
|
169
|
+
}
|
|
170
|
+
function patchCollectionRobots(slug: string, key: keyof RobotsSettings, value: boolean) {
|
|
171
|
+
setCollections((prev) => ({
|
|
172
|
+
...prev,
|
|
173
|
+
[slug]: {
|
|
174
|
+
...(prev[slug] ?? {}),
|
|
175
|
+
defaultRobots: { ...(prev[slug]?.defaultRobots ?? {}), [key]: value },
|
|
176
|
+
},
|
|
177
|
+
}))
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const updatedAt = useMemo(() => {
|
|
181
|
+
// `updatedAt` is set by the server when overrides are persisted but isn't
|
|
182
|
+
// part of the declared `SeoConfigOverrides` shape. Cast through `unknown`
|
|
183
|
+
// so we can surface it in the UI without widening the type contract.
|
|
184
|
+
const ts = (data?.overrides as { updatedAt?: string } | undefined)?.updatedAt
|
|
185
|
+
if (!ts) return null
|
|
186
|
+
try {
|
|
187
|
+
return new Date(ts).toLocaleString()
|
|
188
|
+
} catch {
|
|
189
|
+
return ts
|
|
190
|
+
}
|
|
191
|
+
}, [data])
|
|
192
|
+
|
|
193
|
+
if (loading) {
|
|
194
|
+
return (
|
|
195
|
+
<div className="flex items-center gap-2 py-8 text-sm text-gray-600">
|
|
196
|
+
<Loader2 className="h-4 w-4 animate-spin" /> Loading SEO defaults…
|
|
197
|
+
</div>
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (error) {
|
|
202
|
+
return (
|
|
203
|
+
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700">
|
|
204
|
+
<div className="mb-2 font-medium">Failed to load SEO config</div>
|
|
205
|
+
<div className="font-mono text-xs">{error}</div>
|
|
206
|
+
<button
|
|
207
|
+
onClick={() => void load()}
|
|
208
|
+
className="mt-3 inline-flex items-center gap-1.5 rounded-md border border-red-300 bg-white px-3 py-1.5 text-xs font-medium text-red-700 hover:bg-red-100"
|
|
209
|
+
>
|
|
210
|
+
<RefreshCw className="h-3.5 w-3.5" /> Retry
|
|
211
|
+
</button>
|
|
212
|
+
</div>
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return (
|
|
217
|
+
<div className="space-y-6">
|
|
218
|
+
<div className="flex items-start justify-between gap-4">
|
|
219
|
+
<div>
|
|
220
|
+
<p className="text-sm text-gray-600">
|
|
221
|
+
Edit the SEO defaults that flow into{' '}
|
|
222
|
+
<code className="rounded bg-gray-100 px-1 py-0.5 text-[11px]">/sitemap.xml</code>,{' '}
|
|
223
|
+
<code className="rounded bg-gray-100 px-1 py-0.5 text-[11px]">/robots.txt</code>,{' '}
|
|
224
|
+
<code className="rounded bg-gray-100 px-1 py-0.5 text-[11px]">/og.png</code>, and the{' '}
|
|
225
|
+
<code className="rounded bg-gray-100 px-1 py-0.5 text-[11px]">/resolve</code> meta +
|
|
226
|
+
JSON-LD response. Empty fields fall back to{' '}
|
|
227
|
+
<code className="rounded bg-gray-100 px-1 py-0.5 text-[11px]">actuate.config.ts</code>.
|
|
228
|
+
</p>
|
|
229
|
+
{updatedAt && <p className="mt-1 text-xs text-gray-500">Last saved: {updatedAt}</p>}
|
|
230
|
+
</div>
|
|
231
|
+
<button
|
|
232
|
+
onClick={handleSave}
|
|
233
|
+
disabled={saving}
|
|
234
|
+
className="inline-flex shrink-0 items-center gap-1.5 rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white shadow-sm hover:bg-blue-700 disabled:opacity-50"
|
|
235
|
+
>
|
|
236
|
+
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
|
237
|
+
Save SEO defaults
|
|
238
|
+
</button>
|
|
239
|
+
</div>
|
|
240
|
+
|
|
241
|
+
{/* Site-wide section */}
|
|
242
|
+
<section className="rounded-lg border border-gray-200 bg-white">
|
|
243
|
+
<header className="flex items-center gap-2 border-b border-gray-200 px-4 py-3">
|
|
244
|
+
<Globe className="h-4 w-4 text-blue-600" />
|
|
245
|
+
<h3 className="text-sm font-semibold text-gray-900">Site-wide defaults</h3>
|
|
246
|
+
</header>
|
|
247
|
+
<div className="grid grid-cols-1 gap-4 p-4 md:grid-cols-2">
|
|
248
|
+
<Field
|
|
249
|
+
label="Site URL"
|
|
250
|
+
placeholder={staticSite.siteUrl ?? 'https://example.com'}
|
|
251
|
+
value={site.siteUrl ?? ''}
|
|
252
|
+
onChange={(v) => patchSite('siteUrl', v)}
|
|
253
|
+
hint="Canonical origin used in sitemap URLs and JSON-LD."
|
|
254
|
+
/>
|
|
255
|
+
<Field
|
|
256
|
+
label="Site name"
|
|
257
|
+
placeholder={staticSite.siteName ?? 'My Site'}
|
|
258
|
+
value={site.siteName ?? ''}
|
|
259
|
+
onChange={(v) => patchSite('siteName', v)}
|
|
260
|
+
hint="Brand name in OG cards and Schema.org Organization."
|
|
261
|
+
/>
|
|
262
|
+
<Field
|
|
263
|
+
label="Default OG image"
|
|
264
|
+
placeholder={staticSite.defaultOgImage ?? '/og-default.png'}
|
|
265
|
+
value={site.defaultOgImage ?? ''}
|
|
266
|
+
onChange={(v) => patchSite('defaultOgImage', v)}
|
|
267
|
+
hint="Used when a document doesn't supply its own."
|
|
268
|
+
/>
|
|
269
|
+
<Field
|
|
270
|
+
label="Twitter handle"
|
|
271
|
+
placeholder={staticSite.twitterHandle ?? '@yourbrand'}
|
|
272
|
+
value={site.twitterHandle ?? ''}
|
|
273
|
+
onChange={(v) => patchSite('twitterHandle', v)}
|
|
274
|
+
hint="Including the leading @."
|
|
275
|
+
/>
|
|
276
|
+
</div>
|
|
277
|
+
|
|
278
|
+
<div className="grid grid-cols-1 gap-4 border-t border-gray-100 p-4 md:grid-cols-3">
|
|
279
|
+
<Select
|
|
280
|
+
label="Default change frequency"
|
|
281
|
+
value={site.sitemap?.defaultChangeFreq ?? ''}
|
|
282
|
+
options={CHANGE_FREQS}
|
|
283
|
+
onChange={(v) => patchSiteSitemap('defaultChangeFreq', v as any)}
|
|
284
|
+
placeholderLabel={`default: ${staticSite.sitemap?.defaultChangeFreq ?? 'weekly'}`}
|
|
285
|
+
hint="Applied per-URL in sitemap.xml when a collection doesn't override."
|
|
286
|
+
/>
|
|
287
|
+
<NumberField
|
|
288
|
+
label="Default priority"
|
|
289
|
+
value={site.sitemap?.defaultPriority}
|
|
290
|
+
placeholder={`default: ${staticSite.sitemap?.defaultPriority ?? 0.6}`}
|
|
291
|
+
onChange={(v) => patchSiteSitemap('defaultPriority', v)}
|
|
292
|
+
min={0}
|
|
293
|
+
max={1}
|
|
294
|
+
step={0.1}
|
|
295
|
+
hint="Between 0.0 and 1.0."
|
|
296
|
+
/>
|
|
297
|
+
<Select
|
|
298
|
+
label="OG image theme"
|
|
299
|
+
value={site.ogImage?.theme ?? ''}
|
|
300
|
+
options={['', 'light', 'dark']}
|
|
301
|
+
onChange={(v) => patchSiteOg('theme', (v || undefined) as 'light' | 'dark' | undefined)}
|
|
302
|
+
placeholderLabel={`default: ${staticSite.ogImage?.theme ?? 'light'}`}
|
|
303
|
+
hint="Applied by the built-in /og.png renderer."
|
|
304
|
+
/>
|
|
305
|
+
</div>
|
|
306
|
+
|
|
307
|
+
<div className="flex flex-wrap gap-6 border-t border-gray-100 p-4">
|
|
308
|
+
<Toggle
|
|
309
|
+
label="Block known AI bots in robots.txt"
|
|
310
|
+
checked={!!site.robots?.blockAIBots}
|
|
311
|
+
onChange={(v) => patchSiteRobots('blockAIBots', v)}
|
|
312
|
+
hint="GPTBot, ClaudeBot, anthropic-ai, Bytespider, etc."
|
|
313
|
+
/>
|
|
314
|
+
<Toggle
|
|
315
|
+
label="Disable robots.txt route"
|
|
316
|
+
checked={!!site.robots?.disabled}
|
|
317
|
+
onChange={(v) => patchSiteRobots('disabled', v)}
|
|
318
|
+
/>
|
|
319
|
+
<Toggle
|
|
320
|
+
label="Disable sitemap.xml route"
|
|
321
|
+
checked={!!site.sitemap?.disabled}
|
|
322
|
+
onChange={(v) => patchSiteSitemap('disabled', v)}
|
|
323
|
+
/>
|
|
324
|
+
<Toggle
|
|
325
|
+
label="Disable /og.png route"
|
|
326
|
+
checked={!!site.ogImage?.disabled}
|
|
327
|
+
onChange={(v) => patchSiteOg('disabled', v)}
|
|
328
|
+
/>
|
|
329
|
+
</div>
|
|
330
|
+
</section>
|
|
331
|
+
|
|
332
|
+
{/* Per-collection section */}
|
|
333
|
+
<section className="rounded-lg border border-gray-200 bg-white">
|
|
334
|
+
<header className="flex items-center gap-2 border-b border-gray-200 px-4 py-3">
|
|
335
|
+
<FileText className="h-4 w-4 text-blue-600" />
|
|
336
|
+
<h3 className="text-sm font-semibold text-gray-900">Per-collection defaults</h3>
|
|
337
|
+
<span className="text-xs text-gray-500">
|
|
338
|
+
— shown placeholders are the static defaults
|
|
339
|
+
</span>
|
|
340
|
+
</header>
|
|
341
|
+
<div className="divide-y divide-gray-100">
|
|
342
|
+
{data?.collections.map((col) => {
|
|
343
|
+
const override = collections[col.slug] ?? {}
|
|
344
|
+
const staticC = col.staticSeo ?? {}
|
|
345
|
+
return (
|
|
346
|
+
<div key={col.slug} className="p-4">
|
|
347
|
+
<div className="mb-3 flex items-baseline justify-between gap-4">
|
|
348
|
+
<div>
|
|
349
|
+
<h4 className="text-sm font-semibold text-gray-900">{col.label}</h4>
|
|
350
|
+
<p className="text-xs text-gray-500">
|
|
351
|
+
<code className="rounded bg-gray-100 px-1 py-0.5">{col.slug}</code>
|
|
352
|
+
{col.urlPrefix !== undefined && (
|
|
353
|
+
<>
|
|
354
|
+
{' '}
|
|
355
|
+
prefix:{' '}
|
|
356
|
+
<code className="rounded bg-gray-100 px-1 py-0.5">/{col.urlPrefix}</code>
|
|
357
|
+
</>
|
|
358
|
+
)}{' '}
|
|
359
|
+
— {col.type}
|
|
360
|
+
</p>
|
|
361
|
+
</div>
|
|
362
|
+
</div>
|
|
363
|
+
|
|
364
|
+
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
|
|
365
|
+
<Select
|
|
366
|
+
label="Default Schema.org type"
|
|
367
|
+
value={override.defaultSchemaType ?? ''}
|
|
368
|
+
options={SCHEMA_TYPES}
|
|
369
|
+
onChange={(v) => patchCollection(col.slug, 'defaultSchemaType', v || undefined)}
|
|
370
|
+
placeholderLabel={`default: ${staticC.defaultSchemaType ?? 'auto-detect'}`}
|
|
371
|
+
/>
|
|
372
|
+
<Field
|
|
373
|
+
label="Archive path"
|
|
374
|
+
placeholder={staticC.archivePath ?? '/blog'}
|
|
375
|
+
value={override.archivePath ?? ''}
|
|
376
|
+
onChange={(v) => patchCollection(col.slug, 'archivePath', v)}
|
|
377
|
+
/>
|
|
378
|
+
<Select
|
|
379
|
+
label="Sitemap change frequency"
|
|
380
|
+
value={override.sitemapChangeFreq ?? ''}
|
|
381
|
+
options={CHANGE_FREQS}
|
|
382
|
+
onChange={(v) => patchCollection(col.slug, 'sitemapChangeFreq', v as any)}
|
|
383
|
+
placeholderLabel={`default: ${staticC.sitemapChangeFreq ?? 'weekly'}`}
|
|
384
|
+
/>
|
|
385
|
+
<NumberField
|
|
386
|
+
label="Sitemap priority"
|
|
387
|
+
value={override.sitemapPriority}
|
|
388
|
+
placeholder={`default: ${staticC.sitemapPriority ?? (col.type === 'page' ? 0.8 : 0.6)}`}
|
|
389
|
+
onChange={(v) => patchCollection(col.slug, 'sitemapPriority', v)}
|
|
390
|
+
min={0}
|
|
391
|
+
max={1}
|
|
392
|
+
step={0.1}
|
|
393
|
+
/>
|
|
394
|
+
</div>
|
|
395
|
+
|
|
396
|
+
<div className="mt-3 flex flex-wrap gap-4">
|
|
397
|
+
<Toggle
|
|
398
|
+
label="Exclude from sitemap"
|
|
399
|
+
checked={!!override.excludeFromSitemap}
|
|
400
|
+
onChange={(v) => patchCollection(col.slug, 'excludeFromSitemap', v)}
|
|
401
|
+
/>
|
|
402
|
+
<Toggle
|
|
403
|
+
label="Default: noindex"
|
|
404
|
+
checked={!!override.defaultRobots?.noIndex}
|
|
405
|
+
onChange={(v) => patchCollectionRobots(col.slug, 'noIndex', v)}
|
|
406
|
+
/>
|
|
407
|
+
<Toggle
|
|
408
|
+
label="Default: nofollow"
|
|
409
|
+
checked={!!override.defaultRobots?.noFollow}
|
|
410
|
+
onChange={(v) => patchCollectionRobots(col.slug, 'noFollow', v)}
|
|
411
|
+
/>
|
|
412
|
+
</div>
|
|
413
|
+
</div>
|
|
414
|
+
)
|
|
415
|
+
})}
|
|
416
|
+
{data?.collections.length === 0 && (
|
|
417
|
+
<div className="p-4 text-sm text-gray-500">
|
|
418
|
+
No collections configured in <code>actuate.config.ts</code>.
|
|
419
|
+
</div>
|
|
420
|
+
)}
|
|
421
|
+
</div>
|
|
422
|
+
</section>
|
|
423
|
+
|
|
424
|
+
<div className="flex justify-end">
|
|
425
|
+
<button
|
|
426
|
+
onClick={handleSave}
|
|
427
|
+
disabled={saving}
|
|
428
|
+
className="inline-flex items-center gap-1.5 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 disabled:opacity-50"
|
|
429
|
+
>
|
|
430
|
+
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
|
431
|
+
Save SEO defaults
|
|
432
|
+
</button>
|
|
433
|
+
</div>
|
|
434
|
+
</div>
|
|
435
|
+
)
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function stripEmpty<T extends Record<string, any>>(obj: T): Partial<T> {
|
|
439
|
+
const out: Record<string, any> = {}
|
|
440
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
441
|
+
if (v === undefined || v === null) continue
|
|
442
|
+
if (typeof v === 'string' && v.trim() === '') continue
|
|
443
|
+
if (typeof v === 'object' && !Array.isArray(v)) {
|
|
444
|
+
const nested = stripEmpty(v as Record<string, any>)
|
|
445
|
+
if (Object.keys(nested).length > 0) out[k] = nested
|
|
446
|
+
continue
|
|
447
|
+
}
|
|
448
|
+
out[k] = v
|
|
449
|
+
}
|
|
450
|
+
return out as Partial<T>
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function Field({
|
|
454
|
+
label,
|
|
455
|
+
value,
|
|
456
|
+
placeholder,
|
|
457
|
+
onChange,
|
|
458
|
+
hint,
|
|
459
|
+
}: {
|
|
460
|
+
label: string
|
|
461
|
+
value: string
|
|
462
|
+
placeholder?: string
|
|
463
|
+
onChange: (v: string) => void
|
|
464
|
+
hint?: string
|
|
465
|
+
}) {
|
|
466
|
+
return (
|
|
467
|
+
<div>
|
|
468
|
+
<label className="mb-1 block text-xs font-medium text-gray-700">{label}</label>
|
|
469
|
+
<input
|
|
470
|
+
type="text"
|
|
471
|
+
value={value}
|
|
472
|
+
placeholder={placeholder}
|
|
473
|
+
onChange={(e) => onChange(e.target.value)}
|
|
474
|
+
className="w-full rounded-md border border-gray-300 px-2.5 py-1.5 text-sm text-gray-900 placeholder-gray-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
|
475
|
+
/>
|
|
476
|
+
{hint && <p className="mt-1 text-[11px] text-gray-500">{hint}</p>}
|
|
477
|
+
</div>
|
|
478
|
+
)
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function NumberField({
|
|
482
|
+
label,
|
|
483
|
+
value,
|
|
484
|
+
placeholder,
|
|
485
|
+
onChange,
|
|
486
|
+
min,
|
|
487
|
+
max,
|
|
488
|
+
step,
|
|
489
|
+
hint,
|
|
490
|
+
}: {
|
|
491
|
+
label: string
|
|
492
|
+
value: number | undefined
|
|
493
|
+
placeholder?: string
|
|
494
|
+
onChange: (v: number | undefined) => void
|
|
495
|
+
min?: number
|
|
496
|
+
max?: number
|
|
497
|
+
step?: number
|
|
498
|
+
hint?: string
|
|
499
|
+
}) {
|
|
500
|
+
return (
|
|
501
|
+
<div>
|
|
502
|
+
<label className="mb-1 block text-xs font-medium text-gray-700">{label}</label>
|
|
503
|
+
<input
|
|
504
|
+
type="number"
|
|
505
|
+
value={value ?? ''}
|
|
506
|
+
placeholder={placeholder}
|
|
507
|
+
min={min}
|
|
508
|
+
max={max}
|
|
509
|
+
step={step}
|
|
510
|
+
onChange={(e) => {
|
|
511
|
+
const raw = e.target.value
|
|
512
|
+
if (raw === '') return onChange(undefined)
|
|
513
|
+
const n = Number(raw)
|
|
514
|
+
if (Number.isFinite(n)) onChange(n)
|
|
515
|
+
}}
|
|
516
|
+
className="w-full rounded-md border border-gray-300 px-2.5 py-1.5 text-sm text-gray-900 placeholder-gray-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
|
517
|
+
/>
|
|
518
|
+
{hint && <p className="mt-1 text-[11px] text-gray-500">{hint}</p>}
|
|
519
|
+
</div>
|
|
520
|
+
)
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function Select({
|
|
524
|
+
label,
|
|
525
|
+
value,
|
|
526
|
+
options,
|
|
527
|
+
onChange,
|
|
528
|
+
placeholderLabel,
|
|
529
|
+
hint,
|
|
530
|
+
}: {
|
|
531
|
+
label: string
|
|
532
|
+
value: string
|
|
533
|
+
options: string[]
|
|
534
|
+
onChange: (v: string) => void
|
|
535
|
+
placeholderLabel?: string
|
|
536
|
+
hint?: string
|
|
537
|
+
}) {
|
|
538
|
+
return (
|
|
539
|
+
<div>
|
|
540
|
+
<label className="mb-1 block text-xs font-medium text-gray-700">{label}</label>
|
|
541
|
+
<select
|
|
542
|
+
value={value}
|
|
543
|
+
onChange={(e) => onChange(e.target.value)}
|
|
544
|
+
className="w-full rounded-md border border-gray-300 bg-white px-2.5 py-1.5 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
|
545
|
+
>
|
|
546
|
+
{options.map((opt) => (
|
|
547
|
+
<option key={opt} value={opt}>
|
|
548
|
+
{opt === '' ? (placeholderLabel ?? '— use default —') : opt}
|
|
549
|
+
</option>
|
|
550
|
+
))}
|
|
551
|
+
</select>
|
|
552
|
+
{hint && <p className="mt-1 text-[11px] text-gray-500">{hint}</p>}
|
|
553
|
+
</div>
|
|
554
|
+
)
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function Toggle({
|
|
558
|
+
label,
|
|
559
|
+
checked,
|
|
560
|
+
onChange,
|
|
561
|
+
hint,
|
|
562
|
+
}: {
|
|
563
|
+
label: string
|
|
564
|
+
checked: boolean
|
|
565
|
+
onChange: (v: boolean) => void
|
|
566
|
+
hint?: string
|
|
567
|
+
}) {
|
|
568
|
+
return (
|
|
569
|
+
<label className="flex cursor-pointer items-center gap-2 text-sm text-gray-700">
|
|
570
|
+
<input
|
|
571
|
+
type="checkbox"
|
|
572
|
+
checked={checked}
|
|
573
|
+
onChange={(e) => onChange(e.target.checked)}
|
|
574
|
+
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
|
575
|
+
/>
|
|
576
|
+
<span>
|
|
577
|
+
{label}
|
|
578
|
+
{hint && <span className="ml-1 text-[11px] text-gray-500">— {hint}</span>}
|
|
579
|
+
</span>
|
|
580
|
+
</label>
|
|
581
|
+
)
|
|
582
|
+
}
|