@actuate-media/cms-admin 0.19.0 → 0.21.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 (129) hide show
  1. package/dist/AdminRoot.d.ts.map +1 -1
  2. package/dist/AdminRoot.js +13 -9
  3. package/dist/AdminRoot.js.map +1 -1
  4. package/dist/__tests__/layout/sidebar-posts.test.js +6 -4
  5. package/dist/__tests__/layout/sidebar-posts.test.js.map +1 -1
  6. package/dist/__tests__/lib/seo-service-errors.test.d.ts +2 -0
  7. package/dist/__tests__/lib/seo-service-errors.test.d.ts.map +1 -0
  8. package/dist/__tests__/lib/seo-service-errors.test.js +46 -0
  9. package/dist/__tests__/lib/seo-service-errors.test.js.map +1 -0
  10. package/dist/__tests__/views/use-seo-resource.test.d.ts +2 -0
  11. package/dist/__tests__/views/use-seo-resource.test.d.ts.map +1 -0
  12. package/dist/__tests__/views/use-seo-resource.test.js +56 -0
  13. package/dist/__tests__/views/use-seo-resource.test.js.map +1 -0
  14. package/dist/actuate-admin.css +1 -1
  15. package/dist/components/Breadcrumbs.d.ts.map +1 -1
  16. package/dist/components/Breadcrumbs.js +4 -1
  17. package/dist/components/Breadcrumbs.js.map +1 -1
  18. package/dist/components/seo/Drawer.d.ts +11 -0
  19. package/dist/components/seo/Drawer.d.ts.map +1 -0
  20. package/dist/components/seo/Drawer.js +17 -0
  21. package/dist/components/seo/Drawer.js.map +1 -0
  22. package/dist/components/seo/RedirectEditorDrawer.d.ts +9 -0
  23. package/dist/components/seo/RedirectEditorDrawer.d.ts.map +1 -0
  24. package/dist/components/seo/RedirectEditorDrawer.js +94 -0
  25. package/dist/components/seo/RedirectEditorDrawer.js.map +1 -0
  26. package/dist/components/seo/SeoCopilotDrawer.d.ts +10 -0
  27. package/dist/components/seo/SeoCopilotDrawer.d.ts.map +1 -0
  28. package/dist/components/seo/SeoCopilotDrawer.js +34 -0
  29. package/dist/components/seo/SeoCopilotDrawer.js.map +1 -0
  30. package/dist/components/seo/SeoEditorDrawer.d.ts +8 -0
  31. package/dist/components/seo/SeoEditorDrawer.d.ts.map +1 -0
  32. package/dist/components/seo/SeoEditorDrawer.js +120 -0
  33. package/dist/components/seo/SeoEditorDrawer.js.map +1 -0
  34. package/dist/components/seo/SeoIssueFixDrawer.d.ts +8 -0
  35. package/dist/components/seo/SeoIssueFixDrawer.d.ts.map +1 -0
  36. package/dist/components/seo/SeoIssueFixDrawer.js +111 -0
  37. package/dist/components/seo/SeoIssueFixDrawer.js.map +1 -0
  38. package/dist/components/seo/primitives.d.ts +53 -0
  39. package/dist/components/seo/primitives.d.ts.map +1 -0
  40. package/dist/components/seo/primitives.js +101 -0
  41. package/dist/components/seo/primitives.js.map +1 -0
  42. package/dist/layout/Sidebar.d.ts.map +1 -1
  43. package/dist/layout/Sidebar.js +8 -5
  44. package/dist/layout/Sidebar.js.map +1 -1
  45. package/dist/lib/seo-service.d.ts +322 -0
  46. package/dist/lib/seo-service.d.ts.map +1 -0
  47. package/dist/lib/seo-service.js +331 -0
  48. package/dist/lib/seo-service.js.map +1 -0
  49. package/dist/router/index.d.ts.map +1 -1
  50. package/dist/router/index.js +4 -1
  51. package/dist/router/index.js.map +1 -1
  52. package/dist/views/ApiKeys.d.ts +4 -1
  53. package/dist/views/ApiKeys.d.ts.map +1 -1
  54. package/dist/views/ApiKeys.js +5 -3
  55. package/dist/views/ApiKeys.js.map +1 -1
  56. package/dist/views/Dashboard.js +2 -2
  57. package/dist/views/Dashboard.js.map +1 -1
  58. package/dist/views/MediaBrowser.d.ts.map +1 -1
  59. package/dist/views/MediaBrowser.js +36 -25
  60. package/dist/views/MediaBrowser.js.map +1 -1
  61. package/dist/views/Posts/NewPostTypeInstructions.js +2 -2
  62. package/dist/views/Posts/post-type-presets.d.ts +5 -0
  63. package/dist/views/Posts/post-type-presets.d.ts.map +1 -1
  64. package/dist/views/Posts/post-type-presets.js +10 -64
  65. package/dist/views/Posts/post-type-presets.js.map +1 -1
  66. package/dist/views/SEO.d.ts.map +1 -1
  67. package/dist/views/SEO.js +89 -132
  68. package/dist/views/SEO.js.map +1 -1
  69. package/dist/views/Settings.d.ts +4 -1
  70. package/dist/views/Settings.d.ts.map +1 -1
  71. package/dist/views/Settings.js +45 -6
  72. package/dist/views/Settings.js.map +1 -1
  73. package/dist/views/Users.d.ts +4 -1
  74. package/dist/views/Users.d.ts.map +1 -1
  75. package/dist/views/Users.js +5 -3
  76. package/dist/views/Users.js.map +1 -1
  77. package/dist/views/seo/AuditTab.d.ts +6 -0
  78. package/dist/views/seo/AuditTab.d.ts.map +1 -0
  79. package/dist/views/seo/AuditTab.js +57 -0
  80. package/dist/views/seo/AuditTab.js.map +1 -0
  81. package/dist/views/seo/ContentTab.d.ts +5 -0
  82. package/dist/views/seo/ContentTab.d.ts.map +1 -0
  83. package/dist/views/seo/ContentTab.js +27 -0
  84. package/dist/views/seo/ContentTab.js.map +1 -0
  85. package/dist/views/seo/OverviewTab.d.ts +8 -0
  86. package/dist/views/seo/OverviewTab.d.ts.map +1 -0
  87. package/dist/views/seo/OverviewTab.js +35 -0
  88. package/dist/views/seo/OverviewTab.js.map +1 -0
  89. package/dist/views/seo/RedirectsTab.d.ts +2 -0
  90. package/dist/views/seo/RedirectsTab.d.ts.map +1 -0
  91. package/dist/views/seo/RedirectsTab.js +130 -0
  92. package/dist/views/seo/RedirectsTab.js.map +1 -0
  93. package/dist/views/seo/TechnicalTab.d.ts +2 -0
  94. package/dist/views/seo/TechnicalTab.d.ts.map +1 -0
  95. package/dist/views/seo/TechnicalTab.js +105 -0
  96. package/dist/views/seo/TechnicalTab.js.map +1 -0
  97. package/dist/views/seo/useSeoResource.d.ts +8 -0
  98. package/dist/views/seo/useSeoResource.d.ts.map +1 -0
  99. package/dist/views/seo/useSeoResource.js +55 -0
  100. package/dist/views/seo/useSeoResource.js.map +1 -0
  101. package/package.json +2 -2
  102. package/src/AdminRoot.tsx +13 -9
  103. package/src/__tests__/layout/sidebar-posts.test.ts +6 -4
  104. package/src/__tests__/lib/seo-service-errors.test.ts +60 -0
  105. package/src/__tests__/views/use-seo-resource.test.tsx +68 -0
  106. package/src/components/Breadcrumbs.tsx +4 -1
  107. package/src/components/seo/Drawer.tsx +65 -0
  108. package/src/components/seo/RedirectEditorDrawer.tsx +235 -0
  109. package/src/components/seo/SeoCopilotDrawer.tsx +76 -0
  110. package/src/components/seo/SeoEditorDrawer.tsx +359 -0
  111. package/src/components/seo/SeoIssueFixDrawer.tsx +244 -0
  112. package/src/components/seo/primitives.tsx +240 -0
  113. package/src/layout/Sidebar.tsx +7 -10
  114. package/src/lib/seo-service.ts +660 -0
  115. package/src/router/index.ts +4 -1
  116. package/src/views/ApiKeys.tsx +10 -5
  117. package/src/views/Dashboard.tsx +2 -2
  118. package/src/views/MediaBrowser.tsx +89 -67
  119. package/src/views/Posts/NewPostTypeInstructions.tsx +2 -2
  120. package/src/views/Posts/post-type-presets.ts +15 -64
  121. package/src/views/SEO.tsx +186 -863
  122. package/src/views/Settings.tsx +93 -40
  123. package/src/views/Users.tsx +10 -4
  124. package/src/views/seo/AuditTab.tsx +229 -0
  125. package/src/views/seo/ContentTab.tsx +164 -0
  126. package/src/views/seo/OverviewTab.tsx +257 -0
  127. package/src/views/seo/RedirectsTab.tsx +398 -0
  128. package/src/views/seo/TechnicalTab.tsx +315 -0
  129. package/src/views/seo/useSeoResource.ts +66 -0
