@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
@@ -20,21 +20,36 @@ import {
20
20
  RefreshCw,
21
21
  GitPullRequest,
22
22
  Layers,
23
+ Users as UsersIcon,
24
+ KeyRound,
23
25
  } from 'lucide-react'
24
- import { useState, useEffect } from 'react'
26
+ import { useState, useEffect, useCallback, useMemo } from 'react'
25
27
  import { toast } from 'sonner'
26
28
  import { useApiData } from '../lib/useApiData.js'
27
29
  import { cmsApi } from '../lib/api.js'
28
30
  import { useTheme } from '../components/ThemeProvider.js'
29
31
  import { RelationshipField } from '../fields/RelationshipField.js'
30
32
  import { SEOConfigPanel } from '../components/SEOConfigPanel.js'
33
+ import { Users as UsersView } from './Users.js'
34
+ import { ApiKeys as ApiKeysView } from './ApiKeys.js'
31
35
 
32
36
  export interface SettingsProps {
33
37
  onNavigate?: (path: string) => void
34
38
  config?: any
39
+ /** Initial tab when arriving without a `?tab=` query param (e.g. via the
40
+ * legacy `/users` or `/api-keys` routes). */
41
+ initialTab?: string
35
42
  }
36
43
 
37
- export function Settings({ config, ..._props }: SettingsProps = {}) {
44
+ /** Tabs that drive their own persistence (no global "Save Changes" footer). */
45
+ const SELF_MANAGED_TABS = new Set(['users', 'api-keys'])
46
+
47
+ function readSettingsTabFromUrl(): string | null {
48
+ if (typeof window === 'undefined') return null
49
+ return new URLSearchParams(window.location.search).get('tab')
50
+ }
51
+
52
+ export function Settings({ config, onNavigate, initialTab = 'general' }: SettingsProps = {}) {
38
53
  const { data, loading, error, refetch } = useApiData<any>('/globals/settings')
39
54
 
40
55
  const [siteTitle, setSiteTitle] = useState('My CMS')
@@ -48,7 +63,7 @@ export function Settings({ config, ..._props }: SettingsProps = {}) {
48
63
  const [twoFactorEnabled, setTwoFactorEnabled] = useState(false)
49
64
  const [sessionTimeout, setSessionTimeout] = useState(false)
50
65
  const [ipWhitelist, setIpWhitelist] = useState(false)
51
- const [activeTab, setActiveTab] = useState('general')
66
+ const [activeTab, setActiveTab] = useState<string>(() => readSettingsTabFromUrl() ?? initialTab)
52
67
  const [saving, setSaving] = useState(false)
53
68
 
54
69
  // Layout defaults
@@ -64,6 +79,50 @@ export function Settings({ config, ..._props }: SettingsProps = {}) {
64
79
  : []
65
80
  const hasLayoutRegions = layoutRegions.length > 0
66
81
 
82
+ // Valid tab ids, with `layout` only available when the project configures
83
+ // layout regions. Used to normalise deep links and keep the URL honest.
84
+ const availableTabs = useMemo(
85
+ () =>
86
+ [
87
+ 'general',
88
+ 'appearance',
89
+ ...(hasLayoutRegions ? ['layout'] : []),
90
+ 'security',
91
+ 'users',
92
+ 'api-keys',
93
+ 'seo',
94
+ 'ai',
95
+ 'updates',
96
+ ] as const,
97
+ [hasLayoutRegions],
98
+ )
99
+ const normalizeTab = useCallback(
100
+ (tab: string | null | undefined): string =>
101
+ tab && (availableTabs as readonly string[]).includes(tab) ? tab : 'general',
102
+ [availableTabs],
103
+ )
104
+
105
+ // Drive the active tab from the `?tab=` query param so tabs are deep-linkable
106
+ // and survive browser back/forward (the legacy /users and /api-keys routes
107
+ // land here via `initialTab`).
108
+ useEffect(() => {
109
+ function sync() {
110
+ setActiveTab(normalizeTab(readSettingsTabFromUrl() ?? initialTab))
111
+ }
112
+ sync()
113
+ window.addEventListener('popstate', sync)
114
+ return () => window.removeEventListener('popstate', sync)
115
+ }, [normalizeTab, initialTab])
116
+
117
+ const changeTab = useCallback(
118
+ (tab: string) => {
119
+ const next = normalizeTab(tab)
120
+ setActiveTab(next)
121
+ onNavigate?.(`/settings?tab=${next}`)
122
+ },
123
+ [normalizeTab, onNavigate],
124
+ )
125
+
67
126
  // AI settings
68
127
  const [aiProvider, setAiProvider] = useState('anthropic')
69
128
  const [aiApiKey, setAiApiKey] = useState('')
@@ -176,7 +235,7 @@ export function Settings({ config, ..._props }: SettingsProps = {}) {
176
235
  <p className="text-sm text-gray-600">Manage your CMS configuration</p>
177
236
  </div>
178
237
 
179
- <Tabs.Root value={activeTab} onValueChange={setActiveTab}>
238
+ <Tabs.Root value={activeTab} onValueChange={changeTab}>
180
239
  <Tabs.List className="mb-4 flex gap-1 overflow-x-auto border-b border-gray-200">
181
240
  <Tabs.Trigger value="general" className={tabTriggerClass}>
182
241
  General
@@ -195,6 +254,18 @@ export function Settings({ config, ..._props }: SettingsProps = {}) {
195
254
  <Tabs.Trigger value="security" className={tabTriggerClass}>
196
255
  Security
197
256
  </Tabs.Trigger>
257
+ <Tabs.Trigger value="users" className={tabTriggerClass}>
258
+ <span className="flex items-center gap-1.5">
259
+ <UsersIcon className="h-4 w-4" />
260
+ Users
261
+ </span>
262
+ </Tabs.Trigger>
263
+ <Tabs.Trigger value="api-keys" className={tabTriggerClass}>
264
+ <span className="flex items-center gap-1.5">
265
+ <KeyRound className="h-4 w-4" />
266
+ API Keys
267
+ </span>
268
+ </Tabs.Trigger>
198
269
  <Tabs.Trigger value="seo" className={tabTriggerClass}>
199
270
  SEO
200
271
  </Tabs.Trigger>
@@ -204,9 +275,6 @@ export function Settings({ config, ..._props }: SettingsProps = {}) {
204
275
  AI
205
276
  </span>
206
277
  </Tabs.Trigger>
207
- <Tabs.Trigger value="integrations" className={tabTriggerClass}>
208
- Integrations
209
- </Tabs.Trigger>
210
278
  <Tabs.Trigger value="updates" className={tabTriggerClass}>
211
279
  <span className="flex items-center gap-1.5">
212
280
  <Download className="h-4 w-4" />
@@ -628,29 +696,12 @@ export function Settings({ config, ..._props }: SettingsProps = {}) {
628
696
  </div>
629
697
  </Tabs.Content>
630
698
 
631
- <Tabs.Content value="integrations" className="space-y-4">
632
- <div className="rounded-lg border border-gray-200 bg-white p-4">
633
- <h3 className="mb-4 text-sm font-semibold text-gray-900">API Keys</h3>
634
- <div className="space-y-4">
635
- <div>
636
- <label className="mb-1 block text-sm font-medium text-gray-700">API Key</label>
637
- <div className="flex gap-2">
638
- <input
639
- type="text"
640
- value="•••••••••••••••••••••••••"
641
- readOnly
642
- className="flex-1 rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 text-sm"
643
- />
644
- <button
645
- type="button"
646
- className="rounded-lg border border-gray-300 px-4 py-2 text-sm transition-colors hover:bg-gray-50"
647
- >
648
- Regenerate
649
- </button>
650
- </div>
651
- </div>
652
- </div>
653
- </div>
699
+ <Tabs.Content value="users">
700
+ <UsersView onNavigate={onNavigate} embedded />
701
+ </Tabs.Content>
702
+
703
+ <Tabs.Content value="api-keys">
704
+ <ApiKeysView onNavigate={onNavigate} embedded />
654
705
  </Tabs.Content>
655
706
 
656
707
  <Tabs.Content value="updates" className="space-y-4">
@@ -658,16 +709,18 @@ export function Settings({ config, ..._props }: SettingsProps = {}) {
658
709
  </Tabs.Content>
659
710
  </Tabs.Root>
660
711
 
661
- <div className="mt-6 flex justify-end">
662
- <button
663
- type="button"
664
- onClick={handleSave}
665
- disabled={saving}
666
- className="rounded-lg bg-blue-600 px-6 py-2 text-sm text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
667
- >
668
- {saving ? 'Saving...' : 'Save Changes'}
669
- </button>
670
- </div>
712
+ {!SELF_MANAGED_TABS.has(activeTab) && (
713
+ <div className="mt-6 flex justify-end">
714
+ <button
715
+ type="button"
716
+ onClick={handleSave}
717
+ disabled={saving}
718
+ className="rounded-lg bg-blue-600 px-6 py-2 text-sm text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
719
+ >
720
+ {saving ? 'Saving...' : 'Save Changes'}
721
+ </button>
722
+ </div>
723
+ )}
671
724
  </div>
672
725
  )
673
726
  }
@@ -22,9 +22,12 @@ type UserSortKey = 'name' | 'role' | 'status' | 'lastLogin'
22
22
 
23
23
  export interface UsersProps {
24
24
  onNavigate?: (path: string) => void
25
+ /** When rendered inside the Settings tab shell, drop the page padding and
26
+ * demote the heading so it reads as a section rather than a second page. */
27
+ embedded?: boolean
25
28
  }
26
29
 
27
- export function Users({ onNavigate }: UsersProps) {
30
+ export function Users({ onNavigate, embedded = false }: UsersProps = {}) {
28
31
  const { data, loading, error, refetch } = useApiData<any[]>('/users')
29
32
  const [showInviteDialog, setShowInviteDialog] = useState(false)
30
33
  const [inviteEmail, setInviteEmail] = useState('')
@@ -98,16 +101,19 @@ export function Users({ onNavigate }: UsersProps) {
98
101
  )
99
102
  }
100
103
 
104
+ const rootPadding = embedded ? '' : 'p-3 pr-6 sm:p-4 sm:pr-8'
105
+ const Heading = embedded ? 'h2' : 'h1'
106
+
101
107
  if (loading) {
102
108
  return (
103
- <div className="flex h-64 items-center justify-center p-3 pr-6 sm:p-4 sm:pr-8">
109
+ <div className={`flex h-64 items-center justify-center ${rootPadding}`}>
104
110
  <Loader2 className="h-6 w-6 animate-spin text-blue-600" />
105
111
  </div>
106
112
  )
107
113
  }
108
114
 
109
115
  return (
110
- <div className="p-3 pr-6 sm:p-4 sm:pr-8">
116
+ <div className={rootPadding}>
111
117
  {error && (
112
118
  <div className="mb-4 flex items-center gap-3 rounded-lg border border-red-200 bg-red-50 p-3">
113
119
  <AlertTriangle className="h-5 w-5 shrink-0 text-red-600" />
@@ -123,7 +129,7 @@ export function Users({ onNavigate }: UsersProps) {
123
129
 
124
130
  <div className="mb-4 flex items-center justify-between">
125
131
  <div>
126
- <h1 className="mb-1 text-2xl font-semibold text-gray-900">Users</h1>
132
+ <Heading className="mb-1 text-2xl font-semibold text-gray-900">Users</Heading>
127
133
  <p className="text-sm text-gray-600">{filteredAndSorted.length} total users</p>
128
134
  </div>
129
135
  <button
@@ -0,0 +1,229 @@
1
+ 'use client'
2
+
3
+ import { useMemo, useState } from 'react'
4
+ import { ClipboardCheck, Clock, CheckCircle2 } from 'lucide-react'
5
+ import {
6
+ fetchSeoIssues,
7
+ fetchSeoAuditRuns,
8
+ type SeoIssue,
9
+ type SeoIssueQuery,
10
+ type SeoSeverity,
11
+ type SeoIssueStatus,
12
+ } from '../../lib/seo-service.js'
13
+ import {
14
+ SectionCard,
15
+ SeoLoading,
16
+ SeoEmptyState,
17
+ SeoErrorState,
18
+ severityMeta,
19
+ SeoStatusBadge,
20
+ } from '../../components/seo/primitives.js'
21
+ import { SeoIssueFixDrawer } from '../../components/seo/SeoIssueFixDrawer.js'
22
+ import { useSeoResource } from './useSeoResource.js'
23
+
24
+ const SEVERITY_FILTERS: { id: SeoSeverity | 'all'; label: string }[] = [
25
+ { id: 'all', label: 'All' },
26
+ { id: 'critical', label: 'Critical' },
27
+ { id: 'warning', label: 'Warnings' },
28
+ { id: 'info', label: 'Info' },
29
+ ]
30
+
31
+ const STATUS_FILTERS: { id: SeoIssueStatus | 'all'; label: string }[] = [
32
+ { id: 'open', label: 'Open' },
33
+ { id: 'recurring', label: 'Recurring' },
34
+ { id: 'ignored', label: 'Ignored' },
35
+ { id: 'resolved', label: 'Resolved' },
36
+ { id: 'all', label: 'All' },
37
+ ]
38
+
39
+ function issueStatusTone(status: SeoIssueStatus) {
40
+ switch (status) {
41
+ case 'resolved':
42
+ return 'good' as const
43
+ case 'ignored':
44
+ return 'neutral' as const
45
+ case 'recurring':
46
+ return 'fair' as const
47
+ default:
48
+ return 'critical' as const
49
+ }
50
+ }
51
+
52
+ function AuditSummaryCards({ refetchKey }: { refetchKey: number }) {
53
+ const { data, loading } = useSeoResource(fetchSeoAuditRuns, [refetchKey])
54
+ const last = (data ?? [])[0]
55
+ return (
56
+ <div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
57
+ <div className="border-border bg-card rounded-lg border p-4">
58
+ <p className="text-muted-foreground flex items-center gap-1.5 text-sm">
59
+ <Clock className="h-3.5 w-3.5" aria-hidden /> Last audit
60
+ </p>
61
+ <p className="text-foreground mt-1 text-sm font-medium">
62
+ {loading ? '…' : last ? new Date(last.startedAt).toLocaleString() : 'Never'}
63
+ </p>
64
+ </div>
65
+ <div className="border-border bg-card rounded-lg border p-4">
66
+ <p className="text-muted-foreground text-sm">Pages scanned</p>
67
+ <p className="text-foreground mt-1 text-2xl font-medium tabular-nums">
68
+ {last?.pagesScanned ?? 0}
69
+ </p>
70
+ </div>
71
+ <div className="border-border bg-card rounded-lg border p-4">
72
+ <p className="text-muted-foreground text-sm">Issues found</p>
73
+ <p className="text-foreground mt-1 text-2xl font-medium tabular-nums">
74
+ {last?.issuesFound ?? 0}
75
+ </p>
76
+ </div>
77
+ <div className="border-border bg-card rounded-lg border p-4">
78
+ <p className="text-muted-foreground text-sm">Site score</p>
79
+ <p className="text-foreground mt-1 text-2xl font-medium tabular-nums">
80
+ {last?.siteScoreAfter ?? '—'}
81
+ </p>
82
+ </div>
83
+ </div>
84
+ )
85
+ }
86
+
87
+ export function AuditTab({
88
+ refetchKey = 0,
89
+ initialIssueId,
90
+ onNavigate,
91
+ }: {
92
+ refetchKey?: number
93
+ initialIssueId?: string | null
94
+ onNavigate?: (path: string) => void
95
+ }) {
96
+ const [severity, setSeverity] = useState<SeoSeverity | 'all'>('all')
97
+ const [status, setStatus] = useState<SeoIssueStatus | 'all'>('open')
98
+ const [openIssue, setOpenIssue] = useState<string | null>(initialIssueId ?? null)
99
+
100
+ const query: SeoIssueQuery = useMemo(() => ({ severity, status }), [severity, status])
101
+ const { data, loading, error, refetch } = useSeoResource(
102
+ () => fetchSeoIssues(query),
103
+ [severity, status, refetchKey],
104
+ )
105
+
106
+ const issues = data ?? []
107
+
108
+ return (
109
+ <div className="space-y-4">
110
+ <AuditSummaryCards refetchKey={refetchKey} />
111
+
112
+ <SectionCard>
113
+ <div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
114
+ <div className="flex flex-wrap gap-1" role="group" aria-label="Filter by severity">
115
+ {SEVERITY_FILTERS.map((f) => (
116
+ <button
117
+ key={f.id}
118
+ type="button"
119
+ aria-pressed={severity === f.id}
120
+ onClick={() => setSeverity(f.id)}
121
+ className={`focus-visible:ring-ring rounded-md px-3 py-1.5 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none ${
122
+ severity === f.id
123
+ ? 'bg-primary text-primary-foreground'
124
+ : 'text-muted-foreground hover:bg-accent hover:text-foreground'
125
+ }`}
126
+ >
127
+ {f.label}
128
+ </button>
129
+ ))}
130
+ </div>
131
+ <div className="flex flex-wrap gap-1" role="group" aria-label="Filter by status">
132
+ {STATUS_FILTERS.map((f) => (
133
+ <button
134
+ key={f.id}
135
+ type="button"
136
+ aria-pressed={status === f.id}
137
+ onClick={() => setStatus(f.id)}
138
+ className={`focus-visible:ring-ring rounded-md px-3 py-1.5 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none ${
139
+ status === f.id
140
+ ? 'bg-accent text-foreground'
141
+ : 'text-muted-foreground hover:bg-accent hover:text-foreground'
142
+ }`}
143
+ >
144
+ {f.label}
145
+ </button>
146
+ ))}
147
+ </div>
148
+ </div>
149
+
150
+ {loading ? (
151
+ <SeoLoading label="Loading issues…" />
152
+ ) : error ? (
153
+ <SeoErrorState message={error} onRetry={refetch} />
154
+ ) : issues.length === 0 ? (
155
+ <SeoEmptyState
156
+ icon={status === 'open' ? <CheckCircle2 size={24} /> : <ClipboardCheck size={24} />}
157
+ title={status === 'open' ? 'No open issues' : 'No issues match'}
158
+ description={
159
+ status === 'open'
160
+ ? 'Run an audit to scan for new issues, or adjust the filters.'
161
+ : 'Try a different severity or status filter.'
162
+ }
163
+ />
164
+ ) : (
165
+ <div className="overflow-x-auto">
166
+ <table className="w-full text-sm" aria-label="SEO issues">
167
+ <thead>
168
+ <tr className="text-muted-foreground border-border border-b text-left">
169
+ <th className="py-2 pr-4 font-medium">Severity</th>
170
+ <th className="py-2 pr-4 font-medium">Issue</th>
171
+ <th className="py-2 pr-4 font-medium">Page</th>
172
+ <th className="py-2 pr-4 font-medium">Status</th>
173
+ <th className="py-2 text-right font-medium">
174
+ <span className="sr-only">Actions</span>
175
+ </th>
176
+ </tr>
177
+ </thead>
178
+ <tbody className="divide-border divide-y">
179
+ {issues.map((issue: SeoIssue) => {
180
+ const meta = severityMeta(issue.severity)
181
+ return (
182
+ <tr key={issue.id} className="hover:bg-accent/40">
183
+ <td className="py-2.5 pr-4">
184
+ <span className={`inline-flex items-center gap-1.5 text-sm ${meta.text}`}>
185
+ <span className={`h-1.5 w-1.5 rounded-full ${meta.dot}`} aria-hidden />
186
+ {meta.label}
187
+ </span>
188
+ </td>
189
+ <td className="py-2.5 pr-4">
190
+ <span className="text-foreground block font-medium">{issue.title}</span>
191
+ <span className="text-muted-foreground block capitalize">
192
+ {issue.category}
193
+ </span>
194
+ </td>
195
+ <td className="text-muted-foreground py-2.5 pr-4">
196
+ <span className="block truncate">
197
+ {issue.entityTitle || issue.url || '—'}
198
+ </span>
199
+ </td>
200
+ <td className="py-2.5 pr-4">
201
+ <SeoStatusBadge label={issue.status} tone={issueStatusTone(issue.status)} />
202
+ </td>
203
+ <td className="py-2.5 text-right">
204
+ <button
205
+ onClick={() => setOpenIssue(issue.id)}
206
+ className="text-primary hover:bg-accent focus-visible:ring-ring rounded-md px-2 py-1 text-sm focus-visible:ring-2 focus-visible:outline-none"
207
+ >
208
+ {issue.fixable ? 'Fix' : 'View'}
209
+ </button>
210
+ </td>
211
+ </tr>
212
+ )
213
+ })}
214
+ </tbody>
215
+ </table>
216
+ </div>
217
+ )}
218
+ </SectionCard>
219
+
220
+ <SeoIssueFixDrawer
221
+ open={openIssue !== null}
222
+ onOpenChange={(o) => !o && setOpenIssue(null)}
223
+ issueId={openIssue}
224
+ onChanged={refetch}
225
+ onNavigate={onNavigate}
226
+ />
227
+ </div>
228
+ )
229
+ }
@@ -0,0 +1,164 @@
1
+ 'use client'
2
+
3
+ import { useMemo, useState } from 'react'
4
+ import { Search, FileText, Pencil } from 'lucide-react'
5
+ import {
6
+ fetchSeoContentRecords,
7
+ applyContentSeoQuery,
8
+ type ContentSeoFilter,
9
+ } from '../../lib/seo-service.js'
10
+ import {
11
+ SectionCard,
12
+ SeoLoading,
13
+ SeoEmptyState,
14
+ SeoErrorState,
15
+ ScorePill,
16
+ SeoStatusBadge,
17
+ } from '../../components/seo/primitives.js'
18
+ import { SeoEditorDrawer } from '../../components/seo/SeoEditorDrawer.js'
19
+ import { useSeoResource } from './useSeoResource.js'
20
+
21
+ const FILTERS: { id: ContentSeoFilter; label: string }[] = [
22
+ { id: 'all', label: 'All' },
23
+ { id: 'pages', label: 'Pages' },
24
+ { id: 'posts', label: 'Posts' },
25
+ ]
26
+
27
+ const inputCls =
28
+ 'w-full rounded-md border border-border bg-input-background py-2 pl-9 pr-3 text-base text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
29
+
30
+ export function ContentTab({
31
+ initialEntityType,
32
+ initialEntityId,
33
+ }: {
34
+ initialEntityType?: string | null
35
+ initialEntityId?: string | null
36
+ }) {
37
+ const { data, loading, error, refetch } = useSeoResource(fetchSeoContentRecords, [])
38
+ const [search, setSearch] = useState('')
39
+ const [filter, setFilter] = useState<ContentSeoFilter>('all')
40
+ const [editor, setEditor] = useState<{ type: string; id: string } | null>(
41
+ initialEntityType && initialEntityId ? { type: initialEntityType, id: initialEntityId } : null,
42
+ )
43
+
44
+ const rows = useMemo(
45
+ () => applyContentSeoQuery(data ?? [], { search, filter }),
46
+ [data, search, filter],
47
+ )
48
+
49
+ return (
50
+ <div className="space-y-4">
51
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
52
+ <div className="relative max-w-sm flex-1">
53
+ <Search
54
+ className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2"
55
+ aria-hidden
56
+ />
57
+ <input
58
+ className={inputCls}
59
+ placeholder="Search content…"
60
+ value={search}
61
+ onChange={(e) => setSearch(e.target.value)}
62
+ aria-label="Search content"
63
+ />
64
+ </div>
65
+ <div className="flex gap-1" role="group" aria-label="Content type filter">
66
+ {FILTERS.map((f) => (
67
+ <button
68
+ key={f.id}
69
+ type="button"
70
+ aria-pressed={filter === f.id}
71
+ onClick={() => setFilter(f.id)}
72
+ className={`focus-visible:ring-ring rounded-md px-3 py-1.5 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none ${
73
+ filter === f.id
74
+ ? 'bg-primary text-primary-foreground'
75
+ : 'text-muted-foreground hover:bg-accent hover:text-foreground'
76
+ }`}
77
+ >
78
+ {f.label}
79
+ </button>
80
+ ))}
81
+ </div>
82
+ </div>
83
+
84
+ <SectionCard>
85
+ {loading ? (
86
+ <SeoLoading label="Loading content…" />
87
+ ) : error ? (
88
+ <SeoErrorState message={error} onRetry={refetch} />
89
+ ) : rows.length === 0 ? (
90
+ <SeoEmptyState
91
+ icon={<FileText size={24} />}
92
+ title={search || filter !== 'all' ? 'No matching content' : 'No content yet'}
93
+ description={
94
+ search || filter !== 'all'
95
+ ? 'Try adjusting your search or filter.'
96
+ : 'Publish pages or posts to manage their SEO here.'
97
+ }
98
+ />
99
+ ) : (
100
+ <div className="overflow-x-auto">
101
+ <table className="w-full text-sm" aria-label="Content SEO">
102
+ <thead>
103
+ <tr className="text-muted-foreground border-border border-b text-left">
104
+ <th className="py-2 pr-4 font-medium">Title</th>
105
+ <th className="py-2 pr-4 font-medium">Type</th>
106
+ <th className="py-2 pr-4 font-medium">Meta</th>
107
+ <th className="py-2 pr-4 text-right font-medium">Score</th>
108
+ <th className="py-2 text-right font-medium">
109
+ <span className="sr-only">Actions</span>
110
+ </th>
111
+ </tr>
112
+ </thead>
113
+ <tbody className="divide-border divide-y">
114
+ {rows.map((r) => (
115
+ <tr key={`${r.entityType}-${r.entityId}`} className="hover:bg-accent/40">
116
+ <td className="py-2.5 pr-4">
117
+ <span className="text-foreground block font-medium">{r.title}</span>
118
+ <span className="text-muted-foreground block truncate">{r.url}</span>
119
+ </td>
120
+ <td className="py-2.5 pr-4 capitalize">
121
+ <span className="text-muted-foreground">{r.entityType}</span>
122
+ </td>
123
+ <td className="py-2.5 pr-4">
124
+ <span className="flex flex-wrap gap-1.5">
125
+ <SeoStatusBadge
126
+ label={r.metaTitle ? 'Title' : 'No title'}
127
+ tone={r.metaTitle ? 'good' : 'critical'}
128
+ />
129
+ <SeoStatusBadge
130
+ label={r.metaDescription ? 'Desc' : 'No desc'}
131
+ tone={r.metaDescription ? 'good' : 'critical'}
132
+ />
133
+ {r.noindex && <SeoStatusBadge label="No-index" tone="fair" />}
134
+ </span>
135
+ </td>
136
+ <td className="py-2.5 pr-4 text-right">
137
+ <ScorePill score={r.seoScore} />
138
+ </td>
139
+ <td className="py-2.5 text-right">
140
+ <button
141
+ onClick={() => setEditor({ type: r.entityType, id: r.entityId })}
142
+ className="text-primary hover:bg-accent focus-visible:ring-ring inline-flex items-center gap-1 rounded-md px-2 py-1 text-sm focus-visible:ring-2 focus-visible:outline-none"
143
+ >
144
+ <Pencil className="h-3.5 w-3.5" aria-hidden /> Edit
145
+ </button>
146
+ </td>
147
+ </tr>
148
+ ))}
149
+ </tbody>
150
+ </table>
151
+ </div>
152
+ )}
153
+ </SectionCard>
154
+
155
+ <SeoEditorDrawer
156
+ open={editor !== null}
157
+ onOpenChange={(o) => !o && setEditor(null)}
158
+ entityType={editor?.type ?? null}
159
+ entityId={editor?.id ?? null}
160
+ onSaved={refetch}
161
+ />
162
+ </div>
163
+ )
164
+ }