@actuate-media/cms-admin 0.1.4 → 0.2.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.
Files changed (94) hide show
  1. package/LICENSE +21 -21
  2. package/dist/AdminRoot.d.ts.map +1 -1
  3. package/dist/AdminRoot.js +16 -10
  4. package/dist/AdminRoot.js.map +1 -1
  5. package/dist/actuate-admin.css +2 -0
  6. package/dist/components/TipTapEditor.js +78 -78
  7. package/dist/lib/useApiData.d.ts +8 -1
  8. package/dist/lib/useApiData.d.ts.map +1 -1
  9. package/dist/lib/useApiData.js +39 -7
  10. package/dist/lib/useApiData.js.map +1 -1
  11. package/dist/views/Dashboard.d.ts.map +1 -1
  12. package/dist/views/Dashboard.js +8 -3
  13. package/dist/views/Dashboard.js.map +1 -1
  14. package/package.json +10 -5
  15. package/src/AdminRoot.tsx +312 -0
  16. package/src/__tests__/lib/search.test.ts +138 -0
  17. package/src/__tests__/lib/utils.test.ts +19 -0
  18. package/src/__tests__/router/match-route.test.ts +47 -0
  19. package/src/__tests__/router/strip-base.test.ts +30 -0
  20. package/src/components/Breadcrumbs.tsx +92 -0
  21. package/src/components/CommandPalette.tsx +384 -0
  22. package/src/components/ErrorBoundary.tsx +52 -0
  23. package/src/components/FocalPointPicker.tsx +54 -0
  24. package/src/components/FolderTree.tsx +427 -0
  25. package/src/components/LivePreview.tsx +136 -0
  26. package/src/components/LocaleProvider.tsx +51 -0
  27. package/src/components/LocaleSwitcher.tsx +51 -0
  28. package/src/components/MediaPickerModal.tsx +183 -0
  29. package/src/components/PresenceIndicator.tsx +71 -0
  30. package/src/components/SEOPanel.tsx +767 -0
  31. package/src/components/ThemeProvider.tsx +98 -0
  32. package/src/components/TipTapEditor.tsx +469 -0
  33. package/src/components/VersionHistory.tsx +167 -0
  34. package/src/components/ui/Avatar.tsx +42 -0
  35. package/src/components/ui/Badge.tsx +25 -0
  36. package/src/components/ui/Button.tsx +52 -0
  37. package/src/components/ui/CommandPalette.tsx +119 -0
  38. package/src/components/ui/ConfirmDialog.tsx +52 -0
  39. package/src/components/ui/DataTable.tsx +194 -0
  40. package/src/components/ui/EmptyState.tsx +29 -0
  41. package/src/components/ui/Modal.tsx +48 -0
  42. package/src/components/ui/Pagination.tsx +79 -0
  43. package/src/components/ui/SearchInput.tsx +44 -0
  44. package/src/components/ui/Skeleton.tsx +48 -0
  45. package/src/components/ui/Toast.tsx +66 -0
  46. package/src/components/ui/index.ts +24 -0
  47. package/src/fields/ArrayField.tsx +92 -0
  48. package/src/fields/BlockBuilderField.tsx +421 -0
  49. package/src/fields/DateField.tsx +41 -0
  50. package/src/fields/FieldRenderer.tsx +84 -0
  51. package/src/fields/GroupField.tsx +41 -0
  52. package/src/fields/MediaField.tsx +48 -0
  53. package/src/fields/NavBuilderField.tsx +78 -0
  54. package/src/fields/NumberField.tsx +45 -0
  55. package/src/fields/RelationshipField.tsx +245 -0
  56. package/src/fields/RichTextField.tsx +26 -0
  57. package/src/fields/SelectField.tsx +117 -0
  58. package/src/fields/SlugField.tsx +65 -0
  59. package/src/fields/TextField.tsx +48 -0
  60. package/src/fields/ToggleField.tsx +36 -0
  61. package/src/fields/block-types.ts +95 -0
  62. package/src/fields/index.ts +17 -0
  63. package/src/hooks/useContentLock.ts +52 -0
  64. package/src/hooks/useDebounce.ts +14 -0
  65. package/src/hooks/useKeyboardShortcuts.ts +32 -0
  66. package/src/index.ts +55 -0
  67. package/src/layout/Header.tsx +135 -0
  68. package/src/layout/Layout.tsx +77 -0
  69. package/src/layout/Sidebar.tsx +216 -0
  70. package/src/lib/api.ts +67 -0
  71. package/src/lib/search.ts +59 -0
  72. package/src/lib/useApiData.ts +95 -0
  73. package/src/lib/utils.ts +6 -0
  74. package/src/router/index.ts +81 -0
  75. package/src/styles/build-input.css +11 -0
  76. package/src/styles/tailwind.css +11 -6
  77. package/src/styles/theme.css +182 -181
  78. package/src/views/CollectionList.tsx +270 -0
  79. package/src/views/Dashboard.tsx +207 -0
  80. package/src/views/DocumentEdit.tsx +377 -0
  81. package/src/views/FormEditor.tsx +533 -0
  82. package/src/views/FormSubmissions.tsx +316 -0
  83. package/src/views/Forms.tsx +106 -0
  84. package/src/views/Login.tsx +322 -0
  85. package/src/views/MediaBrowser.tsx +774 -0
  86. package/src/views/PageEditor.tsx +192 -0
  87. package/src/views/Pages.tsx +354 -0
  88. package/src/views/PostEditor.tsx +251 -0
  89. package/src/views/Posts.tsx +243 -0
  90. package/src/views/Redirects.tsx +293 -0
  91. package/src/views/SEO.tsx +458 -0
  92. package/src/views/Settings.tsx +811 -0
  93. package/src/views/SetupWizard.tsx +207 -0
  94. package/src/views/Users.tsx +282 -0