@@ -0,0 +1,315 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useId, useState } from 'react'
4
+ import { RefreshCw, Map, AlertTriangle, CheckCircle2, ExternalLink } from 'lucide-react'
5
+ import * as Switch from '@radix-ui/react-switch'
6
+ import { toast } from 'sonner'
7
+ import {
8
+ fetchSitemapStatus,
9
+ regenerateSitemap,
10
+ fetchCrawlSettings,
11
+ updateCrawlSettings,
12
+ fetchRobotsTxt,
13
+ updateRobotsTxt,
14
+ validateRobotsTxt,
15
+ type CrawlSettings,
16
+ } from '../../lib/seo-service.js'
17
+ import {
18
+ SectionCard,
19
+ SeoLoading,
20
+ SeoErrorState,
21
+ btnPrimary,
22
+ btnSecondary,
23
+ } from '../../components/seo/primitives.js'
24
+ import { useSeoResource } from './useSeoResource.js'
25
+
26
+ const CRAWL_TOGGLES: { key: keyof CrawlSettings; label: string; help: string }[] = [
27
+ {
28
+ key: 'indexEntireSite',
29
+ label: 'Index entire site',
30
+ help: 'Allow search engines to index all public pages.',
31
+ },
32
+ {
33
+ key: 'includeImagesInSitemap',
34
+ label: 'Include images in sitemap',
35
+ help: 'Add image entries to the XML sitemap.',
36
+ },
37
+ {
38
+ key: 'includePostsInSitemap',
39
+ label: 'Include posts in sitemap',
40
+ help: 'Add published posts to the sitemap.',
41
+ },
42
+ {
43
+ key: 'noindexPaginatedArchives',
44
+ label: 'No-index paginated archives',
45
+ help: 'Avoid duplicate-content penalties on page 2+.',
46
+ },
47
+ {
48
+ key: 'noindexTagPages',
49
+ label: 'No-index tag pages',
50
+ help: 'Keep thin tag archives out of the index.',
51
+ },
52
+ ]
53
+
54
+ function Toggle({
55
+ checked,
56
+ onChange,
57
+ label,
58
+ help,
59
+ disabled,
60
+ }: {
61
+ checked: boolean
62
+ onChange: (v: boolean) => void
63
+ label: string
64
+ help: string
65
+ disabled?: boolean
66
+ }) {
67
+ const labelId = useId()
68
+ const helpId = useId()
69
+ return (
70
+ <div className="flex items-center justify-between gap-3 py-2.5">
71
+ <span className="min-w-0">
72
+ <span id={labelId} className="text-foreground block text-sm font-medium">
73
+ {label}
74
+ </span>
75
+ <span id={helpId} className="text-muted-foreground block text-sm">
76
+ {help}
77
+ </span>
78
+ </span>
79
+ <Switch.Root
80
+ checked={checked}
81
+ onCheckedChange={onChange}
82
+ disabled={disabled}
83
+ aria-labelledby={labelId}
84
+ aria-describedby={helpId}
85
+ className="bg-input-background data-[state=checked]:bg-primary focus-visible:ring-ring relative h-[18px] w-8 shrink-0 rounded-full transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
86
+ >
87
+ <Switch.Thumb className="bg-background block h-3.5 w-3.5 translate-x-0.5 rounded-full shadow-sm transition-transform data-[state=checked]:translate-x-[14px]" />
88
+ </Switch.Root>
89
+ </div>
90
+ )
91
+ }
92
+
93
+ function SitemapCard() {
94
+ const { data, loading, error, refetch } = useSeoResource(fetchSitemapStatus, [])
95
+ const [busy, setBusy] = useState(false)
96
+
97
+ async function handleRegenerate() {
98
+ setBusy(true)
99
+ const res = await regenerateSitemap()
100
+ setBusy(false)
101
+ if (res.error) {
102
+ toast.error(res.error)
103
+ return
104
+ }
105
+ toast.success('Sitemap regenerated.')
106
+ refetch()
107
+ }
108
+
109
+ return (
110
+ <SectionCard
111
+ title="Sitemap"
112
+ action={
113
+ <button className={btnSecondary} onClick={handleRegenerate} disabled={busy || loading}>
114
+ <RefreshCw className={`h-4 w-4 ${busy ? 'animate-spin' : ''}`} aria-hidden />
115
+ Regenerate
116
+ </button>
117
+ }
118
+ >
119
+ {loading ? (
120
+ <SeoLoading label="Loading sitemap status…" />
121
+ ) : error ? (
122
+ <SeoErrorState message={error} onRetry={refetch} />
123
+ ) : !data ? (
124
+ <p className="text-muted-foreground text-sm">Sitemap status unavailable.</p>
125
+ ) : (
126
+ <div className="space-y-3">
127
+ <a
128
+ href={data.sitemapUrl}
129
+ target="_blank"
130
+ rel="noopener noreferrer"
131
+ className="text-primary inline-flex items-center gap-1 text-sm hover:underline"
132
+ >
133
+ <Map className="h-4 w-4" aria-hidden />
134
+ {data.sitemapUrl}
135
+ <ExternalLink className="h-3.5 w-3.5" aria-hidden />
136
+ </a>
137
+ <dl className="grid grid-cols-2 gap-3 text-sm">
138
+ <div>
139
+ <dt className="text-muted-foreground">Pages included</dt>
140
+ <dd className="text-foreground font-medium tabular-nums">{data.pagesIncluded}</dd>
141
+ </div>
142
+ <div>
143
+ <dt className="text-muted-foreground">Pages excluded</dt>
144
+ <dd className="text-foreground font-medium tabular-nums">{data.pagesExcluded}</dd>
145
+ </div>
146
+ <div className="col-span-2">
147
+ <dt className="text-muted-foreground">Last generated</dt>
148
+ <dd className="text-foreground">
149
+ {data.lastGeneratedAt ? new Date(data.lastGeneratedAt).toLocaleString() : 'Never'}
150
+ </dd>
151
+ </div>
152
+ </dl>
153
+ {data.warnings.length > 0 && (
154
+ <ul className="space-y-1">
155
+ {data.warnings.map((w, i) => (
156
+ <li key={i} className="text-warning flex items-center gap-1.5 text-sm">
157
+ <AlertTriangle className="h-3.5 w-3.5" aria-hidden /> {w}
158
+ </li>
159
+ ))}
160
+ </ul>
161
+ )}
162
+ </div>
163
+ )}
164
+ </SectionCard>
165
+ )
166
+ }
167
+
168
+ function CrawlCard() {
169
+ const { data, loading, error, refetch } = useSeoResource(fetchCrawlSettings, [])
170
+ const [settings, setSettings] = useState<CrawlSettings | null>(null)
171
+ const [saving, setSaving] = useState(false)
172
+
173
+ useEffect(() => {
174
+ if (data) setSettings(data)
175
+ }, [data])
176
+
177
+ async function save(next: CrawlSettings, changed: keyof CrawlSettings) {
178
+ setSettings(next)
179
+ setSaving(true)
180
+ const res = await updateCrawlSettings({ [changed]: next[changed] })
181
+ setSaving(false)
182
+ if (res.error) {
183
+ toast.error(res.error)
184
+ refetch()
185
+ }
186
+ }
187
+
188
+ return (
189
+ <SectionCard title="Crawl Settings">
190
+ {loading ? (
191
+ <SeoLoading label="Loading crawl settings…" />
192
+ ) : error ? (
193
+ <SeoErrorState message={error} onRetry={refetch} />
194
+ ) : !settings ? (
195
+ <p className="text-muted-foreground text-sm">Crawl settings unavailable.</p>
196
+ ) : (
197
+ <div className="divide-border divide-y" aria-busy={saving}>
198
+ {CRAWL_TOGGLES.map((t) => (
199
+ <Toggle
200
+ key={t.key}
201
+ label={t.label}
202
+ help={t.help}
203
+ checked={settings[t.key]}
204
+ disabled={saving}
205
+ onChange={(v) => save({ ...settings, [t.key]: v }, t.key)}
206
+ />
207
+ ))}
208
+ </div>
209
+ )}
210
+ </SectionCard>
211
+ )
212
+ }
213
+
214
+ function RobotsCard() {
215
+ const { data, loading, error, refetch } = useSeoResource(fetchRobotsTxt, [])
216
+ const [body, setBody] = useState('')
217
+ const [saving, setSaving] = useState(false)
218
+
219
+ useEffect(() => {
220
+ if (data) setBody(data.body)
221
+ }, [data])
222
+
223
+ const validation = validateRobotsTxt(body)
224
+
225
+ async function handleSave() {
226
+ setSaving(true)
227
+ const res = await updateRobotsTxt(body)
228
+ setSaving(false)
229
+ if (res.error) {
230
+ toast.error(res.error)
231
+ return
232
+ }
233
+ toast.success('robots.txt saved.')
234
+ refetch()
235
+ }
236
+
237
+ return (
238
+ <SectionCard
239
+ title="robots.txt"
240
+ description="Controls which crawlers can access your site."
241
+ action={
242
+ <button
243
+ className={btnPrimary}
244
+ onClick={handleSave}
245
+ disabled={saving || loading || !validation.valid}
246
+ >
247
+ {saving ? 'Saving…' : 'Save'}
248
+ </button>
249
+ }
250
+ >
251
+ {loading ? (
252
+ <SeoLoading label="Loading robots.txt…" />
253
+ ) : error ? (
254
+ <SeoErrorState message={error} onRetry={refetch} />
255
+ ) : (
256
+ <div className="space-y-3">
257
+ {validation.blocksEntireSite && (
258
+ <div
259
+ className="border-destructive/30 bg-destructive/10 flex items-start gap-2 rounded-md border p-3"
260
+ role="alert"
261
+ >
262
+ <AlertTriangle className="text-destructive mt-0.5 h-4 w-4 shrink-0" aria-hidden />
263
+ <p className="text-destructive text-sm">
264
+ This configuration blocks your entire site from all search engines. Pages will be
265
+ removed from search results.
266
+ </p>
267
+ </div>
268
+ )}
269
+ <label className="sr-only" htmlFor="robots-editor">
270
+ robots.txt contents
271
+ </label>
272
+ <textarea
273
+ id="robots-editor"
274
+ value={body}
275
+ onChange={(e) => setBody(e.target.value)}
276
+ spellCheck={false}
277
+ className="border-border bg-input-background text-foreground focus-visible:ring-ring min-h-48 w-full rounded-md border p-3 font-mono text-sm focus-visible:ring-2 focus-visible:outline-none"
278
+ />
279
+ {validation.errors.length > 0 ? (
280
+ <ul className="space-y-1">
281
+ {validation.errors.map((e, i) => (
282
+ <li key={i} className="text-destructive flex items-center gap-1.5 text-sm">
283
+ <AlertTriangle className="h-3.5 w-3.5" aria-hidden /> {e}
284
+ </li>
285
+ ))}
286
+ </ul>
287
+ ) : (
288
+ <p className="text-success flex items-center gap-1.5 text-sm">
289
+ <CheckCircle2 className="h-3.5 w-3.5" aria-hidden /> Valid robots.txt
290
+ </p>
291
+ )}
292
+ {validation.warnings
293
+ .filter((w) => !validation.blocksEntireSite || !w.includes('blocks the entire site'))
294
+ .map((w, i) => (
295
+ <p key={i} className="text-warning flex items-center gap-1.5 text-sm">
296
+ <AlertTriangle className="h-3.5 w-3.5" aria-hidden /> {w}
297
+ </p>
298
+ ))}
299
+ </div>
300
+ )}
301
+ </SectionCard>
302
+ )
303
+ }
304
+
305
+ export function TechnicalTab() {
306
+ return (
307
+ <div className="space-y-4">
308
+ <div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
309
+ <SitemapCard />
310
+ <CrawlCard />
311
+ </div>
312
+ <RobotsCard />
313
+ </div>
314
+ )
315
+ }
@@ -0,0 +1,66 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Small async-resource hook for the SEO tabs: wraps a service fetcher with
5
+ * loading / error / data / refetch and abort-on-unmount safety. Keeps each
6
+ * tab declarative without re-implementing fetch plumbing.
7
+ */
8
+ import { useCallback, useEffect, useRef, useState } from 'react'
9
+
10
+ export interface SeoResource<T> {
11
+ data: T | null
12
+ loading: boolean
13
+ error: string | null
14
+ refetch: () => void
15
+ }
16
+
17
+ export function useSeoResource<T>(
18
+ fetcher: () => Promise<T>,
19
+ deps: ReadonlyArray<unknown> = [],
20
+ ): SeoResource<T> {
21
+ const [data, setData] = useState<T | null>(null)
22
+ const [loading, setLoading] = useState(true)
23
+ const [error, setError] = useState<string | null>(null)
24
+ // Tracks whether the component is still mounted (guards setState after
25
+ // unmount). Set once on mount; never toggled by dependency-driven reloads.
26
+ const mountedRef = useRef(true)
27
+ // Monotonic id of the most recently *started* request. A response is only
28
+ // applied when its id still matches — otherwise a slow earlier request that
29
+ // resolves after a faster later one (rapid filter switches) would clobber
30
+ // the newer, correct data.
31
+ const requestIdRef = useRef(0)
32
+ // eslint-disable-next-line react-hooks/exhaustive-deps
33
+ const stableFetcher = useCallback(fetcher, deps)
34
+
35
+ const load = useCallback(async () => {
36
+ const requestId = ++requestIdRef.current
37
+ const isCurrent = () => mountedRef.current && requestId === requestIdRef.current
38
+ setLoading(true)
39
+ setError(null)
40
+ try {
41
+ const result = await stableFetcher()
42
+ if (isCurrent()) setData(result)
43
+ } catch (err) {
44
+ if (isCurrent()) {
45
+ setError(err instanceof Error ? err.message : 'Failed to load data')
46
+ }
47
+ } finally {
48
+ if (isCurrent()) setLoading(false)
49
+ }
50
+ }, [stableFetcher])
51
+
52
+ // Mount/unmount guard only — independent of `load` so a dependency change
53
+ // (which recreates `load`) never flips `mountedRef` to false mid-flight.
54
+ useEffect(() => {
55
+ mountedRef.current = true
56
+ return () => {
57
+ mountedRef.current = false
58
+ }
59
+ }, [])
60
+
61
+ useEffect(() => {
62
+ load()
63
+ }, [load])
64
+
65
+ return { data, loading, error, refetch: load }
66
+ }