@actuate-media/cms-admin 0.31.0 → 0.33.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/__tests__/views/seo-settings.render.test.d.ts +2 -0
- package/dist/__tests__/views/seo-settings.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/seo-settings.render.test.js +152 -0
- package/dist/__tests__/views/seo-settings.render.test.js.map +1 -0
- package/dist/__tests__/views/settings-save-footer.render.test.js +16 -9
- package/dist/__tests__/views/settings-save-footer.render.test.js.map +1 -1
- package/dist/__tests__/views/user-detail-drawer.render.test.js +31 -0
- package/dist/__tests__/views/user-detail-drawer.render.test.js.map +1 -1
- package/dist/actuate-admin.css +1 -1
- package/dist/components/MediaPickerModal.d.ts +15 -1
- package/dist/components/MediaPickerModal.d.ts.map +1 -1
- package/dist/components/MediaPickerModal.js +17 -1
- package/dist/components/MediaPickerModal.js.map +1 -1
- package/dist/views/Settings.js +2 -2
- package/dist/views/Settings.js.map +1 -1
- package/dist/views/settings/SeoSettingsTab.d.ts +7 -0
- package/dist/views/settings/SeoSettingsTab.d.ts.map +1 -0
- package/dist/views/settings/SeoSettingsTab.js +126 -0
- package/dist/views/settings/SeoSettingsTab.js.map +1 -0
- package/dist/views/settings/useSeoSettings.d.ts +79 -0
- package/dist/views/settings/useSeoSettings.d.ts.map +1 -0
- package/dist/views/settings/useSeoSettings.js +278 -0
- package/dist/views/settings/useSeoSettings.js.map +1 -0
- package/dist/views/users/UserDetailDrawer.d.ts.map +1 -1
- package/dist/views/users/UserDetailDrawer.js +45 -5
- package/dist/views/users/UserDetailDrawer.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/views/seo-settings.render.test.tsx +219 -0
- package/src/__tests__/views/settings-save-footer.render.test.tsx +16 -9
- package/src/__tests__/views/user-detail-drawer.render.test.tsx +43 -0
- package/src/components/MediaPickerModal.tsx +39 -2
- package/src/views/Settings.tsx +2 -2
- package/src/views/settings/SeoSettingsTab.tsx +658 -0
- package/src/views/settings/useSeoSettings.ts +374 -0
- package/src/views/users/UserDetailDrawer.tsx +169 -24
|
@@ -0,0 +1,658 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useId, useState } from 'react'
|
|
4
|
+
import {
|
|
5
|
+
AlertTriangle,
|
|
6
|
+
CheckCircle2,
|
|
7
|
+
ExternalLink,
|
|
8
|
+
ImageIcon,
|
|
9
|
+
Info,
|
|
10
|
+
Loader2,
|
|
11
|
+
RefreshCw,
|
|
12
|
+
Search,
|
|
13
|
+
Upload,
|
|
14
|
+
X,
|
|
15
|
+
} from 'lucide-react'
|
|
16
|
+
import { MediaPickerModal } from '../../components/MediaPickerModal.js'
|
|
17
|
+
import { SEOConfigPanel } from '../../components/SEOConfigPanel.js'
|
|
18
|
+
import {
|
|
19
|
+
ConfirmDangerousSettingDialog,
|
|
20
|
+
SettingsSaveBar,
|
|
21
|
+
SettingsSourceBadge,
|
|
22
|
+
SettingsToggleRow,
|
|
23
|
+
SettingsValidationSummary,
|
|
24
|
+
} from './components.js'
|
|
25
|
+
import {
|
|
26
|
+
META_DESCRIPTION_MAX,
|
|
27
|
+
META_DESCRIPTION_MIN,
|
|
28
|
+
useSeoSettings,
|
|
29
|
+
validateTitleTemplateClient,
|
|
30
|
+
type SeoSettingsForm,
|
|
31
|
+
type UseSeoSettings,
|
|
32
|
+
} from './useSeoSettings.js'
|
|
33
|
+
|
|
34
|
+
const INPUT_CLASS =
|
|
35
|
+
'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'
|
|
36
|
+
const LABEL_CLASS = 'mb-1 flex items-center gap-2 text-sm font-medium text-foreground'
|
|
37
|
+
|
|
38
|
+
const SUPPORTED_TOKENS = [
|
|
39
|
+
'%page%',
|
|
40
|
+
'%site%',
|
|
41
|
+
'%type%',
|
|
42
|
+
'%separator%',
|
|
43
|
+
'%author%',
|
|
44
|
+
'%date%',
|
|
45
|
+
'%category%',
|
|
46
|
+
'%tag%',
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
/** Sample data used to render a live title preview. */
|
|
50
|
+
function previewTitle(template: string, siteName: string): string {
|
|
51
|
+
const tpl = template.trim() === '' ? '%page% — %site%' : template
|
|
52
|
+
const vars: Record<string, string> = {
|
|
53
|
+
page: 'Sample Page Title',
|
|
54
|
+
site: siteName || 'Your Site',
|
|
55
|
+
type: 'Page',
|
|
56
|
+
separator: '|',
|
|
57
|
+
author: 'Jane Doe',
|
|
58
|
+
date: new Date().toISOString().slice(0, 10),
|
|
59
|
+
category: 'News',
|
|
60
|
+
tag: 'guide',
|
|
61
|
+
}
|
|
62
|
+
return tpl
|
|
63
|
+
.replace(/%(\w+)%/g, (_, k: string) => vars[k] ?? '')
|
|
64
|
+
.replace(/\s+/g, ' ')
|
|
65
|
+
.replace(/^[\s\-–—|·•]+/, '')
|
|
66
|
+
.replace(/[\s\-–—|·•]+$/, '')
|
|
67
|
+
.trim()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface SeoSettingsTabProps {
|
|
71
|
+
onNavigate?: (path: string) => void
|
|
72
|
+
/** Current user's role. Only ADMIN can persist changes. */
|
|
73
|
+
role?: string
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function SeoSettingsTab({ onNavigate, role }: SeoSettingsTabProps) {
|
|
77
|
+
const canEdit = role === 'ADMIN'
|
|
78
|
+
const seo = useSeoSettings(canEdit)
|
|
79
|
+
|
|
80
|
+
if (seo.loading) {
|
|
81
|
+
return (
|
|
82
|
+
<div className="space-y-4" aria-busy="true" aria-live="polite">
|
|
83
|
+
<span className="sr-only">Loading SEO settings…</span>
|
|
84
|
+
{[0, 1, 2, 3].map((i) => (
|
|
85
|
+
<div key={i} className="border-border bg-card rounded-lg border p-4">
|
|
86
|
+
<div className="bg-muted mb-4 h-4 w-40 animate-pulse rounded" />
|
|
87
|
+
<div className="space-y-3">
|
|
88
|
+
<div className="bg-muted h-9 w-full animate-pulse rounded" />
|
|
89
|
+
<div className="bg-muted h-9 w-full animate-pulse rounded" />
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
))}
|
|
93
|
+
</div>
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<div className="space-y-4">
|
|
99
|
+
<SeoWorkspaceCallout onNavigate={onNavigate} />
|
|
100
|
+
|
|
101
|
+
{seo.loadError && (
|
|
102
|
+
<div
|
|
103
|
+
role="alert"
|
|
104
|
+
className="border-destructive/30 bg-destructive/10 text-destructive flex items-center gap-3 rounded-lg border p-3 text-sm"
|
|
105
|
+
>
|
|
106
|
+
<AlertTriangle size={16} className="shrink-0" aria-hidden="true" />
|
|
107
|
+
<span className="flex-1">{seo.loadError}</span>
|
|
108
|
+
<button
|
|
109
|
+
type="button"
|
|
110
|
+
onClick={seo.refetch}
|
|
111
|
+
className="border-destructive/40 rounded-md border px-3 py-1 transition-colors hover:opacity-80"
|
|
112
|
+
>
|
|
113
|
+
Retry
|
|
114
|
+
</button>
|
|
115
|
+
</div>
|
|
116
|
+
)}
|
|
117
|
+
|
|
118
|
+
{!canEdit && (
|
|
119
|
+
<div className="border-border bg-muted text-muted-foreground flex items-center gap-2 rounded-lg border p-3 text-sm">
|
|
120
|
+
<Info size={16} className="shrink-0" aria-hidden="true" />
|
|
121
|
+
You have read-only access to SEO defaults. Only administrators can make changes.
|
|
122
|
+
</div>
|
|
123
|
+
)}
|
|
124
|
+
|
|
125
|
+
<SettingsValidationSummary issues={seo.issues} />
|
|
126
|
+
|
|
127
|
+
<MetaDefaultsCard seo={seo} canEdit={canEdit} />
|
|
128
|
+
<SearchEngineVerificationCard seo={seo} canEdit={canEdit} />
|
|
129
|
+
<SitemapDefaultsCard seo={seo} canEdit={canEdit} onNavigate={onNavigate} />
|
|
130
|
+
|
|
131
|
+
<details className="border-border bg-card group rounded-lg border">
|
|
132
|
+
<summary className="text-foreground flex cursor-pointer list-none items-center justify-between p-4 text-base font-medium">
|
|
133
|
+
<span>Advanced & per-collection SEO</span>
|
|
134
|
+
<span className="text-muted-foreground text-sm font-normal group-open:hidden">Show</span>
|
|
135
|
+
<span className="text-muted-foreground hidden text-sm font-normal group-open:inline">
|
|
136
|
+
Hide
|
|
137
|
+
</span>
|
|
138
|
+
</summary>
|
|
139
|
+
<div className="border-border border-t p-4">
|
|
140
|
+
<p className="text-muted-foreground mb-4 text-sm">
|
|
141
|
+
Site name, social handle, per-collection schema, and route toggles. These share the same
|
|
142
|
+
SEO configuration as the defaults above.
|
|
143
|
+
</p>
|
|
144
|
+
<SEOConfigPanel />
|
|
145
|
+
</div>
|
|
146
|
+
</details>
|
|
147
|
+
|
|
148
|
+
{seo.saveState === 'error' && seo.saveError && (
|
|
149
|
+
<div
|
|
150
|
+
role="alert"
|
|
151
|
+
className="border-destructive/30 bg-destructive/10 text-destructive flex items-center gap-3 rounded-lg border p-3 text-sm"
|
|
152
|
+
>
|
|
153
|
+
<AlertTriangle size={16} className="shrink-0" aria-hidden="true" />
|
|
154
|
+
<span className="flex-1">{seo.saveError}</span>
|
|
155
|
+
<button
|
|
156
|
+
type="button"
|
|
157
|
+
onClick={seo.save}
|
|
158
|
+
className="border-destructive/40 rounded-md border px-3 py-1 transition-colors hover:opacity-80"
|
|
159
|
+
>
|
|
160
|
+
Retry
|
|
161
|
+
</button>
|
|
162
|
+
</div>
|
|
163
|
+
)}
|
|
164
|
+
|
|
165
|
+
<SettingsSaveBar
|
|
166
|
+
dirty={seo.dirty}
|
|
167
|
+
canEdit={canEdit}
|
|
168
|
+
hasErrors={seo.hasErrors}
|
|
169
|
+
saveState={seo.saveState}
|
|
170
|
+
onSave={seo.save}
|
|
171
|
+
onReset={seo.reset}
|
|
172
|
+
/>
|
|
173
|
+
</div>
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// Full SEO workspace callout
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
function SeoWorkspaceCallout({ onNavigate }: { onNavigate?: (path: string) => void }) {
|
|
182
|
+
return (
|
|
183
|
+
<div className="border-border bg-accent/40 flex items-center gap-4 rounded-lg border p-4">
|
|
184
|
+
<span className="bg-brand/10 text-brand flex h-10 w-10 shrink-0 items-center justify-center rounded-lg">
|
|
185
|
+
<Search size={20} aria-hidden="true" />
|
|
186
|
+
</span>
|
|
187
|
+
<div className="flex-1">
|
|
188
|
+
<p className="text-foreground text-base font-medium">Looking for the full SEO workspace?</p>
|
|
189
|
+
<p className="text-muted-foreground text-sm">
|
|
190
|
+
These are site-wide defaults. Per-page meta, audits, redirects and AI recovery live in the
|
|
191
|
+
SEO dashboard.
|
|
192
|
+
</p>
|
|
193
|
+
</div>
|
|
194
|
+
<button
|
|
195
|
+
type="button"
|
|
196
|
+
onClick={() => onNavigate?.('/seo')}
|
|
197
|
+
className="border-border text-foreground hover:bg-accent inline-flex shrink-0 items-center gap-2 rounded-md border px-4 py-2 text-base font-medium transition-colors"
|
|
198
|
+
>
|
|
199
|
+
<ExternalLink size={16} aria-hidden="true" />
|
|
200
|
+
Open SEO
|
|
201
|
+
</button>
|
|
202
|
+
</div>
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
// Meta Defaults
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
function MetaDefaultsCard({ seo, canEdit }: { seo: UseSeoSettings; canEdit: boolean }) {
|
|
211
|
+
const headingId = useId()
|
|
212
|
+
const templateId = useId()
|
|
213
|
+
const descId = useId()
|
|
214
|
+
const [pickerOpen, setPickerOpen] = useState(false)
|
|
215
|
+
|
|
216
|
+
const { form, setField, issues, sources } = seo
|
|
217
|
+
const templateIssue = issues.find((i) => i.field === 'titleTemplate')
|
|
218
|
+
const descIssue = issues.find((i) => i.field === 'defaultMetaDescription')
|
|
219
|
+
const tplValidation = validateTitleTemplateClient(form.titleTemplate)
|
|
220
|
+
|
|
221
|
+
const descLen = form.defaultMetaDescription.trim().length
|
|
222
|
+
const descCountClass =
|
|
223
|
+
descLen > META_DESCRIPTION_MAX || (descLen > 0 && descLen < META_DESCRIPTION_MIN)
|
|
224
|
+
? 'text-warning'
|
|
225
|
+
: 'text-muted-foreground'
|
|
226
|
+
|
|
227
|
+
const templateEditable = sources.titleTemplate?.editable !== false && canEdit
|
|
228
|
+
const descEditable = sources.defaultMetaDescription?.editable !== false && canEdit
|
|
229
|
+
const imageEditable = sources.defaultOgImage?.editable !== false && canEdit
|
|
230
|
+
|
|
231
|
+
return (
|
|
232
|
+
<section aria-labelledby={headingId} className="border-border bg-card rounded-lg border p-4">
|
|
233
|
+
<h3 id={headingId} className="text-foreground mb-4 text-base font-medium">
|
|
234
|
+
Meta Defaults
|
|
235
|
+
</h3>
|
|
236
|
+
|
|
237
|
+
<div className="space-y-4">
|
|
238
|
+
{/* Title template */}
|
|
239
|
+
<div>
|
|
240
|
+
<label htmlFor={templateId} className={LABEL_CLASS}>
|
|
241
|
+
Title template
|
|
242
|
+
<SettingsSourceBadge meta={sources.titleTemplate} />
|
|
243
|
+
</label>
|
|
244
|
+
<input
|
|
245
|
+
id={templateId}
|
|
246
|
+
type="text"
|
|
247
|
+
value={form.titleTemplate}
|
|
248
|
+
disabled={!templateEditable}
|
|
249
|
+
placeholder="%page% — %site%"
|
|
250
|
+
aria-invalid={templateIssue?.severity === 'error'}
|
|
251
|
+
onChange={(e) => setField('titleTemplate', e.target.value)}
|
|
252
|
+
className={INPUT_CLASS}
|
|
253
|
+
/>
|
|
254
|
+
<p className="text-muted-foreground mt-1 text-sm">
|
|
255
|
+
Tokens: {SUPPORTED_TOKENS.join(' ')}. Applied to all pages without a custom SEO title.
|
|
256
|
+
</p>
|
|
257
|
+
{templateIssue ? (
|
|
258
|
+
<p className="text-destructive mt-1 text-sm">{templateIssue.message}</p>
|
|
259
|
+
) : (
|
|
260
|
+
tplValidation.valid && (
|
|
261
|
+
<p className="text-muted-foreground mt-1 text-sm">
|
|
262
|
+
Preview:{' '}
|
|
263
|
+
<span className="text-foreground font-medium">
|
|
264
|
+
{previewTitle(form.titleTemplate, seo.siteName)}
|
|
265
|
+
</span>
|
|
266
|
+
</p>
|
|
267
|
+
)
|
|
268
|
+
)}
|
|
269
|
+
</div>
|
|
270
|
+
|
|
271
|
+
{/* Default meta description */}
|
|
272
|
+
<div>
|
|
273
|
+
<label htmlFor={descId} className={LABEL_CLASS}>
|
|
274
|
+
Default meta description
|
|
275
|
+
<SettingsSourceBadge meta={sources.defaultMetaDescription} />
|
|
276
|
+
</label>
|
|
277
|
+
<textarea
|
|
278
|
+
id={descId}
|
|
279
|
+
rows={3}
|
|
280
|
+
value={form.defaultMetaDescription}
|
|
281
|
+
disabled={!descEditable}
|
|
282
|
+
aria-describedby={`${descId}-count`}
|
|
283
|
+
onChange={(e) => setField('defaultMetaDescription', e.target.value)}
|
|
284
|
+
className={`${INPUT_CLASS} resize-y`}
|
|
285
|
+
/>
|
|
286
|
+
<div className="mt-1 flex items-center justify-between gap-2">
|
|
287
|
+
<p className="text-muted-foreground text-sm">
|
|
288
|
+
Used when a page or post defines no description of its own.
|
|
289
|
+
</p>
|
|
290
|
+
<span id={`${descId}-count`} className={`text-sm ${descCountClass}`}>
|
|
291
|
+
{descLen}/{META_DESCRIPTION_MAX}
|
|
292
|
+
</span>
|
|
293
|
+
</div>
|
|
294
|
+
{descIssue && <p className="text-warning mt-1 text-sm">{descIssue.message}</p>}
|
|
295
|
+
</div>
|
|
296
|
+
|
|
297
|
+
{/* Default social share image */}
|
|
298
|
+
<div>
|
|
299
|
+
<span className={LABEL_CLASS}>
|
|
300
|
+
Default social share image
|
|
301
|
+
<SettingsSourceBadge meta={sources.defaultOgImage} />
|
|
302
|
+
</span>
|
|
303
|
+
<div className="flex items-center gap-3">
|
|
304
|
+
<div className="border-border bg-muted flex h-16 w-28 shrink-0 items-center justify-center overflow-hidden rounded-md border">
|
|
305
|
+
{form.defaultOgImage ? (
|
|
306
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
307
|
+
<img
|
|
308
|
+
src={form.defaultOgImage}
|
|
309
|
+
alt="Default social share preview"
|
|
310
|
+
className="h-full w-full object-cover"
|
|
311
|
+
/>
|
|
312
|
+
) : (
|
|
313
|
+
<ImageIcon size={20} className="text-muted-foreground" aria-hidden="true" />
|
|
314
|
+
)}
|
|
315
|
+
</div>
|
|
316
|
+
<div className="flex flex-col gap-2">
|
|
317
|
+
<div className="flex items-center gap-2">
|
|
318
|
+
<button
|
|
319
|
+
type="button"
|
|
320
|
+
onClick={() => setPickerOpen(true)}
|
|
321
|
+
disabled={!imageEditable}
|
|
322
|
+
className="border-border text-foreground hover:bg-accent inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-60"
|
|
323
|
+
>
|
|
324
|
+
<Upload size={14} aria-hidden="true" />
|
|
325
|
+
{form.defaultOgImage ? 'Replace' : 'Choose image'}
|
|
326
|
+
</button>
|
|
327
|
+
{form.defaultOgImage && imageEditable && (
|
|
328
|
+
<button
|
|
329
|
+
type="button"
|
|
330
|
+
onClick={() => {
|
|
331
|
+
setField('defaultOgImage', '')
|
|
332
|
+
setField('defaultSocialImageAssetId', '')
|
|
333
|
+
}}
|
|
334
|
+
className="text-muted-foreground hover:text-destructive inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-sm transition-colors"
|
|
335
|
+
>
|
|
336
|
+
<X size={14} aria-hidden="true" />
|
|
337
|
+
Remove
|
|
338
|
+
</button>
|
|
339
|
+
)}
|
|
340
|
+
</div>
|
|
341
|
+
<p className="text-muted-foreground text-sm">1200×630 recommended</p>
|
|
342
|
+
</div>
|
|
343
|
+
</div>
|
|
344
|
+
</div>
|
|
345
|
+
</div>
|
|
346
|
+
|
|
347
|
+
{/* SERP + social preview */}
|
|
348
|
+
<SeoPreviewCard
|
|
349
|
+
title={previewTitle(form.titleTemplate, seo.siteName)}
|
|
350
|
+
url={seo.siteUrl || 'https://example.com'}
|
|
351
|
+
description={
|
|
352
|
+
form.defaultMetaDescription.trim() || 'Your default meta description appears here.'
|
|
353
|
+
}
|
|
354
|
+
image={form.defaultOgImage}
|
|
355
|
+
siteName={seo.siteName}
|
|
356
|
+
/>
|
|
357
|
+
|
|
358
|
+
<MediaPickerModal
|
|
359
|
+
open={pickerOpen}
|
|
360
|
+
onClose={() => setPickerOpen(false)}
|
|
361
|
+
accept="image/*"
|
|
362
|
+
onSelect={(url) => setField('defaultOgImage', url)}
|
|
363
|
+
onSelectItem={(item) => setField('defaultSocialImageAssetId', item.id)}
|
|
364
|
+
/>
|
|
365
|
+
</section>
|
|
366
|
+
)
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function SeoPreviewCard({
|
|
370
|
+
title,
|
|
371
|
+
url,
|
|
372
|
+
description,
|
|
373
|
+
image,
|
|
374
|
+
siteName,
|
|
375
|
+
}: {
|
|
376
|
+
title: string
|
|
377
|
+
url: string
|
|
378
|
+
description: string
|
|
379
|
+
image: string
|
|
380
|
+
siteName: string
|
|
381
|
+
}) {
|
|
382
|
+
return (
|
|
383
|
+
<div className="border-border mt-4 grid gap-4 border-t pt-4 sm:grid-cols-2">
|
|
384
|
+
<div className="border-border rounded-lg border p-3">
|
|
385
|
+
<p className="text-muted-foreground mb-2 text-xs font-medium tracking-wide uppercase">
|
|
386
|
+
Search preview
|
|
387
|
+
</p>
|
|
388
|
+
<p className="text-success truncate text-sm">{url}</p>
|
|
389
|
+
<p className="text-primary truncate text-lg leading-snug">{title}</p>
|
|
390
|
+
<p className="text-muted-foreground line-clamp-2 text-sm">{description}</p>
|
|
391
|
+
</div>
|
|
392
|
+
<div className="border-border overflow-hidden rounded-lg border">
|
|
393
|
+
<div className="bg-muted flex aspect-1200/630 items-center justify-center">
|
|
394
|
+
{image ? (
|
|
395
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
396
|
+
<img src={image} alt="Social share preview" className="h-full w-full object-cover" />
|
|
397
|
+
) : (
|
|
398
|
+
<ImageIcon size={24} className="text-muted-foreground" aria-hidden="true" />
|
|
399
|
+
)}
|
|
400
|
+
</div>
|
|
401
|
+
<div className="p-3">
|
|
402
|
+
<p className="text-muted-foreground truncate text-xs uppercase">
|
|
403
|
+
{siteName || new URL(url).hostname}
|
|
404
|
+
</p>
|
|
405
|
+
<p className="text-foreground truncate text-sm font-medium">{title}</p>
|
|
406
|
+
<p className="text-muted-foreground line-clamp-1 text-xs">{description}</p>
|
|
407
|
+
</div>
|
|
408
|
+
</div>
|
|
409
|
+
</div>
|
|
410
|
+
)
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ---------------------------------------------------------------------------
|
|
414
|
+
// Search Engine Verification
|
|
415
|
+
// ---------------------------------------------------------------------------
|
|
416
|
+
|
|
417
|
+
function VerificationField({
|
|
418
|
+
label,
|
|
419
|
+
field,
|
|
420
|
+
placeholder,
|
|
421
|
+
value,
|
|
422
|
+
onChange,
|
|
423
|
+
disabled,
|
|
424
|
+
}: {
|
|
425
|
+
label: string
|
|
426
|
+
field: keyof SeoSettingsForm
|
|
427
|
+
placeholder: string
|
|
428
|
+
value: string
|
|
429
|
+
onChange: (key: keyof SeoSettingsForm, value: string) => void
|
|
430
|
+
disabled: boolean
|
|
431
|
+
}) {
|
|
432
|
+
const id = useId()
|
|
433
|
+
const hasValue = value.trim().length > 0
|
|
434
|
+
return (
|
|
435
|
+
<div>
|
|
436
|
+
<label htmlFor={id} className={LABEL_CLASS}>
|
|
437
|
+
{label}
|
|
438
|
+
{hasValue && (
|
|
439
|
+
<span 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">
|
|
440
|
+
<CheckCircle2 size={11} className="text-success" aria-hidden="true" />
|
|
441
|
+
Saved
|
|
442
|
+
</span>
|
|
443
|
+
)}
|
|
444
|
+
</label>
|
|
445
|
+
<input
|
|
446
|
+
id={id}
|
|
447
|
+
type="text"
|
|
448
|
+
autoComplete="off"
|
|
449
|
+
spellCheck={false}
|
|
450
|
+
value={value}
|
|
451
|
+
disabled={disabled}
|
|
452
|
+
placeholder={placeholder}
|
|
453
|
+
onChange={(e) => onChange(field, e.target.value)}
|
|
454
|
+
className={INPUT_CLASS}
|
|
455
|
+
/>
|
|
456
|
+
</div>
|
|
457
|
+
)
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function SearchEngineVerificationCard({ seo, canEdit }: { seo: UseSeoSettings; canEdit: boolean }) {
|
|
461
|
+
const headingId = useId()
|
|
462
|
+
const { form, setField } = seo
|
|
463
|
+
const onChange = (key: keyof SeoSettingsForm, value: string) => setField(key, value as never)
|
|
464
|
+
|
|
465
|
+
return (
|
|
466
|
+
<section aria-labelledby={headingId} className="border-border bg-card rounded-lg border p-4">
|
|
467
|
+
<h3 id={headingId} className="text-foreground mb-1 text-base font-medium">
|
|
468
|
+
Search Engine Verification
|
|
469
|
+
</h3>
|
|
470
|
+
<p className="text-muted-foreground mb-4 text-sm">
|
|
471
|
+
Paste the verification token from each provider. We render only safe meta tags into your
|
|
472
|
+
site's head — never arbitrary HTML.
|
|
473
|
+
</p>
|
|
474
|
+
|
|
475
|
+
<div className="space-y-4">
|
|
476
|
+
<VerificationField
|
|
477
|
+
label="Google Search Console"
|
|
478
|
+
field="googleVerification"
|
|
479
|
+
placeholder="google-site-verification…"
|
|
480
|
+
value={form.googleVerification}
|
|
481
|
+
onChange={onChange}
|
|
482
|
+
disabled={!canEdit}
|
|
483
|
+
/>
|
|
484
|
+
<VerificationField
|
|
485
|
+
label="Bing Webmaster Tools"
|
|
486
|
+
field="bingVerification"
|
|
487
|
+
placeholder="msvalidate.01…"
|
|
488
|
+
value={form.bingVerification}
|
|
489
|
+
onChange={onChange}
|
|
490
|
+
disabled={!canEdit}
|
|
491
|
+
/>
|
|
492
|
+
</div>
|
|
493
|
+
|
|
494
|
+
<details className="group mt-4">
|
|
495
|
+
<summary className="text-brand cursor-pointer list-none text-sm hover:underline">
|
|
496
|
+
<span className="group-open:hidden">More providers</span>
|
|
497
|
+
<span className="hidden group-open:inline">Fewer providers</span>
|
|
498
|
+
</summary>
|
|
499
|
+
<div className="mt-4 space-y-4">
|
|
500
|
+
<VerificationField
|
|
501
|
+
label="Yandex"
|
|
502
|
+
field="yandexVerification"
|
|
503
|
+
placeholder="yandex-verification…"
|
|
504
|
+
value={form.yandexVerification}
|
|
505
|
+
onChange={onChange}
|
|
506
|
+
disabled={!canEdit}
|
|
507
|
+
/>
|
|
508
|
+
<VerificationField
|
|
509
|
+
label="Pinterest"
|
|
510
|
+
field="pinterestVerification"
|
|
511
|
+
placeholder="p:domain_verify…"
|
|
512
|
+
value={form.pinterestVerification}
|
|
513
|
+
onChange={onChange}
|
|
514
|
+
disabled={!canEdit}
|
|
515
|
+
/>
|
|
516
|
+
<VerificationField
|
|
517
|
+
label="Facebook domain verification"
|
|
518
|
+
field="facebookDomainVerification"
|
|
519
|
+
placeholder="facebook-domain-verification…"
|
|
520
|
+
value={form.facebookDomainVerification}
|
|
521
|
+
onChange={onChange}
|
|
522
|
+
disabled={!canEdit}
|
|
523
|
+
/>
|
|
524
|
+
</div>
|
|
525
|
+
</details>
|
|
526
|
+
</section>
|
|
527
|
+
)
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// ---------------------------------------------------------------------------
|
|
531
|
+
// Sitemap Defaults
|
|
532
|
+
// ---------------------------------------------------------------------------
|
|
533
|
+
|
|
534
|
+
function SitemapDefaultsCard({
|
|
535
|
+
seo,
|
|
536
|
+
canEdit,
|
|
537
|
+
onNavigate,
|
|
538
|
+
}: {
|
|
539
|
+
seo: UseSeoSettings
|
|
540
|
+
canEdit: boolean
|
|
541
|
+
onNavigate?: (path: string) => void
|
|
542
|
+
}) {
|
|
543
|
+
const headingId = useId()
|
|
544
|
+
const [pendingDisable, setPendingDisable] = useState(false)
|
|
545
|
+
const { form, setField, sitemap, siteUrl, isProduction, regenerateSitemap, regenerating } = seo
|
|
546
|
+
|
|
547
|
+
function requestAutoGenerate(value: boolean) {
|
|
548
|
+
// Turning auto-generation off on production can let the sitemap go stale.
|
|
549
|
+
if (!value && isProduction) {
|
|
550
|
+
setPendingDisable(true)
|
|
551
|
+
return
|
|
552
|
+
}
|
|
553
|
+
setField('autoGenerateSitemap', value)
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const lastGenerated = sitemap?.lastGeneratedAt
|
|
557
|
+
? new Date(sitemap.lastGeneratedAt).toLocaleString()
|
|
558
|
+
: 'Never'
|
|
559
|
+
|
|
560
|
+
return (
|
|
561
|
+
<section aria-labelledby={headingId} className="border-border bg-card rounded-lg border p-4">
|
|
562
|
+
<h3 id={headingId} className="text-foreground mb-4 text-base font-medium">
|
|
563
|
+
Sitemap
|
|
564
|
+
</h3>
|
|
565
|
+
|
|
566
|
+
{!siteUrl && (
|
|
567
|
+
<div className="border-warning/30 bg-warning/10 text-warning mb-4 flex items-start gap-2 rounded-md border p-3 text-sm">
|
|
568
|
+
<AlertTriangle size={16} className="mt-0.5 shrink-0" aria-hidden="true" />
|
|
569
|
+
Site URL is not set — sitemap URLs may be incorrect. Set it in Settings → General.
|
|
570
|
+
</div>
|
|
571
|
+
)}
|
|
572
|
+
|
|
573
|
+
<div className="space-y-4">
|
|
574
|
+
<SettingsToggleRow
|
|
575
|
+
label="Auto-generate XML sitemap"
|
|
576
|
+
description="Rebuild the sitemap whenever content is published."
|
|
577
|
+
checked={form.autoGenerateSitemap}
|
|
578
|
+
disabled={!canEdit}
|
|
579
|
+
onChange={requestAutoGenerate}
|
|
580
|
+
/>
|
|
581
|
+
<SettingsToggleRow
|
|
582
|
+
label="Ping search engines on publish"
|
|
583
|
+
description="Notify Google and Bing when the sitemap changes."
|
|
584
|
+
checked={form.pingOnPublish}
|
|
585
|
+
disabled={!canEdit || !form.autoGenerateSitemap}
|
|
586
|
+
onChange={(v) => setField('pingOnPublish', v)}
|
|
587
|
+
/>
|
|
588
|
+
</div>
|
|
589
|
+
|
|
590
|
+
<div className="border-border mt-4 space-y-2 border-t pt-4">
|
|
591
|
+
<div className="flex items-center justify-between gap-2 text-sm">
|
|
592
|
+
<span className="text-muted-foreground">Sitemap URL</span>
|
|
593
|
+
{sitemap?.sitemapUrl ? (
|
|
594
|
+
<a
|
|
595
|
+
href={sitemap.sitemapUrl}
|
|
596
|
+
target="_blank"
|
|
597
|
+
rel="noreferrer"
|
|
598
|
+
className="text-brand inline-flex items-center gap-1 font-medium hover:underline"
|
|
599
|
+
>
|
|
600
|
+
View sitemap
|
|
601
|
+
<ExternalLink size={13} aria-hidden="true" />
|
|
602
|
+
</a>
|
|
603
|
+
) : (
|
|
604
|
+
<span className="text-foreground font-medium">/sitemap.xml</span>
|
|
605
|
+
)}
|
|
606
|
+
</div>
|
|
607
|
+
<SummaryRow label="Last generated" value={lastGenerated} />
|
|
608
|
+
<SummaryRow label="Included URLs" value={String(sitemap?.pagesIncluded ?? '—')} />
|
|
609
|
+
<SummaryRow label="Excluded URLs" value={String(sitemap?.pagesExcluded ?? '—')} />
|
|
610
|
+
<div className="flex items-center justify-between gap-2 pt-1">
|
|
611
|
+
<button
|
|
612
|
+
type="button"
|
|
613
|
+
onClick={regenerateSitemap}
|
|
614
|
+
disabled={!canEdit || regenerating}
|
|
615
|
+
className="border-border text-foreground hover:bg-accent inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-60"
|
|
616
|
+
>
|
|
617
|
+
{regenerating ? (
|
|
618
|
+
<Loader2 size={14} className="animate-spin" aria-hidden="true" />
|
|
619
|
+
) : (
|
|
620
|
+
<RefreshCw size={14} aria-hidden="true" />
|
|
621
|
+
)}
|
|
622
|
+
Regenerate now
|
|
623
|
+
</button>
|
|
624
|
+
{onNavigate && (
|
|
625
|
+
<button
|
|
626
|
+
type="button"
|
|
627
|
+
onClick={() => onNavigate('/seo?tab=technical')}
|
|
628
|
+
className="text-brand text-sm hover:underline"
|
|
629
|
+
>
|
|
630
|
+
Manage in SEO Technical →
|
|
631
|
+
</button>
|
|
632
|
+
)}
|
|
633
|
+
</div>
|
|
634
|
+
</div>
|
|
635
|
+
|
|
636
|
+
<ConfirmDangerousSettingDialog
|
|
637
|
+
open={pendingDisable}
|
|
638
|
+
title="Stop auto-generating the sitemap on production?"
|
|
639
|
+
description="Your sitemap may go stale as content changes, which can hurt how quickly search engines discover new pages."
|
|
640
|
+
confirmLabel="Turn Off"
|
|
641
|
+
onConfirm={() => {
|
|
642
|
+
setField('autoGenerateSitemap', false)
|
|
643
|
+
setPendingDisable(false)
|
|
644
|
+
}}
|
|
645
|
+
onCancel={() => setPendingDisable(false)}
|
|
646
|
+
/>
|
|
647
|
+
</section>
|
|
648
|
+
)
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function SummaryRow({ label, value }: { label: string; value: string }) {
|
|
652
|
+
return (
|
|
653
|
+
<div className="flex items-center justify-between gap-2 text-sm">
|
|
654
|
+
<span className="text-muted-foreground">{label}</span>
|
|
655
|
+
<span className="text-foreground font-medium">{value}</span>
|
|
656
|
+
</div>
|
|
657
|
+
)
|
|
658
|
+
}
|