@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.
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +13 -9
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/layout/sidebar-posts.test.js +6 -4
- package/dist/__tests__/layout/sidebar-posts.test.js.map +1 -1
- package/dist/__tests__/lib/seo-service-errors.test.d.ts +2 -0
- package/dist/__tests__/lib/seo-service-errors.test.d.ts.map +1 -0
- package/dist/__tests__/lib/seo-service-errors.test.js +46 -0
- package/dist/__tests__/lib/seo-service-errors.test.js.map +1 -0
- package/dist/__tests__/views/use-seo-resource.test.d.ts +2 -0
- package/dist/__tests__/views/use-seo-resource.test.d.ts.map +1 -0
- package/dist/__tests__/views/use-seo-resource.test.js +56 -0
- package/dist/__tests__/views/use-seo-resource.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/components/Breadcrumbs.d.ts.map +1 -1
- package/dist/components/Breadcrumbs.js +4 -1
- package/dist/components/Breadcrumbs.js.map +1 -1
- package/dist/components/seo/Drawer.d.ts +11 -0
- package/dist/components/seo/Drawer.d.ts.map +1 -0
- package/dist/components/seo/Drawer.js +17 -0
- package/dist/components/seo/Drawer.js.map +1 -0
- package/dist/components/seo/RedirectEditorDrawer.d.ts +9 -0
- package/dist/components/seo/RedirectEditorDrawer.d.ts.map +1 -0
- package/dist/components/seo/RedirectEditorDrawer.js +94 -0
- package/dist/components/seo/RedirectEditorDrawer.js.map +1 -0
- package/dist/components/seo/SeoCopilotDrawer.d.ts +10 -0
- package/dist/components/seo/SeoCopilotDrawer.d.ts.map +1 -0
- package/dist/components/seo/SeoCopilotDrawer.js +34 -0
- package/dist/components/seo/SeoCopilotDrawer.js.map +1 -0
- package/dist/components/seo/SeoEditorDrawer.d.ts +8 -0
- package/dist/components/seo/SeoEditorDrawer.d.ts.map +1 -0
- package/dist/components/seo/SeoEditorDrawer.js +120 -0
- package/dist/components/seo/SeoEditorDrawer.js.map +1 -0
- package/dist/components/seo/SeoIssueFixDrawer.d.ts +8 -0
- package/dist/components/seo/SeoIssueFixDrawer.d.ts.map +1 -0
- package/dist/components/seo/SeoIssueFixDrawer.js +111 -0
- package/dist/components/seo/SeoIssueFixDrawer.js.map +1 -0
- package/dist/components/seo/primitives.d.ts +53 -0
- package/dist/components/seo/primitives.d.ts.map +1 -0
- package/dist/components/seo/primitives.js +101 -0
- package/dist/components/seo/primitives.js.map +1 -0
- package/dist/layout/Sidebar.d.ts.map +1 -1
- package/dist/layout/Sidebar.js +8 -5
- package/dist/layout/Sidebar.js.map +1 -1
- package/dist/lib/seo-service.d.ts +322 -0
- package/dist/lib/seo-service.d.ts.map +1 -0
- package/dist/lib/seo-service.js +331 -0
- package/dist/lib/seo-service.js.map +1 -0
- package/dist/router/index.d.ts.map +1 -1
- package/dist/router/index.js +4 -1
- package/dist/router/index.js.map +1 -1
- package/dist/views/ApiKeys.d.ts +4 -1
- package/dist/views/ApiKeys.d.ts.map +1 -1
- package/dist/views/ApiKeys.js +5 -3
- package/dist/views/ApiKeys.js.map +1 -1
- package/dist/views/Dashboard.js +2 -2
- package/dist/views/Dashboard.js.map +1 -1
- package/dist/views/MediaBrowser.d.ts.map +1 -1
- package/dist/views/MediaBrowser.js +36 -25
- package/dist/views/MediaBrowser.js.map +1 -1
- package/dist/views/Posts/NewPostTypeInstructions.js +2 -2
- package/dist/views/Posts/post-type-presets.d.ts +5 -0
- package/dist/views/Posts/post-type-presets.d.ts.map +1 -1
- package/dist/views/Posts/post-type-presets.js +10 -64
- package/dist/views/Posts/post-type-presets.js.map +1 -1
- package/dist/views/SEO.d.ts.map +1 -1
- package/dist/views/SEO.js +89 -132
- package/dist/views/SEO.js.map +1 -1
- package/dist/views/Settings.d.ts +4 -1
- package/dist/views/Settings.d.ts.map +1 -1
- package/dist/views/Settings.js +45 -6
- package/dist/views/Settings.js.map +1 -1
- package/dist/views/Users.d.ts +4 -1
- package/dist/views/Users.d.ts.map +1 -1
- package/dist/views/Users.js +5 -3
- package/dist/views/Users.js.map +1 -1
- package/dist/views/seo/AuditTab.d.ts +6 -0
- package/dist/views/seo/AuditTab.d.ts.map +1 -0
- package/dist/views/seo/AuditTab.js +57 -0
- package/dist/views/seo/AuditTab.js.map +1 -0
- package/dist/views/seo/ContentTab.d.ts +5 -0
- package/dist/views/seo/ContentTab.d.ts.map +1 -0
- package/dist/views/seo/ContentTab.js +27 -0
- package/dist/views/seo/ContentTab.js.map +1 -0
- package/dist/views/seo/OverviewTab.d.ts +8 -0
- package/dist/views/seo/OverviewTab.d.ts.map +1 -0
- package/dist/views/seo/OverviewTab.js +35 -0
- package/dist/views/seo/OverviewTab.js.map +1 -0
- package/dist/views/seo/RedirectsTab.d.ts +2 -0
- package/dist/views/seo/RedirectsTab.d.ts.map +1 -0
- package/dist/views/seo/RedirectsTab.js +130 -0
- package/dist/views/seo/RedirectsTab.js.map +1 -0
- package/dist/views/seo/TechnicalTab.d.ts +2 -0
- package/dist/views/seo/TechnicalTab.d.ts.map +1 -0
- package/dist/views/seo/TechnicalTab.js +105 -0
- package/dist/views/seo/TechnicalTab.js.map +1 -0
- package/dist/views/seo/useSeoResource.d.ts +8 -0
- package/dist/views/seo/useSeoResource.d.ts.map +1 -0
- package/dist/views/seo/useSeoResource.js +55 -0
- package/dist/views/seo/useSeoResource.js.map +1 -0
- package/package.json +2 -2
- package/src/AdminRoot.tsx +13 -9
- package/src/__tests__/layout/sidebar-posts.test.ts +6 -4
- package/src/__tests__/lib/seo-service-errors.test.ts +60 -0
- package/src/__tests__/views/use-seo-resource.test.tsx +68 -0
- package/src/components/Breadcrumbs.tsx +4 -1
- package/src/components/seo/Drawer.tsx +65 -0
- package/src/components/seo/RedirectEditorDrawer.tsx +235 -0
- package/src/components/seo/SeoCopilotDrawer.tsx +76 -0
- package/src/components/seo/SeoEditorDrawer.tsx +359 -0
- package/src/components/seo/SeoIssueFixDrawer.tsx +244 -0
- package/src/components/seo/primitives.tsx +240 -0
- package/src/layout/Sidebar.tsx +7 -10
- package/src/lib/seo-service.ts +660 -0
- package/src/router/index.ts +4 -1
- package/src/views/ApiKeys.tsx +10 -5
- package/src/views/Dashboard.tsx +2 -2
- package/src/views/MediaBrowser.tsx +89 -67
- package/src/views/Posts/NewPostTypeInstructions.tsx +2 -2
- package/src/views/Posts/post-type-presets.ts +15 -64
- package/src/views/SEO.tsx +186 -863
- package/src/views/Settings.tsx +93 -40
- package/src/views/Users.tsx +10 -4
- package/src/views/seo/AuditTab.tsx +229 -0
- package/src/views/seo/ContentTab.tsx +164 -0
- package/src/views/seo/OverviewTab.tsx +257 -0
- package/src/views/seo/RedirectsTab.tsx +398 -0
- package/src/views/seo/TechnicalTab.tsx +315 -0
- package/src/views/seo/useSeoResource.ts +66 -0
package/src/views/Settings.tsx
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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={
|
|
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="
|
|
632
|
-
<
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
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
|
-
|
|
662
|
-
<
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
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
|
}
|
package/src/views/Users.tsx
CHANGED
|
@@ -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=
|
|
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=
|
|
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
|
-
<
|
|
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
|
+
}
|