@actuate-media/cms-admin 0.25.0 → 0.27.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/appearance-settings.render.test.d.ts +2 -0
- package/dist/__tests__/views/appearance-settings.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/appearance-settings.render.test.js +95 -0
- package/dist/__tests__/views/appearance-settings.render.test.js.map +1 -0
- 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/components/ThemeProvider.d.ts +47 -4
- package/dist/components/ThemeProvider.d.ts.map +1 -1
- package/dist/components/ThemeProvider.js +173 -27
- package/dist/components/ThemeProvider.js.map +1 -1
- package/dist/components/ui/Badge.d.ts +1 -1
- package/dist/components/ui/Button.d.ts +1 -1
- package/dist/components/ui/Card.d.ts +2 -2
- package/dist/components/ui/Input.d.ts +1 -1
- package/dist/components/ui/Select.d.ts +1 -1
- package/dist/layout/Sidebar.d.ts.map +1 -1
- package/dist/layout/Sidebar.js +47 -8
- package/dist/layout/Sidebar.js.map +1 -1
- package/dist/views/Settings.d.ts +3 -1
- package/dist/views/Settings.d.ts.map +1 -1
- package/dist/views/Settings.js +10 -33
- package/dist/views/Settings.js.map +1 -1
- package/dist/views/settings/AdminThemeCard.d.ts +6 -0
- package/dist/views/settings/AdminThemeCard.d.ts.map +1 -0
- package/dist/views/settings/AdminThemeCard.js +31 -0
- package/dist/views/settings/AdminThemeCard.js.map +1 -0
- package/dist/views/settings/AppearanceTab.d.ts +7 -0
- package/dist/views/settings/AppearanceTab.d.ts.map +1 -0
- package/dist/views/settings/AppearanceTab.js +24 -0
- package/dist/views/settings/AppearanceTab.js.map +1 -0
- package/dist/views/settings/BrandPreviewCard.d.ts +5 -0
- package/dist/views/settings/BrandPreviewCard.d.ts.map +1 -0
- package/dist/views/settings/BrandPreviewCard.js +18 -0
- package/dist/views/settings/BrandPreviewCard.js.map +1 -0
- package/dist/views/settings/BrandingCard.d.ts +6 -0
- package/dist/views/settings/BrandingCard.d.ts.map +1 -0
- package/dist/views/settings/BrandingCard.js +38 -0
- package/dist/views/settings/BrandingCard.js.map +1 -0
- 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/TypographyMotionCard.d.ts +6 -0
- package/dist/views/settings/TypographyMotionCard.d.ts.map +1 -0
- package/dist/views/settings/TypographyMotionCard.js +8 -0
- package/dist/views/settings/TypographyMotionCard.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/useAppearanceSettings.d.ts +69 -0
- package/dist/views/settings/useAppearanceSettings.d.ts.map +1 -0
- package/dist/views/settings/useAppearanceSettings.js +344 -0
- package/dist/views/settings/useAppearanceSettings.js.map +1 -0
- package/dist/views/settings/useGeneralSettings.d.ts +82 -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/appearance-settings.render.test.tsx +132 -0
- package/src/__tests__/views/general-settings.render.test.tsx +135 -0
- package/src/components/ThemeProvider.tsx +249 -36
- package/src/layout/Sidebar.tsx +81 -13
- package/src/styles/theme.css +32 -2
- package/src/views/Settings.tsx +19 -155
- package/src/views/settings/AdminThemeCard.tsx +145 -0
- package/src/views/settings/AppearanceTab.tsx +81 -0
- package/src/views/settings/BrandPreviewCard.tsx +125 -0
- package/src/views/settings/BrandingCard.tsx +332 -0
- 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/TypographyMotionCard.tsx +65 -0
- package/src/views/settings/components.tsx +198 -0
- package/src/views/settings/useAppearanceSettings.ts +513 -0
- package/src/views/settings/useGeneralSettings.ts +379 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useId, useState } from 'react'
|
|
4
|
+
import { ConfirmDangerousSettingDialog, SettingsToggleRow } from './components.js'
|
|
5
|
+
import type { GeneralSettingsForm, SiteIndexStatus } from './useGeneralSettings.js'
|
|
6
|
+
|
|
7
|
+
interface SeoRobotsDefaultsCardProps {
|
|
8
|
+
form: GeneralSettingsForm
|
|
9
|
+
setField: <K extends keyof GeneralSettingsForm>(key: K, value: GeneralSettingsForm[K]) => void
|
|
10
|
+
isProduction: boolean
|
|
11
|
+
indexStatus: SiteIndexStatus | null
|
|
12
|
+
canEdit: boolean
|
|
13
|
+
onNavigate?: (path: string) => void
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface PendingChange {
|
|
17
|
+
field: keyof GeneralSettingsForm
|
|
18
|
+
value: boolean
|
|
19
|
+
title: string
|
|
20
|
+
description: string
|
|
21
|
+
confirmLabel: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function SeoRobotsDefaultsCard({
|
|
25
|
+
form,
|
|
26
|
+
setField,
|
|
27
|
+
isProduction,
|
|
28
|
+
indexStatus,
|
|
29
|
+
canEdit,
|
|
30
|
+
onNavigate,
|
|
31
|
+
}: SeoRobotsDefaultsCardProps) {
|
|
32
|
+
const headingId = useId()
|
|
33
|
+
const [pending, setPending] = useState<PendingChange | null>(null)
|
|
34
|
+
|
|
35
|
+
// Some toggles can take the whole production site out of search results, so
|
|
36
|
+
// they route through a confirmation step before being applied.
|
|
37
|
+
function requestToggle(field: keyof GeneralSettingsForm, value: boolean) {
|
|
38
|
+
if (field === 'defaultNoIndex' && value && isProduction) {
|
|
39
|
+
setPending({
|
|
40
|
+
field,
|
|
41
|
+
value,
|
|
42
|
+
title: 'Block this production site from search engines?',
|
|
43
|
+
description:
|
|
44
|
+
'This may prevent search engines from indexing your production site. Pages will be served with a noindex directive unless they explicitly opt in.',
|
|
45
|
+
confirmLabel: 'Enable No Index',
|
|
46
|
+
})
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
if (field === 'noIndexNonProduction' && !value) {
|
|
50
|
+
setPending({
|
|
51
|
+
field,
|
|
52
|
+
value,
|
|
53
|
+
title: 'Allow non-production environments to be indexed?',
|
|
54
|
+
description:
|
|
55
|
+
'Preview and staging environments may become indexable if publicly accessible. Search engines could index unfinished content.',
|
|
56
|
+
confirmLabel: 'Turn Off',
|
|
57
|
+
})
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
setField(field, value)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const effectiveProd = form.defaultNoIndex ? 'Blocked' : 'Indexable'
|
|
64
|
+
const sitemapCheck = indexStatus?.checks.find((c) => c.key === 'sitemap')
|
|
65
|
+
const sitemapStatus = sitemapCheck ? (sitemapCheck.value === 'Yes' ? 'Enabled' : 'Disabled') : '—'
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<section aria-labelledby={headingId} className="border-border bg-card rounded-lg border p-4">
|
|
69
|
+
<h3 id={headingId} className="text-foreground mb-1 text-base font-medium">
|
|
70
|
+
SEO & Robots Defaults
|
|
71
|
+
</h3>
|
|
72
|
+
<p className="text-muted-foreground mb-4 text-sm">
|
|
73
|
+
Set the site-wide default for search engine indexing. Individual pages can inherit or
|
|
74
|
+
override these rules in their SEO panel. These share the same configuration as the SEO
|
|
75
|
+
section.
|
|
76
|
+
</p>
|
|
77
|
+
|
|
78
|
+
<div className="space-y-4">
|
|
79
|
+
<SettingsToggleRow
|
|
80
|
+
label="Default No Index"
|
|
81
|
+
description="Ask search engines not to index pages unless a page explicitly allows indexing."
|
|
82
|
+
checked={form.defaultNoIndex}
|
|
83
|
+
disabled={!canEdit}
|
|
84
|
+
onChange={(v) => requestToggle('defaultNoIndex', v)}
|
|
85
|
+
/>
|
|
86
|
+
<SettingsToggleRow
|
|
87
|
+
label="Default No Follow"
|
|
88
|
+
description="Ask search engines not to follow page links unless a page explicitly allows following."
|
|
89
|
+
checked={form.defaultNoFollow}
|
|
90
|
+
disabled={!canEdit}
|
|
91
|
+
onChange={(v) => requestToggle('defaultNoFollow', v)}
|
|
92
|
+
/>
|
|
93
|
+
<SettingsToggleRow
|
|
94
|
+
label="Noindex Non-Production Environments"
|
|
95
|
+
description="Force noindex when the deployed environment is not production."
|
|
96
|
+
checked={form.noIndexNonProduction}
|
|
97
|
+
disabled={!canEdit}
|
|
98
|
+
onChange={(v) => requestToggle('noIndexNonProduction', v)}
|
|
99
|
+
/>
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
<div className="border-border mt-4 space-y-2 border-t pt-4">
|
|
103
|
+
<SummaryRow
|
|
104
|
+
label="Effective production indexing"
|
|
105
|
+
value={effectiveProd}
|
|
106
|
+
danger={form.defaultNoIndex}
|
|
107
|
+
/>
|
|
108
|
+
<SummaryRow
|
|
109
|
+
label="Effective current environment"
|
|
110
|
+
value={indexStatus?.label ?? '—'}
|
|
111
|
+
danger={indexStatus?.severity === 'danger'}
|
|
112
|
+
/>
|
|
113
|
+
<SummaryRow label="Sitemap status" value={sitemapStatus} />
|
|
114
|
+
{onNavigate && (
|
|
115
|
+
<button
|
|
116
|
+
type="button"
|
|
117
|
+
onClick={() => onNavigate('/seo?tab=technical')}
|
|
118
|
+
className="text-brand mt-1 text-sm hover:underline"
|
|
119
|
+
>
|
|
120
|
+
Manage advanced robots.txt & sitemap in SEO →
|
|
121
|
+
</button>
|
|
122
|
+
)}
|
|
123
|
+
</div>
|
|
124
|
+
|
|
125
|
+
<ConfirmDangerousSettingDialog
|
|
126
|
+
open={pending !== null}
|
|
127
|
+
title={pending?.title ?? ''}
|
|
128
|
+
description={pending?.description ?? ''}
|
|
129
|
+
confirmLabel={pending?.confirmLabel}
|
|
130
|
+
onConfirm={() => {
|
|
131
|
+
if (pending) setField(pending.field, pending.value)
|
|
132
|
+
setPending(null)
|
|
133
|
+
}}
|
|
134
|
+
onCancel={() => setPending(null)}
|
|
135
|
+
/>
|
|
136
|
+
</section>
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function SummaryRow({ label, value, danger }: { label: string; value: string; danger?: boolean }) {
|
|
141
|
+
return (
|
|
142
|
+
<div className="flex items-center justify-between gap-2 text-sm">
|
|
143
|
+
<span className="text-muted-foreground">{label}</span>
|
|
144
|
+
<span className={danger ? 'text-destructive font-medium' : 'text-foreground font-medium'}>
|
|
145
|
+
{value}
|
|
146
|
+
</span>
|
|
147
|
+
</div>
|
|
148
|
+
)
|
|
149
|
+
}
|
|
@@ -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,65 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { SettingsToggleRow } from './components.js'
|
|
4
|
+
import { AVAILABLE_FONTS } from '../../components/ThemeProvider.js'
|
|
5
|
+
import type { UseAppearanceSettings } from './useAppearanceSettings.js'
|
|
6
|
+
|
|
7
|
+
export function TypographyMotionCard({
|
|
8
|
+
s,
|
|
9
|
+
canEdit,
|
|
10
|
+
}: {
|
|
11
|
+
s: UseAppearanceSettings
|
|
12
|
+
canEdit: boolean
|
|
13
|
+
}) {
|
|
14
|
+
return (
|
|
15
|
+
<section className="border-border bg-card rounded-lg border p-6">
|
|
16
|
+
<header className="mb-4">
|
|
17
|
+
<h3 className="text-foreground text-lg font-medium">Typography & Motion</h3>
|
|
18
|
+
<p className="text-muted-foreground mt-0.5 text-sm">
|
|
19
|
+
Admin UI font and motion preferences.
|
|
20
|
+
</p>
|
|
21
|
+
</header>
|
|
22
|
+
|
|
23
|
+
<div className="space-y-5">
|
|
24
|
+
<div>
|
|
25
|
+
<label
|
|
26
|
+
htmlFor="content-font"
|
|
27
|
+
className="text-foreground mb-1 block text-base font-medium"
|
|
28
|
+
>
|
|
29
|
+
Content font
|
|
30
|
+
</label>
|
|
31
|
+
<p className="text-muted-foreground mb-2 text-sm">
|
|
32
|
+
Applies to the admin UI. Falls back to the system font if not installed.
|
|
33
|
+
</p>
|
|
34
|
+
<select
|
|
35
|
+
id="content-font"
|
|
36
|
+
value={s.appearance.font}
|
|
37
|
+
disabled={!canEdit}
|
|
38
|
+
onChange={(e) => s.setFont(e.target.value)}
|
|
39
|
+
className="border-border bg-input-background text-foreground focus-visible:ring-ring w-full max-w-md rounded-md border px-3 py-2 text-base focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50"
|
|
40
|
+
>
|
|
41
|
+
{AVAILABLE_FONTS.map((font) => (
|
|
42
|
+
<option key={font} value={font}>
|
|
43
|
+
{font}
|
|
44
|
+
</option>
|
|
45
|
+
))}
|
|
46
|
+
{/* Preserve a config-defined font that isn't in the curated list. */}
|
|
47
|
+
{!AVAILABLE_FONTS.includes(s.appearance.font as (typeof AVAILABLE_FONTS)[number]) && (
|
|
48
|
+
<option value={s.appearance.font}>{s.appearance.font}</option>
|
|
49
|
+
)}
|
|
50
|
+
</select>
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
<div className="border-border border-t pt-4">
|
|
54
|
+
<SettingsToggleRow
|
|
55
|
+
label="Reduce motion"
|
|
56
|
+
description="Minimize animations and transitions across the admin. Focus and state changes are preserved."
|
|
57
|
+
checked={s.appearance.reduceMotion}
|
|
58
|
+
onChange={s.setReduceMotion}
|
|
59
|
+
disabled={!canEdit}
|
|
60
|
+
/>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
</section>
|
|
64
|
+
)
|
|
65
|
+
}
|