@actuate-media/cms-admin 0.25.0 → 0.26.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 +3 -3
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/views/general-settings.render.test.d.ts +2 -0
- package/dist/__tests__/views/general-settings.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/general-settings.render.test.js +105 -0
- package/dist/__tests__/views/general-settings.render.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/views/Settings.d.ts +3 -1
- package/dist/views/Settings.d.ts.map +1 -1
- package/dist/views/Settings.js +9 -28
- package/dist/views/Settings.js.map +1 -1
- package/dist/views/settings/GeneralSettingsTab.d.ts +7 -0
- package/dist/views/settings/GeneralSettingsTab.d.ts.map +1 -0
- package/dist/views/settings/GeneralSettingsTab.js +19 -0
- package/dist/views/settings/GeneralSettingsTab.js.map +1 -0
- package/dist/views/settings/LanguageRegionCard.d.ts +10 -0
- package/dist/views/settings/LanguageRegionCard.d.ts.map +1 -0
- package/dist/views/settings/LanguageRegionCard.js +48 -0
- package/dist/views/settings/LanguageRegionCard.js.map +1 -0
- package/dist/views/settings/SeoRobotsDefaultsCard.d.ts +12 -0
- package/dist/views/settings/SeoRobotsDefaultsCard.d.ts.map +1 -0
- package/dist/views/settings/SeoRobotsDefaultsCard.js +45 -0
- package/dist/views/settings/SeoRobotsDefaultsCard.js.map +1 -0
- package/dist/views/settings/SiteIndexStatusCard.d.ts +12 -0
- package/dist/views/settings/SiteIndexStatusCard.d.ts.map +1 -0
- package/dist/views/settings/SiteIndexStatusCard.js +51 -0
- package/dist/views/settings/SiteIndexStatusCard.js.map +1 -0
- package/dist/views/settings/SiteInformationCard.d.ts +11 -0
- package/dist/views/settings/SiteInformationCard.d.ts.map +1 -0
- package/dist/views/settings/SiteInformationCard.js +21 -0
- package/dist/views/settings/SiteInformationCard.js.map +1 -0
- package/dist/views/settings/components.d.ts +38 -0
- package/dist/views/settings/components.d.ts.map +1 -0
- package/dist/views/settings/components.js +43 -0
- package/dist/views/settings/components.js.map +1 -0
- package/dist/views/settings/useGeneralSettings.d.ts +79 -0
- package/dist/views/settings/useGeneralSettings.d.ts.map +1 -0
- package/dist/views/settings/useGeneralSettings.js +286 -0
- package/dist/views/settings/useGeneralSettings.js.map +1 -0
- package/package.json +3 -3
- package/src/AdminRoot.tsx +5 -3
- package/src/__tests__/views/general-settings.render.test.tsx +135 -0
- package/src/views/Settings.tsx +17 -120
- package/src/views/settings/GeneralSettingsTab.tsx +126 -0
- package/src/views/settings/LanguageRegionCard.tsx +119 -0
- package/src/views/settings/SeoRobotsDefaultsCard.tsx +149 -0
- package/src/views/settings/SiteIndexStatusCard.tsx +184 -0
- package/src/views/settings/SiteInformationCard.tsx +122 -0
- package/src/views/settings/components.tsx +198 -0
- package/src/views/settings/useGeneralSettings.ts +376 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
CheckCircle2,
|
|
5
|
+
XCircle,
|
|
6
|
+
AlertTriangle,
|
|
7
|
+
HelpCircle,
|
|
8
|
+
ShieldCheck,
|
|
9
|
+
ExternalLink,
|
|
10
|
+
Search,
|
|
11
|
+
} from 'lucide-react'
|
|
12
|
+
import type { ReactNode } from 'react'
|
|
13
|
+
import type { SiteIndexStatus } from './useGeneralSettings.js'
|
|
14
|
+
|
|
15
|
+
interface SiteIndexStatusCardProps {
|
|
16
|
+
status: SiteIndexStatus | null
|
|
17
|
+
loading: boolean
|
|
18
|
+
/** Resolve an action href relative to the admin (e.g. /seo?tab=audit). */
|
|
19
|
+
onNavigate?: (path: string) => void
|
|
20
|
+
/** Trigger the SEO audit flow when an action declares `action: run_seo_audit`. */
|
|
21
|
+
onRunAudit?: () => void
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const SEVERITY_STYLES: Record<SiteIndexStatus['severity'], { pill: string; icon: ReactNode }> = {
|
|
25
|
+
success: {
|
|
26
|
+
pill: 'bg-success/15 text-success border-success/30',
|
|
27
|
+
icon: <CheckCircle2 size={14} aria-hidden="true" />,
|
|
28
|
+
},
|
|
29
|
+
warning: {
|
|
30
|
+
pill: 'bg-warning/15 text-warning border-warning/30',
|
|
31
|
+
icon: <AlertTriangle size={14} aria-hidden="true" />,
|
|
32
|
+
},
|
|
33
|
+
danger: {
|
|
34
|
+
pill: 'bg-destructive/10 text-destructive border-destructive/30',
|
|
35
|
+
icon: <XCircle size={14} aria-hidden="true" />,
|
|
36
|
+
},
|
|
37
|
+
neutral: {
|
|
38
|
+
pill: 'bg-muted text-muted-foreground border-border',
|
|
39
|
+
icon: <HelpCircle size={14} aria-hidden="true" />,
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const CHECK_ICON: Record<string, ReactNode> = {
|
|
44
|
+
pass: <CheckCircle2 size={14} className="text-success" aria-hidden="true" />,
|
|
45
|
+
warning: <AlertTriangle size={14} className="text-warning" aria-hidden="true" />,
|
|
46
|
+
fail: <XCircle size={14} className="text-destructive" aria-hidden="true" />,
|
|
47
|
+
unknown: <HelpCircle size={14} className="text-muted-foreground" aria-hidden="true" />,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const CHECK_SR: Record<string, string> = {
|
|
51
|
+
pass: 'OK',
|
|
52
|
+
warning: 'Warning',
|
|
53
|
+
fail: 'Problem',
|
|
54
|
+
unknown: 'Unknown',
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function SiteIndexStatusCard({
|
|
58
|
+
status,
|
|
59
|
+
loading,
|
|
60
|
+
onNavigate,
|
|
61
|
+
onRunAudit,
|
|
62
|
+
}: SiteIndexStatusCardProps) {
|
|
63
|
+
return (
|
|
64
|
+
<section
|
|
65
|
+
aria-labelledby="site-index-status-heading"
|
|
66
|
+
className="border-border bg-card rounded-lg border p-4"
|
|
67
|
+
>
|
|
68
|
+
<div className="mb-3 flex items-start justify-between gap-3">
|
|
69
|
+
<div className="flex items-start gap-2">
|
|
70
|
+
<ShieldCheck size={18} className="text-muted-foreground mt-0.5" aria-hidden="true" />
|
|
71
|
+
<div>
|
|
72
|
+
<h3 id="site-index-status-heading" className="text-foreground text-base font-medium">
|
|
73
|
+
Site Index Status
|
|
74
|
+
</h3>
|
|
75
|
+
<p className="text-muted-foreground text-sm">
|
|
76
|
+
Current effective crawl and indexing state
|
|
77
|
+
</p>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
{status && (
|
|
81
|
+
<span
|
|
82
|
+
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium ${SEVERITY_STYLES[status.severity].pill}`}
|
|
83
|
+
>
|
|
84
|
+
{SEVERITY_STYLES[status.severity].icon}
|
|
85
|
+
<span className="sr-only">Status: </span>
|
|
86
|
+
{status.label}
|
|
87
|
+
</span>
|
|
88
|
+
)}
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
{loading && !status ? (
|
|
92
|
+
<div className="space-y-2" aria-hidden="true">
|
|
93
|
+
<div className="bg-muted h-4 w-2/3 animate-pulse rounded" />
|
|
94
|
+
<div className="bg-muted h-20 w-full animate-pulse rounded" />
|
|
95
|
+
</div>
|
|
96
|
+
) : !status ? (
|
|
97
|
+
<p className="text-muted-foreground text-sm">Index status is unavailable.</p>
|
|
98
|
+
) : (
|
|
99
|
+
<>
|
|
100
|
+
<p className="text-muted-foreground mb-3 text-sm">{status.description}</p>
|
|
101
|
+
|
|
102
|
+
<dl className="border-border grid grid-cols-1 gap-x-6 gap-y-2 rounded-md border p-3 sm:grid-cols-2">
|
|
103
|
+
{status.checks.map((check) => (
|
|
104
|
+
<div key={check.key} className="flex items-center justify-between gap-2 text-sm">
|
|
105
|
+
<dt className="text-muted-foreground">{check.label}</dt>
|
|
106
|
+
<dd className="text-foreground flex items-center gap-1.5 font-medium">
|
|
107
|
+
<span className="sr-only">{CHECK_SR[check.status]}: </span>
|
|
108
|
+
{CHECK_ICON[check.status]}
|
|
109
|
+
{check.value}
|
|
110
|
+
</dd>
|
|
111
|
+
</div>
|
|
112
|
+
))}
|
|
113
|
+
</dl>
|
|
114
|
+
|
|
115
|
+
{status.actions.length > 0 && (
|
|
116
|
+
<div className="mt-3 flex flex-wrap gap-2">
|
|
117
|
+
{status.actions.map((action) => (
|
|
118
|
+
<SiteIndexAction
|
|
119
|
+
key={action.label}
|
|
120
|
+
label={action.label}
|
|
121
|
+
href={action.href}
|
|
122
|
+
action={action.action}
|
|
123
|
+
onNavigate={onNavigate}
|
|
124
|
+
onRunAudit={onRunAudit}
|
|
125
|
+
/>
|
|
126
|
+
))}
|
|
127
|
+
</div>
|
|
128
|
+
)}
|
|
129
|
+
</>
|
|
130
|
+
)}
|
|
131
|
+
</section>
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function SiteIndexAction({
|
|
136
|
+
label,
|
|
137
|
+
href,
|
|
138
|
+
action,
|
|
139
|
+
onNavigate,
|
|
140
|
+
onRunAudit,
|
|
141
|
+
}: {
|
|
142
|
+
label: string
|
|
143
|
+
href?: string
|
|
144
|
+
action?: string
|
|
145
|
+
onNavigate?: (path: string) => void
|
|
146
|
+
onRunAudit?: () => void
|
|
147
|
+
}) {
|
|
148
|
+
const className =
|
|
149
|
+
'border-border text-foreground hover:bg-accent inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-sm transition-colors'
|
|
150
|
+
|
|
151
|
+
// The audit action stays in-app; robots/sitemap are real public URLs.
|
|
152
|
+
const isAudit = action === 'run_seo_audit'
|
|
153
|
+
const isAdminRoute = href?.startsWith('/seo')
|
|
154
|
+
const icon = isAudit ? (
|
|
155
|
+
<Search size={14} aria-hidden="true" />
|
|
156
|
+
) : (
|
|
157
|
+
<ExternalLink size={14} aria-hidden="true" />
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
if (isAudit && onRunAudit) {
|
|
161
|
+
return (
|
|
162
|
+
<button type="button" onClick={onRunAudit} className={className}>
|
|
163
|
+
{icon}
|
|
164
|
+
{label}
|
|
165
|
+
</button>
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (isAdminRoute && onNavigate) {
|
|
170
|
+
return (
|
|
171
|
+
<button type="button" onClick={() => onNavigate(href!)} className={className}>
|
|
172
|
+
{icon}
|
|
173
|
+
{label}
|
|
174
|
+
</button>
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return (
|
|
179
|
+
<a href={href} target="_blank" rel="noopener noreferrer" className={className}>
|
|
180
|
+
{icon}
|
|
181
|
+
{label}
|
|
182
|
+
</a>
|
|
183
|
+
)
|
|
184
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useId } from 'react'
|
|
4
|
+
import { CheckCircle2 } from 'lucide-react'
|
|
5
|
+
import { SettingsSourceBadge } from './components.js'
|
|
6
|
+
import type {
|
|
7
|
+
GeneralSettingsForm,
|
|
8
|
+
SettingsSourceMetadata,
|
|
9
|
+
ValidationIssue,
|
|
10
|
+
} from './useGeneralSettings.js'
|
|
11
|
+
|
|
12
|
+
const INPUT_CLASS =
|
|
13
|
+
'w-full rounded-md border border-border bg-input-background px-3 py-2 text-base text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-60'
|
|
14
|
+
const LABEL_CLASS = 'mb-1 flex items-center gap-2 text-sm font-medium text-foreground'
|
|
15
|
+
|
|
16
|
+
interface SiteInformationCardProps {
|
|
17
|
+
form: GeneralSettingsForm
|
|
18
|
+
setField: <K extends keyof GeneralSettingsForm>(key: K, value: GeneralSettingsForm[K]) => void
|
|
19
|
+
sources: Record<string, SettingsSourceMetadata>
|
|
20
|
+
issues: ValidationIssue[]
|
|
21
|
+
canEdit: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function SiteInformationCard({
|
|
25
|
+
form,
|
|
26
|
+
setField,
|
|
27
|
+
sources,
|
|
28
|
+
issues,
|
|
29
|
+
canEdit,
|
|
30
|
+
}: SiteInformationCardProps) {
|
|
31
|
+
const titleId = useId()
|
|
32
|
+
const taglineId = useId()
|
|
33
|
+
const urlId = useId()
|
|
34
|
+
const urlErrId = useId()
|
|
35
|
+
|
|
36
|
+
const titleIssue = issues.find((i) => i.field === 'siteTitle')
|
|
37
|
+
const urlIssue = issues.find((i) => i.field === 'siteUrl')
|
|
38
|
+
const urlEditable = sources.siteUrl?.editable !== false && canEdit
|
|
39
|
+
const urlValid = !urlIssue && form.siteUrl.trim().length > 0
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<section
|
|
43
|
+
aria-labelledby={`${titleId}-heading`}
|
|
44
|
+
className="border-border bg-card rounded-lg border p-4"
|
|
45
|
+
>
|
|
46
|
+
<h3 id={`${titleId}-heading`} className="text-foreground mb-4 text-base font-medium">
|
|
47
|
+
Site Information
|
|
48
|
+
</h3>
|
|
49
|
+
<div className="space-y-4">
|
|
50
|
+
<div>
|
|
51
|
+
<label htmlFor={titleId} className={LABEL_CLASS}>
|
|
52
|
+
Site Title
|
|
53
|
+
</label>
|
|
54
|
+
<input
|
|
55
|
+
id={titleId}
|
|
56
|
+
type="text"
|
|
57
|
+
value={form.siteTitle}
|
|
58
|
+
disabled={!canEdit}
|
|
59
|
+
aria-invalid={titleIssue?.severity === 'error'}
|
|
60
|
+
onChange={(e) => setField('siteTitle', e.target.value)}
|
|
61
|
+
className={INPUT_CLASS}
|
|
62
|
+
/>
|
|
63
|
+
{titleIssue && <p className="text-destructive mt-1 text-sm">{titleIssue.message}</p>}
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<div>
|
|
67
|
+
<label htmlFor={taglineId} className={LABEL_CLASS}>
|
|
68
|
+
Tagline
|
|
69
|
+
</label>
|
|
70
|
+
<input
|
|
71
|
+
id={taglineId}
|
|
72
|
+
type="text"
|
|
73
|
+
value={form.tagline}
|
|
74
|
+
disabled={!canEdit}
|
|
75
|
+
onChange={(e) => setField('tagline', e.target.value)}
|
|
76
|
+
className={INPUT_CLASS}
|
|
77
|
+
/>
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
<div>
|
|
81
|
+
<label htmlFor={urlId} className={LABEL_CLASS}>
|
|
82
|
+
Site URL
|
|
83
|
+
<SettingsSourceBadge meta={sources.siteUrl} />
|
|
84
|
+
</label>
|
|
85
|
+
<input
|
|
86
|
+
id={urlId}
|
|
87
|
+
type="url"
|
|
88
|
+
inputMode="url"
|
|
89
|
+
placeholder="https://example.com"
|
|
90
|
+
value={form.siteUrl}
|
|
91
|
+
disabled={!urlEditable}
|
|
92
|
+
aria-invalid={urlIssue?.severity === 'error'}
|
|
93
|
+
aria-describedby={urlIssue ? urlErrId : undefined}
|
|
94
|
+
onChange={(e) => setField('siteUrl', e.target.value)}
|
|
95
|
+
className={INPUT_CLASS}
|
|
96
|
+
/>
|
|
97
|
+
{urlIssue ? (
|
|
98
|
+
<p
|
|
99
|
+
id={urlErrId}
|
|
100
|
+
className={
|
|
101
|
+
urlIssue.severity === 'error'
|
|
102
|
+
? 'text-destructive mt-1 text-sm'
|
|
103
|
+
: 'text-warning mt-1 text-sm'
|
|
104
|
+
}
|
|
105
|
+
>
|
|
106
|
+
{urlIssue.message}
|
|
107
|
+
</p>
|
|
108
|
+
) : urlValid ? (
|
|
109
|
+
<p className="text-muted-foreground mt-1 flex items-center gap-1.5 text-sm">
|
|
110
|
+
<CheckCircle2 size={13} className="text-success" aria-hidden="true" />
|
|
111
|
+
Valid — used for canonical URLs, sitemap, robots.txt, and Open Graph.
|
|
112
|
+
</p>
|
|
113
|
+
) : (
|
|
114
|
+
<p className="text-muted-foreground mt-1 text-sm">
|
|
115
|
+
Used for canonical URLs, sitemap, robots.txt, and Open Graph fallbacks.
|
|
116
|
+
</p>
|
|
117
|
+
)}
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
</section>
|
|
121
|
+
)
|
|
122
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as Dialog from '@radix-ui/react-dialog'
|
|
4
|
+
import { AlertTriangle, Check, Loader2, Lock } from 'lucide-react'
|
|
5
|
+
import type { ReactNode } from 'react'
|
|
6
|
+
import type { SettingsSourceMetadata, ValidationIssue } from './useGeneralSettings.js'
|
|
7
|
+
|
|
8
|
+
const SOURCE_LABEL: Record<SettingsSourceMetadata['source'], string> = {
|
|
9
|
+
config: 'Config-managed',
|
|
10
|
+
environment: 'Environment-managed',
|
|
11
|
+
database: 'Editable',
|
|
12
|
+
plugin: 'Plugin setting',
|
|
13
|
+
computed: 'Computed',
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Small badge showing where a setting's value comes from. */
|
|
17
|
+
export function SettingsSourceBadge({ meta }: { meta?: SettingsSourceMetadata }) {
|
|
18
|
+
if (!meta || (meta.source === 'database' && meta.editable)) return null
|
|
19
|
+
const locked = !meta.editable
|
|
20
|
+
return (
|
|
21
|
+
<span
|
|
22
|
+
className="text-muted-foreground border-border bg-muted inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 text-xs"
|
|
23
|
+
title={meta.reason}
|
|
24
|
+
>
|
|
25
|
+
{locked && <Lock size={11} aria-hidden="true" />}
|
|
26
|
+
{SOURCE_LABEL[meta.source]}
|
|
27
|
+
</span>
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A labelled switch row with description, used for the SEO defaults. */
|
|
32
|
+
export function SettingsToggleRow({
|
|
33
|
+
label,
|
|
34
|
+
description,
|
|
35
|
+
checked,
|
|
36
|
+
onChange,
|
|
37
|
+
disabled = false,
|
|
38
|
+
badge,
|
|
39
|
+
}: {
|
|
40
|
+
label: string
|
|
41
|
+
description: string
|
|
42
|
+
checked: boolean
|
|
43
|
+
onChange: (value: boolean) => void
|
|
44
|
+
disabled?: boolean
|
|
45
|
+
badge?: ReactNode
|
|
46
|
+
}) {
|
|
47
|
+
return (
|
|
48
|
+
<div className="flex items-start justify-between gap-4">
|
|
49
|
+
<div className="flex-1">
|
|
50
|
+
<div className="flex items-center gap-2">
|
|
51
|
+
<span className="text-foreground text-base font-medium">{label}</span>
|
|
52
|
+
{badge}
|
|
53
|
+
</div>
|
|
54
|
+
<p className="text-muted-foreground mt-0.5 text-sm">{description}</p>
|
|
55
|
+
</div>
|
|
56
|
+
<button
|
|
57
|
+
type="button"
|
|
58
|
+
role="switch"
|
|
59
|
+
aria-checked={checked}
|
|
60
|
+
aria-label={label}
|
|
61
|
+
disabled={disabled}
|
|
62
|
+
onClick={() => onChange(!checked)}
|
|
63
|
+
className={`focus-visible:ring-ring relative mt-0.5 h-6 w-11 shrink-0 rounded-full transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 ${
|
|
64
|
+
checked ? 'bg-brand' : 'bg-switch-background'
|
|
65
|
+
}`}
|
|
66
|
+
>
|
|
67
|
+
<span
|
|
68
|
+
className={`absolute top-0.5 block h-5 w-5 rounded-full bg-white shadow-sm transition-transform ${
|
|
69
|
+
checked ? 'translate-x-[22px]' : 'translate-x-0.5'
|
|
70
|
+
}`}
|
|
71
|
+
/>
|
|
72
|
+
</button>
|
|
73
|
+
</div>
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Aggregated validation messages shown above the cards. */
|
|
78
|
+
export function SettingsValidationSummary({ issues }: { issues: ValidationIssue[] }) {
|
|
79
|
+
if (issues.length === 0) return null
|
|
80
|
+
const errors = issues.filter((i) => i.severity === 'error')
|
|
81
|
+
const warnings = issues.filter((i) => i.severity === 'warning')
|
|
82
|
+
const isError = errors.length > 0
|
|
83
|
+
return (
|
|
84
|
+
<div
|
|
85
|
+
role="alert"
|
|
86
|
+
aria-live="polite"
|
|
87
|
+
className={`flex items-start gap-3 rounded-lg border p-3 text-sm ${
|
|
88
|
+
isError
|
|
89
|
+
? 'border-destructive/30 bg-destructive/10 text-destructive'
|
|
90
|
+
: 'border-warning/30 bg-warning/10 text-warning'
|
|
91
|
+
}`}
|
|
92
|
+
>
|
|
93
|
+
<AlertTriangle size={16} className="mt-0.5 shrink-0" aria-hidden="true" />
|
|
94
|
+
<div className="space-y-1">
|
|
95
|
+
{[...errors, ...warnings].map((issue, i) => (
|
|
96
|
+
<p key={`${issue.field}-${i}`}>{issue.message}</p>
|
|
97
|
+
))}
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Sticky footer with Reset + Save, gated on dirty state. */
|
|
104
|
+
export function SettingsSaveBar({
|
|
105
|
+
dirty,
|
|
106
|
+
canEdit,
|
|
107
|
+
hasErrors,
|
|
108
|
+
saveState,
|
|
109
|
+
onSave,
|
|
110
|
+
onReset,
|
|
111
|
+
}: {
|
|
112
|
+
dirty: boolean
|
|
113
|
+
canEdit: boolean
|
|
114
|
+
hasErrors: boolean
|
|
115
|
+
saveState: 'idle' | 'saving' | 'success' | 'error'
|
|
116
|
+
onSave: () => void
|
|
117
|
+
onReset: () => void
|
|
118
|
+
}) {
|
|
119
|
+
const saving = saveState === 'saving'
|
|
120
|
+
return (
|
|
121
|
+
<div className="border-border bg-background/80 sticky bottom-0 z-10 flex items-center justify-end gap-4 border-t py-3 backdrop-blur">
|
|
122
|
+
{saveState === 'success' && !dirty && (
|
|
123
|
+
<span className="text-success flex items-center gap-1.5 text-sm" aria-live="polite">
|
|
124
|
+
<Check size={16} aria-hidden="true" /> Saved
|
|
125
|
+
</span>
|
|
126
|
+
)}
|
|
127
|
+
<button
|
|
128
|
+
type="button"
|
|
129
|
+
onClick={onReset}
|
|
130
|
+
disabled={!dirty || saving}
|
|
131
|
+
className="text-muted-foreground hover:text-foreground rounded-md px-3 py-2 text-base transition-colors disabled:cursor-not-allowed disabled:opacity-40"
|
|
132
|
+
>
|
|
133
|
+
Reset
|
|
134
|
+
</button>
|
|
135
|
+
<button
|
|
136
|
+
type="button"
|
|
137
|
+
onClick={onSave}
|
|
138
|
+
disabled={!canEdit || !dirty || hasErrors || saving}
|
|
139
|
+
className="bg-brand text-brand-foreground focus-visible:ring-ring inline-flex items-center gap-2 rounded-md px-5 py-2 text-base font-medium transition-opacity hover:opacity-90 focus-visible:ring-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
|
140
|
+
>
|
|
141
|
+
{saving && <Loader2 size={16} className="animate-spin" aria-hidden="true" />}
|
|
142
|
+
{saving ? 'Saving…' : 'Save Changes'}
|
|
143
|
+
</button>
|
|
144
|
+
</div>
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Confirmation dialog for dangerous indexing changes. */
|
|
149
|
+
export function ConfirmDangerousSettingDialog({
|
|
150
|
+
open,
|
|
151
|
+
title,
|
|
152
|
+
description,
|
|
153
|
+
confirmLabel = 'Continue',
|
|
154
|
+
onConfirm,
|
|
155
|
+
onCancel,
|
|
156
|
+
}: {
|
|
157
|
+
open: boolean
|
|
158
|
+
title: string
|
|
159
|
+
description: string
|
|
160
|
+
confirmLabel?: string
|
|
161
|
+
onConfirm: () => void
|
|
162
|
+
onCancel: () => void
|
|
163
|
+
}) {
|
|
164
|
+
return (
|
|
165
|
+
<Dialog.Root open={open} onOpenChange={(o) => !o && onCancel()}>
|
|
166
|
+
<Dialog.Portal>
|
|
167
|
+
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/50" />
|
|
168
|
+
<Dialog.Content className="bg-card text-card-foreground fixed top-1/2 left-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg p-6 shadow-lg">
|
|
169
|
+
<div className="mb-3 flex items-center gap-2">
|
|
170
|
+
<span className="bg-warning/15 text-warning flex h-9 w-9 items-center justify-center rounded-full">
|
|
171
|
+
<AlertTriangle size={20} aria-hidden="true" />
|
|
172
|
+
</span>
|
|
173
|
+
<Dialog.Title className="text-foreground text-lg font-medium">{title}</Dialog.Title>
|
|
174
|
+
</div>
|
|
175
|
+
<Dialog.Description className="text-muted-foreground mb-5 text-sm">
|
|
176
|
+
{description}
|
|
177
|
+
</Dialog.Description>
|
|
178
|
+
<div className="flex items-center justify-end gap-3">
|
|
179
|
+
<button
|
|
180
|
+
type="button"
|
|
181
|
+
onClick={onCancel}
|
|
182
|
+
className="border-border text-foreground hover:bg-accent rounded-md border px-4 py-2 text-base transition-colors"
|
|
183
|
+
>
|
|
184
|
+
Cancel
|
|
185
|
+
</button>
|
|
186
|
+
<button
|
|
187
|
+
type="button"
|
|
188
|
+
onClick={onConfirm}
|
|
189
|
+
className="bg-destructive text-destructive-foreground rounded-md px-4 py-2 text-base font-medium transition-opacity hover:opacity-90"
|
|
190
|
+
>
|
|
191
|
+
{confirmLabel}
|
|
192
|
+
</button>
|
|
193
|
+
</div>
|
|
194
|
+
</Dialog.Content>
|
|
195
|
+
</Dialog.Portal>
|
|
196
|
+
</Dialog.Root>
|
|
197
|
+
)
|
|
198
|
+
}
|