@@ -0,0 +1,767 @@
1
+ 'use client';
2
+
3
+ import { useState, useMemo } from 'react';
4
+ import {
5
+ Search,
6
+ RefreshCw,
7
+ EyeOff,
8
+ ChevronDown,
9
+ ChevronUp,
10
+ Globe,
11
+ Share2,
12
+ Settings2,
13
+ Target,
14
+ BookOpen,
15
+ CheckCircle2,
16
+ AlertCircle,
17
+ XCircle,
18
+ BarChart3,
19
+ Star,
20
+ } from 'lucide-react';
21
+
22
+ export interface SEOData {
23
+ metaTitle?: string;
24
+ metaDescription?: string;
25
+ focusKeyphrase?: string;
26
+ canonical?: string;
27
+ noIndex?: boolean;
28
+ noFollow?: boolean;
29
+ ogTitle?: string;
30
+ ogDescription?: string;
31
+ ogImage?: string;
32
+ twitterTitle?: string;
33
+ twitterDescription?: string;
34
+ twitterImage?: string;
35
+ isCornerstone?: boolean;
36
+ schemaType?: string;
37
+ }
38
+
39
+ export interface SEOPanelProps {
40
+ title: string;
41
+ slug: string;
42
+ content?: string;
43
+ seoData: SEOData;
44
+ onChange: (data: SEOData) => void;
45
+ siteUrl?: string;
46
+ }
47
+
48
+ interface SEOCheck {
49
+ id: string;
50
+ label: string;
51
+ status: 'good' | 'ok' | 'bad';
52
+ detail: string;
53
+ }
54
+
55
+ interface ReadabilityResult {
56
+ fleschScore: number;
57
+ avgSentenceLength: number;
58
+ wordCount: number;
59
+ readingTime: number;
60
+ passiveEstimate: number;
61
+ }
62
+
63
+ const SCHEMA_TYPES = [
64
+ 'Article',
65
+ 'BlogPosting',
66
+ 'WebPage',
67
+ 'FAQPage',
68
+ 'Product',
69
+ 'Event',
70
+ 'LocalBusiness',
71
+ 'HowTo',
72
+ 'Recipe',
73
+ ] as const;
74
+
75
+ function stripHtml(html: string): string {
76
+ return html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
77
+ }
78
+
79
+ function countWords(text: string): number {
80
+ if (!text.trim()) return 0;
81
+ return text.trim().split(/\s+/).length;
82
+ }
83
+
84
+ function countSentences(text: string): number {
85
+ if (!text.trim()) return 0;
86
+ const matches = text.match(/[.!?]+/g);
87
+ return matches ? matches.length : 1;
88
+ }
89
+
90
+ function countSyllables(word: string): number {
91
+ const w = word.toLowerCase().replace(/[^a-z]/g, '');
92
+ if (w.length <= 3) return 1;
93
+ let count = 0;
94
+ const vowels = 'aeiouy';
95
+ let prevVowel = false;
96
+ for (let i = 0; i < w.length; i++) {
97
+ const isVowel = vowels.includes(w[i] ?? '');
98
+ if (isVowel && !prevVowel) count++;
99
+ prevVowel = isVowel;
100
+ }
101
+ if (w.endsWith('e') && count > 1) count--;
102
+ return Math.max(count, 1);
103
+ }
104
+
105
+ function analyzeReadability(text: string): ReadabilityResult {
106
+ const plainText = stripHtml(text);
107
+ const wordCount = countWords(plainText);
108
+ const sentenceCount = countSentences(plainText);
109
+ const avgSentenceLength = sentenceCount > 0 ? wordCount / sentenceCount : 0;
110
+ const readingTime = Math.max(1, Math.ceil(wordCount / 200));
111
+
112
+ let fleschScore = 0;
113
+ if (wordCount > 0 && sentenceCount > 0) {
114
+ const words = plainText.split(/\s+/);
115
+ const totalSyllables = words.reduce((sum, w) => sum + countSyllables(w), 0);
116
+ fleschScore = Math.round(
117
+ 206.835 - 1.015 * (wordCount / sentenceCount) - 84.6 * (totalSyllables / wordCount)
118
+ );
119
+ fleschScore = Math.max(0, Math.min(100, fleschScore));
120
+ }
121
+
122
+ const passivePatterns = /\b(is|are|was|were|been|being|be)\s+\w+ed\b/gi;
123
+ const passiveMatches = plainText.match(passivePatterns);
124
+ const passiveEstimate = passiveMatches
125
+ ? Math.round((passiveMatches.length / Math.max(sentenceCount, 1)) * 100)
126
+ : 0;
127
+
128
+ return { fleschScore, avgSentenceLength, wordCount, readingTime, passiveEstimate };
129
+ }
130
+
131
+ function runSEOChecks(
132
+ seoData: SEOData,
133
+ title: string,
134
+ slug: string,
135
+ content: string
136
+ ): SEOCheck[] {
137
+ const checks: SEOCheck[] = [];
138
+ const plainText = stripHtml(content);
139
+ const wordCount = countWords(plainText);
140
+ const keyphrase = (seoData.focusKeyphrase ?? '').toLowerCase().trim();
141
+ const metaTitle = seoData.metaTitle ?? '';
142
+ const metaDesc = seoData.metaDescription ?? '';
143
+
144
+ // Meta title length
145
+ if (!metaTitle) {
146
+ checks.push({ id: 'title-missing', label: 'Meta title', status: 'bad', detail: 'No meta title set' });
147
+ } else if (metaTitle.length >= 30 && metaTitle.length <= 60) {
148
+ checks.push({ id: 'title-length', label: 'Meta title length', status: 'good', detail: `${metaTitle.length} chars (ideal: 30-60)` });
149
+ } else if (metaTitle.length > 60) {
150
+ checks.push({ id: 'title-length', label: 'Meta title length', status: 'ok', detail: `${metaTitle.length} chars — too long, may be truncated` });
151
+ } else {
152
+ checks.push({ id: 'title-length', label: 'Meta title length', status: 'ok', detail: `${metaTitle.length} chars — quite short` });
153
+ }
154
+
155
+ // Meta description length
156
+ if (!metaDesc) {
157
+ checks.push({ id: 'desc-missing', label: 'Meta description', status: 'bad', detail: 'No meta description set' });
158
+ } else if (metaDesc.length >= 120 && metaDesc.length <= 160) {
159
+ checks.push({ id: 'desc-length', label: 'Meta description length', status: 'good', detail: `${metaDesc.length} chars (ideal: 120-160)` });
160
+ } else if (metaDesc.length > 160) {
161
+ checks.push({ id: 'desc-length', label: 'Meta description length', status: 'ok', detail: `${metaDesc.length} chars — may be truncated` });
162
+ } else {
163
+ checks.push({ id: 'desc-length', label: 'Meta description length', status: 'ok', detail: `${metaDesc.length} chars — could be longer` });
164
+ }
165
+
166
+ // Content length
167
+ if (wordCount >= 300) {
168
+ checks.push({ id: 'content-length', label: 'Content length', status: 'good', detail: `${wordCount} words` });
169
+ } else if (wordCount >= 150) {
170
+ checks.push({ id: 'content-length', label: 'Content length', status: 'ok', detail: `${wordCount} words — aim for 300+` });
171
+ } else {
172
+ checks.push({ id: 'content-length', label: 'Content length', status: 'bad', detail: `${wordCount} words — too short` });
173
+ }
174
+
175
+ // Focus keyphrase checks
176
+ if (!keyphrase) {
177
+ checks.push({ id: 'keyphrase-missing', label: 'Focus keyphrase', status: 'bad', detail: 'No focus keyphrase set' });
178
+ } else {
179
+ const inTitle = metaTitle.toLowerCase().includes(keyphrase);
180
+ checks.push({
181
+ id: 'keyphrase-title',
182
+ label: 'Keyphrase in title',
183
+ status: inTitle ? 'good' : 'bad',
184
+ detail: inTitle ? 'Found in meta title' : 'Not found in meta title',
185
+ });
186
+
187
+ const inDesc = metaDesc.toLowerCase().includes(keyphrase);
188
+ checks.push({
189
+ id: 'keyphrase-desc',
190
+ label: 'Keyphrase in description',
191
+ status: inDesc ? 'good' : 'ok',
192
+ detail: inDesc ? 'Found in meta description' : 'Not found in meta description',
193
+ });
194
+
195
+ const inSlug = slug.toLowerCase().includes(keyphrase.replace(/\s+/g, '-'));
196
+ checks.push({
197
+ id: 'keyphrase-slug',
198
+ label: 'Keyphrase in slug',
199
+ status: inSlug ? 'good' : 'ok',
200
+ detail: inSlug ? 'Found in URL slug' : 'Not found in URL slug',
201
+ });
202
+
203
+ const firstParagraph = plainText.slice(0, 300).toLowerCase();
204
+ const inIntro = firstParagraph.includes(keyphrase);
205
+ checks.push({
206
+ id: 'keyphrase-intro',
207
+ label: 'Keyphrase in introduction',
208
+ status: inIntro ? 'good' : 'ok',
209
+ detail: inIntro ? 'Found in first paragraph' : 'Not found in first paragraph',
210
+ });
211
+
212
+ if (wordCount > 0) {
213
+ const kpWords = keyphrase.split(/\s+/).length;
214
+ const regex = new RegExp(keyphrase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
215
+ const matches = plainText.match(regex);
216
+ const occurrences = matches ? matches.length : 0;
217
+ const density = (occurrences * kpWords) / wordCount * 100;
218
+ const densityOk = density >= 0.5 && density <= 3;
219
+ checks.push({
220
+ id: 'keyphrase-density',
221
+ label: 'Keyphrase density',
222
+ status: densityOk ? 'good' : density === 0 ? 'bad' : 'ok',
223
+ detail: `${density.toFixed(1)}% (aim for 0.5-3%)`,
224
+ });
225
+ }
226
+ }
227
+
228
+ // Image alt text
229
+ const imgTags = content.match(/<img[^>]*>/gi) ?? [];
230
+ if (imgTags.length > 0) {
231
+ const withoutAlt = imgTags.filter(tag => !tag.match(/alt\s*=\s*["'][^"']+["']/i));
232
+ if (withoutAlt.length === 0) {
233
+ checks.push({ id: 'img-alt', label: 'Image alt text', status: 'good', detail: 'All images have alt text' });
234
+ } else {
235
+ checks.push({ id: 'img-alt', label: 'Image alt text', status: 'ok', detail: `${withoutAlt.length} image(s) missing alt text` });
236
+ }
237
+ }
238
+
239
+ // OG checks
240
+ const hasOgTitle = !!(seoData.ogTitle || metaTitle);
241
+ const hasOgDesc = !!(seoData.ogDescription || metaDesc);
242
+ const hasOgImage = !!seoData.ogImage;
243
+ checks.push({
244
+ id: 'og-title',
245
+ label: 'Social title',
246
+ status: hasOgTitle ? 'good' : 'ok',
247
+ detail: hasOgTitle ? 'Set' : 'Missing — will fall back to page title',
248
+ });
249
+ checks.push({
250
+ id: 'og-desc',
251
+ label: 'Social description',
252
+ status: hasOgDesc ? 'good' : 'ok',
253
+ detail: hasOgDesc ? 'Set' : 'Missing — will fall back to meta description',
254
+ });
255
+ checks.push({
256
+ id: 'og-image',
257
+ label: 'Social image',
258
+ status: hasOgImage ? 'good' : 'bad',
259
+ detail: hasOgImage ? 'Set' : 'No OG image set — strongly recommended',
260
+ });
261
+
262
+ return checks;
263
+ }
264
+
265
+ function computeOverallScore(checks: SEOCheck[]): number {
266
+ if (checks.length === 0) return 0;
267
+ const scoreMap = { good: 100, ok: 50, bad: 0 };
268
+ const total = checks.reduce((sum, c) => sum + scoreMap[c.status], 0);
269
+ return Math.round(total / checks.length);
270
+ }
271
+
272
+ function StatusDot({ status }: { status: 'good' | 'ok' | 'bad' }) {
273
+ if (status === 'good') return <CheckCircle2 className="h-4 w-4 shrink-0 text-green-500" />;
274
+ if (status === 'ok') return <AlertCircle className="h-4 w-4 shrink-0 text-amber-500" />;
275
+ return <XCircle className="h-4 w-4 shrink-0 text-red-500" />;
276
+ }
277
+
278
+ function ScoreRing({ score }: { score: number }) {
279
+ const radius = 28;
280
+ const circumference = 2 * Math.PI * radius;
281
+ const offset = circumference - (score / 100) * circumference;
282
+ const color = score >= 70 ? '#22c55e' : score >= 40 ? '#f59e0b' : '#ef4444';
283
+
284
+ return (
285
+ <div className="relative inline-flex items-center justify-center">
286
+ <svg width="72" height="72" className="-rotate-90">
287
+ <circle cx="36" cy="36" r={radius} fill="none" stroke="var(--border)" strokeWidth="5" />
288
+ <circle
289
+ cx="36"
290
+ cy="36"
291
+ r={radius}
292
+ fill="none"
293
+ stroke={color}
294
+ strokeWidth="5"
295
+ strokeDasharray={circumference}
296
+ strokeDashoffset={offset}
297
+ strokeLinecap="round"
298
+ className="transition-all duration-500"
299
+ />
300
+ </svg>
301
+ <span className="absolute text-sm font-bold" style={{ color }}>{score}</span>
302
+ </div>
303
+ );
304
+ }
305
+
306
+ function Section({
307
+ id,
308
+ title,
309
+ icon,
310
+ expanded,
311
+ onToggle,
312
+ children,
313
+ badge,
314
+ }: {
315
+ id: string;
316
+ title: string;
317
+ icon: React.ReactNode;
318
+ expanded: boolean;
319
+ onToggle: (id: string) => void;
320
+ children: React.ReactNode;
321
+ badge?: React.ReactNode;
322
+ }) {
323
+ return (
324
+ <div className="border border-[var(--border)] rounded-lg overflow-hidden">
325
+ <button
326
+ onClick={() => onToggle(id)}
327
+ className="flex items-center justify-between w-full px-4 py-2.5 text-left text-sm font-medium text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors"
328
+ >
329
+ <span className="flex items-center gap-2">
330
+ {icon}
331
+ {title}
332
+ {badge}
333
+ </span>
334
+ {expanded ? <ChevronUp className="h-4 w-4 text-[var(--muted-foreground)]" /> : <ChevronDown className="h-4 w-4 text-[var(--muted-foreground)]" />}
335
+ </button>
336
+ {expanded && <div className="px-4 pb-4 pt-1">{children}</div>}
337
+ </div>
338
+ );
339
+ }
340
+
341
+ function ToggleSwitch({
342
+ label,
343
+ description,
344
+ checked,
345
+ onChange,
346
+ }: {
347
+ label: string;
348
+ description: string;
349
+ checked: boolean;
350
+ onChange: (v: boolean) => void;
351
+ }) {
352
+ return (
353
+ <div className="flex items-center justify-between gap-3">
354
+ <div className="flex-1 min-w-0">
355
+ <label className="text-sm font-medium text-[var(--foreground)]">{label}</label>
356
+ <p className="text-xs text-[var(--muted-foreground)] mt-0.5">{description}</p>
357
+ </div>
358
+ <button
359
+ type="button"
360
+ role="switch"
361
+ aria-checked={checked}
362
+ onClick={() => onChange(!checked)}
363
+ className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full transition-colors ${checked ? 'bg-[var(--primary)]' : 'bg-[var(--muted)]'}`}
364
+ >
365
+ <span className={`pointer-events-none block h-5 w-5 rounded-full bg-white shadow-sm transition-transform mt-0.5 ${checked ? 'translate-x-[22px]' : 'translate-x-0.5'}`} />
366
+ </button>
367
+ </div>
368
+ );
369
+ }
370
+
371
+ function InputField({
372
+ label,
373
+ value,
374
+ onChange,
375
+ placeholder,
376
+ type = 'text',
377
+ charCount,
378
+ charTarget,
379
+ }: {
380
+ label: string;
381
+ value: string;
382
+ onChange: (v: string) => void;
383
+ placeholder?: string;
384
+ type?: string;
385
+ charCount?: number;
386
+ charTarget?: string;
387
+ }) {
388
+ return (
389
+ <div>
390
+ <label className="block text-xs font-medium text-[var(--muted-foreground)] mb-1">{label}</label>
391
+ <input
392
+ type={type}
393
+ value={value}
394
+ onChange={(e) => onChange(e.target.value)}
395
+ className="w-full px-3 py-1.5 text-sm border border-[var(--border)] rounded-lg bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)] placeholder:text-[var(--muted-foreground)]"
396
+ placeholder={placeholder}
397
+ />
398
+ {charCount !== undefined && charTarget && (
399
+ <p className="text-xs mt-1 text-[var(--muted-foreground)]">{charCount} chars {charTarget}</p>
400
+ )}
401
+ </div>
402
+ );
403
+ }
404
+
405
+ function TextareaField({
406
+ label,
407
+ value,
408
+ onChange,
409
+ placeholder,
410
+ rows = 3,
411
+ charCount,
412
+ charTarget,
413
+ }: {
414
+ label: string;
415
+ value: string;
416
+ onChange: (v: string) => void;
417
+ placeholder?: string;
418
+ rows?: number;
419
+ charCount?: number;
420
+ charTarget?: string;
421
+ }) {
422
+ return (
423
+ <div>
424
+ <label className="block text-xs font-medium text-[var(--muted-foreground)] mb-1">{label}</label>
425
+ <textarea
426
+ value={value}
427
+ onChange={(e) => onChange(e.target.value)}
428
+ rows={rows}
429
+ className="w-full px-3 py-1.5 text-sm border border-[var(--border)] rounded-lg bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)] resize-none placeholder:text-[var(--muted-foreground)]"
430
+ placeholder={placeholder}
431
+ />
432
+ {charCount !== undefined && charTarget && (
433
+ <p className="text-xs mt-1 text-[var(--muted-foreground)]">{charCount} chars {charTarget}</p>
434
+ )}
435
+ </div>
436
+ );
437
+ }
438
+
439
+ export function SEOPanel({ title, slug, content = '', seoData, onChange, siteUrl = 'https://example.com' }: SEOPanelProps) {
440
+ const [expandedSections, setExpandedSections] = useState<string[]>(['analysis']);
441
+
442
+ const update = (partial: Partial<SEOData>) => {
443
+ onChange({ ...seoData, ...partial });
444
+ };
445
+
446
+ const toggleSection = (id: string) => {
447
+ setExpandedSections((prev) =>
448
+ prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id]
449
+ );
450
+ };
451
+
452
+ const checks = useMemo(() => runSEOChecks(seoData, title, slug, content), [seoData, title, slug, content]);
453
+ const readability = useMemo(() => analyzeReadability(content), [content]);
454
+ const score = useMemo(() => computeOverallScore(checks), [checks]);
455
+
456
+ const goodCount = checks.filter((c) => c.status === 'good').length;
457
+ const okCount = checks.filter((c) => c.status === 'ok').length;
458
+ const badCount = checks.filter((c) => c.status === 'bad').length;
459
+
460
+ const metaTitle = seoData.metaTitle ?? '';
461
+ const metaDesc = seoData.metaDescription ?? '';
462
+ const displayTitle = seoData.ogTitle || metaTitle || title || 'Page Title';
463
+ const displayDesc = seoData.ogDescription || metaDesc || 'Add a meta description to see how this page will appear in search results.';
464
+ const fleschLabel =
465
+ readability.fleschScore >= 60 ? 'Easy to read' : readability.fleschScore >= 30 ? 'Fairly difficult' : 'Very difficult';
466
+ const fleschColor =
467
+ readability.fleschScore >= 60 ? 'text-green-500' : readability.fleschScore >= 30 ? 'text-amber-500' : 'text-red-500';
468
+
469
+ return (
470
+ <div className="rounded-lg border border-[var(--border)] bg-[var(--card)]">
471
+ {/* Header */}
472
+ <div className="flex items-center justify-between px-4 py-3 border-b border-[var(--border)]">
473
+ <h3 className="font-semibold text-[var(--foreground)] flex items-center gap-2 text-sm">
474
+ <Search className="w-4 h-4" />
475
+ SEO
476
+ </h3>
477
+ <button
478
+ onClick={() => update({ metaTitle: title, ogTitle: '', ogDescription: '' })}
479
+ className="text-xs text-[var(--primary)] hover:opacity-80 flex items-center gap-1"
480
+ >
481
+ <RefreshCw className="w-3 h-3" />
482
+ Reset
483
+ </button>
484
+ </div>
485
+
486
+ {/* Score */}
487
+ <div className="flex items-center gap-4 px-4 py-3 border-b border-[var(--border)]">
488
+ <ScoreRing score={score} />
489
+ <div className="text-xs text-[var(--muted-foreground)] space-y-0.5">
490
+ <div className="flex items-center gap-1.5"><CheckCircle2 className="h-3 w-3 text-green-500" /> {goodCount} passed</div>
491
+ <div className="flex items-center gap-1.5"><AlertCircle className="h-3 w-3 text-amber-500" /> {okCount} improvements</div>
492
+ <div className="flex items-center gap-1.5"><XCircle className="h-3 w-3 text-red-500" /> {badCount} issues</div>
493
+ </div>
494
+ </div>
495
+
496
+ {/* Sections */}
497
+ <div className="p-3 space-y-2">
498
+ {/* SEO Analysis */}
499
+ <Section
500
+ id="analysis"
501
+ title="SEO Analysis"
502
+ icon={<BarChart3 className="h-4 w-4" />}
503
+ expanded={expandedSections.includes('analysis')}
504
+ onToggle={toggleSection}
505
+ badge={
506
+ <span className="ml-1 inline-flex items-center rounded-full bg-[var(--muted)] px-2 py-0.5 text-[10px] font-medium text-[var(--muted-foreground)]">
507
+ {goodCount}/{checks.length}
508
+ </span>
509
+ }
510
+ >
511
+ <div className="space-y-1.5">
512
+ {checks.map((c) => (
513
+ <div key={c.id} className="flex items-start gap-2 py-1">
514
+ <StatusDot status={c.status} />
515
+ <div className="min-w-0">
516
+ <p className="text-sm text-[var(--foreground)] leading-snug">{c.label}</p>
517
+ <p className="text-xs text-[var(--muted-foreground)]">{c.detail}</p>
518
+ </div>
519
+ </div>
520
+ ))}
521
+ </div>
522
+ </Section>
523
+
524
+ {/* Readability */}
525
+ <Section
526
+ id="readability"
527
+ title="Readability"
528
+ icon={<BookOpen className="h-4 w-4" />}
529
+ expanded={expandedSections.includes('readability')}
530
+ onToggle={toggleSection}
531
+ >
532
+ <div className="grid grid-cols-2 gap-3">
533
+ <div className="rounded-lg bg-[var(--muted)] p-2.5">
534
+ <p className="text-xs text-[var(--muted-foreground)]">Flesch Score</p>
535
+ <p className={`text-lg font-bold ${fleschColor}`}>{readability.fleschScore}</p>
536
+ <p className={`text-[10px] ${fleschColor}`}>{fleschLabel}</p>
537
+ </div>
538
+ <div className="rounded-lg bg-[var(--muted)] p-2.5">
539
+ <p className="text-xs text-[var(--muted-foreground)]">Word Count</p>
540
+ <p className="text-lg font-bold text-[var(--foreground)]">{readability.wordCount}</p>
541
+ <p className="text-[10px] text-[var(--muted-foreground)]">words</p>
542
+ </div>
543
+ <div className="rounded-lg bg-[var(--muted)] p-2.5">
544
+ <p className="text-xs text-[var(--muted-foreground)]">Avg. Sentence</p>
545
+ <p className="text-lg font-bold text-[var(--foreground)]">{readability.avgSentenceLength.toFixed(1)}</p>
546
+ <p className="text-[10px] text-[var(--muted-foreground)]">words/sentence</p>
547
+ </div>
548
+ <div className="rounded-lg bg-[var(--muted)] p-2.5">
549
+ <p className="text-xs text-[var(--muted-foreground)]">Reading Time</p>
550
+ <p className="text-lg font-bold text-[var(--foreground)]">{readability.readingTime}</p>
551
+ <p className="text-[10px] text-[var(--muted-foreground)]">min</p>
552
+ </div>
553
+ </div>
554
+ <div className="mt-3 flex items-center gap-2 text-xs text-[var(--muted-foreground)]">
555
+ <span>Passive voice est.:</span>
556
+ <span className={readability.passiveEstimate > 15 ? 'text-amber-500 font-medium' : 'text-green-500 font-medium'}>
557
+ {readability.passiveEstimate}%
558
+ </span>
559
+ </div>
560
+ </Section>
561
+
562
+ {/* Basic SEO */}
563
+ <Section
564
+ id="basic"
565
+ title="Basic SEO"
566
+ icon={<Target className="h-4 w-4" />}
567
+ expanded={expandedSections.includes('basic')}
568
+ onToggle={toggleSection}
569
+ >
570
+ <div className="space-y-3">
571
+ <InputField
572
+ label="Meta Title"
573
+ value={metaTitle}
574
+ onChange={(v) => update({ metaTitle: v })}
575
+ placeholder={title || 'Enter meta title'}
576
+ charCount={metaTitle.length}
577
+ charTarget="(ideal: 30-60)"
578
+ />
579
+ <TextareaField
580
+ label="Meta Description"
581
+ value={metaDesc}
582
+ onChange={(v) => update({ metaDescription: v })}
583
+ placeholder="Brief description for search engines"
584
+ charCount={metaDesc.length}
585
+ charTarget="(ideal: 120-160)"
586
+ />
587
+ <InputField
588
+ label="Focus Keyphrase"
589
+ value={seoData.focusKeyphrase ?? ''}
590
+ onChange={(v) => update({ focusKeyphrase: v })}
591
+ placeholder="Primary keyword or phrase"
592
+ />
593
+ <InputField
594
+ label="Canonical URL"
595
+ value={seoData.canonical ?? ''}
596
+ onChange={(v) => update({ canonical: v })}
597
+ placeholder="https://example.com/canonical-url"
598
+ type="url"
599
+ />
600
+ </div>
601
+ </Section>
602
+
603
+ {/* Robots Meta */}
604
+ <Section
605
+ id="robots"
606
+ title="Robots Meta"
607
+ icon={<EyeOff className="h-4 w-4" />}
608
+ expanded={expandedSections.includes('robots')}
609
+ onToggle={toggleSection}
610
+ >
611
+ <div className="space-y-4">
612
+ <ToggleSwitch
613
+ label="No Index"
614
+ description="Prevent search engines from indexing this page"
615
+ checked={seoData.noIndex ?? false}
616
+ onChange={(v) => update({ noIndex: v })}
617
+ />
618
+ <ToggleSwitch
619
+ label="No Follow"
620
+ description="Prevent search engines from following links"
621
+ checked={seoData.noFollow ?? false}
622
+ onChange={(v) => update({ noFollow: v })}
623
+ />
624
+ </div>
625
+ </Section>
626
+
627
+ {/* Search Preview */}
628
+ <Section
629
+ id="preview"
630
+ title="Search Preview"
631
+ icon={<Globe className="h-4 w-4" />}
632
+ expanded={expandedSections.includes('preview')}
633
+ onToggle={toggleSection}
634
+ >
635
+ <div className="rounded-lg border border-[var(--border)] p-3 bg-[var(--background)]">
636
+ <div className="text-sm text-blue-600 hover:underline cursor-pointer line-clamp-1">
637
+ {metaTitle || title || 'Page Title'}
638
+ </div>
639
+ <div className="text-xs text-green-700 mt-1 truncate">
640
+ {siteUrl}/{slug}
641
+ </div>
642
+ <div className="text-sm text-[var(--muted-foreground)] mt-1 line-clamp-2">
643
+ {metaDesc || 'Add a meta description to see how this page will appear in search results.'}
644
+ </div>
645
+ </div>
646
+ </Section>
647
+
648
+ {/* Social Media */}
649
+ <Section
650
+ id="social"
651
+ title="Social Media"
652
+ icon={<Share2 className="h-4 w-4" />}
653
+ expanded={expandedSections.includes('social')}
654
+ onToggle={toggleSection}
655
+ >
656
+ <div className="space-y-3">
657
+ <InputField
658
+ label="OG Title"
659
+ value={seoData.ogTitle ?? ''}
660
+ onChange={(v) => update({ ogTitle: v })}
661
+ placeholder={metaTitle || title || 'Leave blank to use meta title'}
662
+ />
663
+ <TextareaField
664
+ label="OG Description"
665
+ value={seoData.ogDescription ?? ''}
666
+ onChange={(v) => update({ ogDescription: v })}
667
+ rows={2}
668
+ placeholder={metaDesc || 'Leave blank to use meta description'}
669
+ />
670
+ <InputField
671
+ label="OG Image URL"
672
+ value={seoData.ogImage ?? ''}
673
+ onChange={(v) => update({ ogImage: v })}
674
+ placeholder="https://example.com/og-image.jpg"
675
+ type="url"
676
+ />
677
+
678
+ <div className="border-t border-[var(--border)] pt-3 mt-3">
679
+ <p className="text-xs font-medium text-[var(--muted-foreground)] mb-2">Twitter / X Overrides</p>
680
+ <div className="space-y-3">
681
+ <InputField
682
+ label="Twitter Title"
683
+ value={seoData.twitterTitle ?? ''}
684
+ onChange={(v) => update({ twitterTitle: v })}
685
+ placeholder="Leave blank to use OG title"
686
+ />
687
+ <TextareaField
688
+ label="Twitter Description"
689
+ value={seoData.twitterDescription ?? ''}
690
+ onChange={(v) => update({ twitterDescription: v })}
691
+ rows={2}
692
+ placeholder="Leave blank to use OG description"
693
+ />
694
+ <InputField
695
+ label="Twitter Image URL"
696
+ value={seoData.twitterImage ?? ''}
697
+ onChange={(v) => update({ twitterImage: v })}
698
+ placeholder="Leave blank to use OG image"
699
+ type="url"
700
+ />
701
+ </div>
702
+ </div>
703
+
704
+ {/* Social preview card */}
705
+ <div className="mt-3">
706
+ <p className="text-xs font-medium text-[var(--muted-foreground)] mb-2">Social Preview</p>
707
+ <div className="rounded-lg border border-[var(--border)] overflow-hidden bg-[var(--muted)]">
708
+ {seoData.ogImage ? (
709
+ <div className="aspect-video bg-[var(--muted)] flex items-center justify-center overflow-hidden">
710
+ <img src={seoData.ogImage} alt="OG preview" className="w-full h-full object-cover" />
711
+ </div>
712
+ ) : (
713
+ <div className="aspect-video bg-[var(--muted)] flex items-center justify-center text-[var(--muted-foreground)] text-sm">
714
+ No OG image set
715
+ </div>
716
+ )}
717
+ <div className="p-3">
718
+ <div className="text-sm font-medium text-[var(--foreground)] line-clamp-1">{displayTitle}</div>
719
+ <div className="text-xs text-[var(--muted-foreground)] mt-1 line-clamp-2">
720
+ {displayDesc.slice(0, 100)}
721
+ </div>
722
+ <div className="text-xs text-[var(--muted-foreground)] mt-1 truncate">{siteUrl}</div>
723
+ </div>
724
+ </div>
725
+ </div>
726
+ </div>
727
+ </Section>
728
+
729
+ {/* Advanced */}
730
+ <Section
731
+ id="advanced"
732
+ title="Advanced"
733
+ icon={<Settings2 className="h-4 w-4" />}
734
+ expanded={expandedSections.includes('advanced')}
735
+ onToggle={toggleSection}
736
+ >
737
+ <div className="space-y-4">
738
+ <ToggleSwitch
739
+ label="Cornerstone Content"
740
+ description="Mark as cornerstone — your most important, comprehensive articles"
741
+ checked={seoData.isCornerstone ?? false}
742
+ onChange={(v) => update({ isCornerstone: v })}
743
+ />
744
+ {seoData.isCornerstone && (
745
+ <div className="flex items-center gap-1.5 text-xs text-amber-600 bg-amber-50 rounded-md px-2.5 py-1.5">
746
+ <Star className="h-3.5 w-3.5" />
747
+ Cornerstone content is held to stricter SEO standards
748
+ </div>
749
+ )}
750
+ <div>
751
+ <label className="block text-xs font-medium text-[var(--muted-foreground)] mb-1">Schema Type</label>
752
+ <select
753
+ value={seoData.schemaType ?? 'Article'}
754
+ onChange={(e) => update({ schemaType: e.target.value })}
755
+ className="w-full px-3 py-1.5 text-sm border border-[var(--border)] rounded-lg bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)]"
756
+ >
757
+ {SCHEMA_TYPES.map((t) => (
758
+ <option key={t} value={t}>{t}</option>
759
+ ))}
760
+ </select>
761
+ </div>
762
+ </div>
763
+ </Section>
764
+ </div>
765
+ </div>
766
+ );
767
+ }