@actuate-media/cms-admin 0.1.4 → 0.2.1
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/LICENSE +21 -21
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +17 -11
- package/dist/AdminRoot.js.map +1 -1
- package/dist/actuate-admin.css +2 -0
- package/dist/components/TipTapEditor.js +78 -78
- package/dist/lib/useApiData.d.ts +8 -1
- package/dist/lib/useApiData.d.ts.map +1 -1
- package/dist/lib/useApiData.js +39 -7
- package/dist/lib/useApiData.js.map +1 -1
- package/dist/views/Dashboard.d.ts +2 -1
- package/dist/views/Dashboard.d.ts.map +1 -1
- package/dist/views/Dashboard.js +52 -7
- package/dist/views/Dashboard.js.map +1 -1
- package/package.json +10 -5
- package/src/AdminRoot.tsx +312 -0
- package/src/__tests__/lib/search.test.ts +138 -0
- package/src/__tests__/lib/utils.test.ts +19 -0
- package/src/__tests__/router/match-route.test.ts +47 -0
- package/src/__tests__/router/strip-base.test.ts +30 -0
- package/src/components/Breadcrumbs.tsx +92 -0
- package/src/components/CommandPalette.tsx +384 -0
- package/src/components/ErrorBoundary.tsx +52 -0
- package/src/components/FocalPointPicker.tsx +54 -0
- package/src/components/FolderTree.tsx +427 -0
- package/src/components/LivePreview.tsx +136 -0
- package/src/components/LocaleProvider.tsx +51 -0
- package/src/components/LocaleSwitcher.tsx +51 -0
- package/src/components/MediaPickerModal.tsx +183 -0
- package/src/components/PresenceIndicator.tsx +71 -0
- package/src/components/SEOPanel.tsx +767 -0
- package/src/components/ThemeProvider.tsx +98 -0
- package/src/components/TipTapEditor.tsx +469 -0
- package/src/components/VersionHistory.tsx +167 -0
- package/src/components/ui/Avatar.tsx +42 -0
- package/src/components/ui/Badge.tsx +25 -0
- package/src/components/ui/Button.tsx +52 -0
- package/src/components/ui/CommandPalette.tsx +119 -0
- package/src/components/ui/ConfirmDialog.tsx +52 -0
- package/src/components/ui/DataTable.tsx +194 -0
- package/src/components/ui/EmptyState.tsx +29 -0
- package/src/components/ui/Modal.tsx +48 -0
- package/src/components/ui/Pagination.tsx +79 -0
- package/src/components/ui/SearchInput.tsx +44 -0
- package/src/components/ui/Skeleton.tsx +48 -0
- package/src/components/ui/Toast.tsx +66 -0
- package/src/components/ui/index.ts +24 -0
- package/src/fields/ArrayField.tsx +92 -0
- package/src/fields/BlockBuilderField.tsx +421 -0
- package/src/fields/DateField.tsx +41 -0
- package/src/fields/FieldRenderer.tsx +84 -0
- package/src/fields/GroupField.tsx +41 -0
- package/src/fields/MediaField.tsx +48 -0
- package/src/fields/NavBuilderField.tsx +78 -0
- package/src/fields/NumberField.tsx +45 -0
- package/src/fields/RelationshipField.tsx +245 -0
- package/src/fields/RichTextField.tsx +26 -0
- package/src/fields/SelectField.tsx +117 -0
- package/src/fields/SlugField.tsx +65 -0
- package/src/fields/TextField.tsx +48 -0
- package/src/fields/ToggleField.tsx +36 -0
- package/src/fields/block-types.ts +95 -0
- package/src/fields/index.ts +17 -0
- package/src/hooks/useContentLock.ts +52 -0
- package/src/hooks/useDebounce.ts +14 -0
- package/src/hooks/useKeyboardShortcuts.ts +32 -0
- package/src/index.ts +55 -0
- package/src/layout/Header.tsx +135 -0
- package/src/layout/Layout.tsx +77 -0
- package/src/layout/Sidebar.tsx +216 -0
- package/src/lib/api.ts +67 -0
- package/src/lib/search.ts +59 -0
- package/src/lib/useApiData.ts +95 -0
- package/src/lib/utils.ts +6 -0
- package/src/router/index.ts +81 -0
- package/src/styles/build-input.css +11 -0
- package/src/styles/tailwind.css +11 -6
- package/src/styles/theme.css +182 -181
- package/src/views/CollectionList.tsx +270 -0
- package/src/views/Dashboard.tsx +300 -0
- package/src/views/DocumentEdit.tsx +377 -0
- package/src/views/FormEditor.tsx +533 -0
- package/src/views/FormSubmissions.tsx +316 -0
- package/src/views/Forms.tsx +106 -0
- package/src/views/Login.tsx +322 -0
- package/src/views/MediaBrowser.tsx +774 -0
- package/src/views/PageEditor.tsx +192 -0
- package/src/views/Pages.tsx +354 -0
- package/src/views/PostEditor.tsx +251 -0
- package/src/views/Posts.tsx +243 -0
- package/src/views/Redirects.tsx +293 -0
- package/src/views/SEO.tsx +458 -0
- package/src/views/Settings.tsx +811 -0
- package/src/views/SetupWizard.tsx +207 -0
- package/src/views/Users.tsx +282 -0
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as Tabs from '@radix-ui/react-tabs';
|
|
4
|
+
import {
|
|
5
|
+
Search, Globe, FileCode2, BarChart3, AlertTriangle, CheckCircle2, XCircle,
|
|
6
|
+
ArrowUpRight, RefreshCw, BookOpen, Link2, Bot, Plus, Pencil, Trash2,
|
|
7
|
+
ExternalLink, ArrowRightLeft, Copy, ShieldCheck, Loader2,
|
|
8
|
+
} from 'lucide-react';
|
|
9
|
+
import { useState, useMemo, type FormEvent } from 'react';
|
|
10
|
+
import { toast } from 'sonner';
|
|
11
|
+
import * as Dialog from '@radix-ui/react-dialog';
|
|
12
|
+
import { useApiData } from '../lib/useApiData.js';
|
|
13
|
+
import { cmsApi } from '../lib/api.js';
|
|
14
|
+
|
|
15
|
+
export interface SEOProps {
|
|
16
|
+
onNavigate?: (path: string) => void;
|
|
17
|
+
initialTab?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function SEO({ onNavigate, initialTab = 'pages' }: SEOProps) {
|
|
21
|
+
const { data: seoData, loading: seoLoading, error: seoError, refetch: seoRefetch } = useApiData<any[]>('/seo/pages');
|
|
22
|
+
const { data: redirectsData, loading: redirectsLoading, error: redirectsError, refetch: redirectsRefetch } = useApiData<any[]>('/redirects');
|
|
23
|
+
const { data: linkHealthData, loading: linkHealthLoading } = useApiData<any[]>('/seo/link-health');
|
|
24
|
+
|
|
25
|
+
const [activeTab, setActiveTab] = useState(initialTab);
|
|
26
|
+
const [searchQuery, setSearchQuery] = useState('');
|
|
27
|
+
const [filterScore, setFilterScore] = useState<string>('all');
|
|
28
|
+
const [scanning, setScanning] = useState(false);
|
|
29
|
+
|
|
30
|
+
const [showAddRedirect, setShowAddRedirect] = useState(false);
|
|
31
|
+
const [newRedirect, setNewRedirect] = useState({ source: '', destination: '', type: '301' as '301' | '302' });
|
|
32
|
+
const [redirectSearch, setRedirectSearch] = useState('');
|
|
33
|
+
|
|
34
|
+
const seoPages = seoData ?? [];
|
|
35
|
+
const redirects = redirectsData ?? [];
|
|
36
|
+
const linkHealth = linkHealthData ?? [];
|
|
37
|
+
|
|
38
|
+
const handleTabChange = (tab: string) => {
|
|
39
|
+
setActiveTab(tab);
|
|
40
|
+
const path = tab === 'pages' ? '/seo' : `/seo/${tab}`;
|
|
41
|
+
onNavigate?.(path);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// --- Pages tab data ---
|
|
45
|
+
const filtered = seoPages
|
|
46
|
+
.filter((page: any) => {
|
|
47
|
+
const matchesSearch = page.url.toLowerCase().includes(searchQuery.toLowerCase()) || page.title.toLowerCase().includes(searchQuery.toLowerCase());
|
|
48
|
+
const matchesScore = filterScore === 'all' || (filterScore === 'good' && page.score >= 80) || (filterScore === 'warning' && page.score >= 50 && page.score < 80) || (filterScore === 'critical' && page.score < 50);
|
|
49
|
+
return matchesSearch && matchesScore;
|
|
50
|
+
})
|
|
51
|
+
.sort((a: any, b: any) => !searchQuery ? b.issues - a.issues : 0);
|
|
52
|
+
|
|
53
|
+
const totalIssues = seoPages.reduce((sum: number, p: any) => sum + p.issues, 0);
|
|
54
|
+
const avgScore = seoPages.length > 0 ? Math.round(seoPages.reduce((sum: number, p: any) => sum + p.score, 0) / seoPages.length) : 0;
|
|
55
|
+
const missingMeta = seoPages.filter((p: any) => !p.metaTitle || !p.metaDescription).length;
|
|
56
|
+
const missingSchema = seoPages.filter((p: any) => !p.schemaType).length;
|
|
57
|
+
|
|
58
|
+
// --- Canonical data ---
|
|
59
|
+
const missingCanonical = seoPages.filter((p: any) => !p.canonical);
|
|
60
|
+
const allCanonicals = seoPages.filter((p: any) => p.canonical);
|
|
61
|
+
const canonicalDomains = [...new Set(allCanonicals.map((p: any) => { try { return new URL(p.canonical).hostname; } catch { return ''; } }).filter(Boolean))];
|
|
62
|
+
|
|
63
|
+
// --- Redirects data ---
|
|
64
|
+
const filteredRedirects = redirects.filter((r: any) =>
|
|
65
|
+
r.from.toLowerCase().includes(redirectSearch.toLowerCase()) ||
|
|
66
|
+
r.to.toLowerCase().includes(redirectSearch.toLowerCase())
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const handleScan = async () => {
|
|
70
|
+
setScanning(true);
|
|
71
|
+
try {
|
|
72
|
+
const res = await cmsApi('/seo/scan', { method: 'POST' });
|
|
73
|
+
if (res.error) {
|
|
74
|
+
toast.error(res.error);
|
|
75
|
+
} else {
|
|
76
|
+
const d = res.data as { total: number; pagesWithIssues: number; totalProblems: number };
|
|
77
|
+
toast.success(`SEO scan complete — ${d.totalProblems} issues found across ${d.pagesWithIssues} of ${d.total} pages`);
|
|
78
|
+
seoRefetch();
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
toast.error('SEO scan failed');
|
|
82
|
+
} finally {
|
|
83
|
+
setScanning(false);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const handleAddRedirect = async (e: FormEvent) => {
|
|
88
|
+
e.preventDefault();
|
|
89
|
+
const res = await cmsApi('/redirects', {
|
|
90
|
+
method: 'POST',
|
|
91
|
+
body: JSON.stringify({ from: newRedirect.source, to: newRedirect.destination, type: newRedirect.type }),
|
|
92
|
+
});
|
|
93
|
+
if (res.error) {
|
|
94
|
+
toast.error(res.error);
|
|
95
|
+
} else {
|
|
96
|
+
toast.success('Redirect added');
|
|
97
|
+
redirectsRefetch();
|
|
98
|
+
}
|
|
99
|
+
setShowAddRedirect(false);
|
|
100
|
+
setNewRedirect({ source: '', destination: '', type: '301' });
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const handleDeleteRedirect = async (id: number) => {
|
|
104
|
+
const res = await cmsApi(`/redirects/${id}`, { method: 'DELETE' });
|
|
105
|
+
if (res.error) {
|
|
106
|
+
toast.error(res.error);
|
|
107
|
+
} else {
|
|
108
|
+
toast.success('Redirect deleted');
|
|
109
|
+
redirectsRefetch();
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
function scoreBadge(score: number) {
|
|
114
|
+
if (score >= 80) return 'bg-green-100 text-green-800';
|
|
115
|
+
if (score >= 50) return 'bg-yellow-100 text-yellow-800';
|
|
116
|
+
return 'bg-red-100 text-red-800';
|
|
117
|
+
}
|
|
118
|
+
function scoreIcon(score: number) {
|
|
119
|
+
if (score >= 80) return <CheckCircle2 className="w-4 h-4 text-green-600" />;
|
|
120
|
+
if (score >= 50) return <AlertTriangle className="w-4 h-4 text-yellow-600" />;
|
|
121
|
+
return <XCircle className="w-4 h-4 text-red-600" />;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const tabClass = "px-4 py-2 text-sm font-medium text-gray-600 transition-colors hover:text-gray-900 data-[state=active]:border-b-2 data-[state=active]:border-blue-600 data-[state=active]:text-blue-600 shrink-0";
|
|
125
|
+
|
|
126
|
+
const isLoading = seoLoading || redirectsLoading || linkHealthLoading;
|
|
127
|
+
|
|
128
|
+
if (isLoading) {
|
|
129
|
+
return (
|
|
130
|
+
<div className="p-3 pr-6 sm:p-4 sm:pr-8 flex items-center justify-center h-64">
|
|
131
|
+
<Loader2 className="w-6 h-6 animate-spin text-blue-600" />
|
|
132
|
+
</div>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<div className="p-3 pr-6 sm:p-4 sm:pr-8 h-full flex flex-col">
|
|
138
|
+
{(seoError || redirectsError) && (
|
|
139
|
+
<div className="mb-4 flex items-center gap-3 rounded-lg border border-red-200 bg-red-50 p-3">
|
|
140
|
+
<AlertTriangle className="w-5 h-5 text-red-600 shrink-0" />
|
|
141
|
+
<span className="text-sm text-red-800 flex-1">{seoError || redirectsError}</span>
|
|
142
|
+
<button onClick={() => { seoRefetch(); redirectsRefetch(); }} className="px-3 py-1 text-sm text-red-700 border border-red-300 rounded-lg hover:bg-red-100 transition-colors">Retry</button>
|
|
143
|
+
</div>
|
|
144
|
+
)}
|
|
145
|
+
|
|
146
|
+
<div className="flex flex-col sm:flex-row sm:items-center justify-between mb-4 gap-3">
|
|
147
|
+
<div>
|
|
148
|
+
<h1 className="text-xl sm:text-2xl font-semibold text-gray-900 mb-1">SEO & Redirects</h1>
|
|
149
|
+
<p className="text-sm text-gray-600">Search optimization, redirects, canonicalization, and link health</p>
|
|
150
|
+
</div>
|
|
151
|
+
<button onClick={handleScan} disabled={scanning} className="flex items-center justify-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm disabled:opacity-60">
|
|
152
|
+
<RefreshCw className={`w-4 h-4 ${scanning ? 'animate-spin' : ''}`} />
|
|
153
|
+
{scanning ? 'Scanning...' : 'Run SEO Scan'}
|
|
154
|
+
</button>
|
|
155
|
+
</div>
|
|
156
|
+
|
|
157
|
+
<Tabs.Root value={activeTab} onValueChange={handleTabChange}>
|
|
158
|
+
<Tabs.List className="mb-4 flex gap-1 border-b border-gray-200 overflow-x-auto">
|
|
159
|
+
<Tabs.Trigger value="pages" className={tabClass}>
|
|
160
|
+
<span className="flex items-center gap-1.5"><BarChart3 className="w-4 h-4" />Pages</span>
|
|
161
|
+
</Tabs.Trigger>
|
|
162
|
+
<Tabs.Trigger value="redirects" className={tabClass}>
|
|
163
|
+
<span className="flex items-center gap-1.5"><ArrowRightLeft className="w-4 h-4" />Redirects</span>
|
|
164
|
+
</Tabs.Trigger>
|
|
165
|
+
<Tabs.Trigger value="canonicals" className={tabClass}>
|
|
166
|
+
<span className="flex items-center gap-1.5"><Copy className="w-4 h-4" />Canonicalization</span>
|
|
167
|
+
</Tabs.Trigger>
|
|
168
|
+
<Tabs.Trigger value="links" className={tabClass}>
|
|
169
|
+
<span className="flex items-center gap-1.5"><Link2 className="w-4 h-4" />Link Health</span>
|
|
170
|
+
</Tabs.Trigger>
|
|
171
|
+
</Tabs.List>
|
|
172
|
+
|
|
173
|
+
{/* PAGES TAB */}
|
|
174
|
+
<Tabs.Content value="pages" className="flex flex-col flex-1 min-h-0">
|
|
175
|
+
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
|
|
176
|
+
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
|
177
|
+
<div className="flex items-center gap-2 mb-2"><BarChart3 className="w-4 h-4 text-blue-600" /><span className="text-xs font-medium text-gray-600">Avg SEO Score</span></div>
|
|
178
|
+
<div className="text-2xl font-semibold text-gray-900">{avgScore}</div>
|
|
179
|
+
<div className={`text-xs mt-1 ${avgScore >= 80 ? 'text-green-600' : avgScore >= 50 ? 'text-yellow-600' : 'text-red-600'}`}>{avgScore >= 80 ? 'Good' : avgScore >= 50 ? 'Needs improvement' : 'Critical'}</div>
|
|
180
|
+
</div>
|
|
181
|
+
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
|
182
|
+
<div className="flex items-center gap-2 mb-2"><AlertTriangle className="w-4 h-4 text-yellow-600" /><span className="text-xs font-medium text-gray-600">Total Issues</span></div>
|
|
183
|
+
<div className="text-2xl font-semibold text-gray-900">{totalIssues}</div>
|
|
184
|
+
<div className="text-xs text-gray-500 mt-1">Across {seoPages.filter((p: any) => p.issues > 0).length} pages</div>
|
|
185
|
+
</div>
|
|
186
|
+
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
|
187
|
+
<div className="flex items-center gap-2 mb-2"><FileCode2 className="w-4 h-4 text-purple-600" /><span className="text-xs font-medium text-gray-600">Missing Meta</span></div>
|
|
188
|
+
<div className="text-2xl font-semibold text-gray-900">{missingMeta}</div>
|
|
189
|
+
<div className="text-xs text-gray-500 mt-1">Title or description</div>
|
|
190
|
+
</div>
|
|
191
|
+
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
|
192
|
+
<div className="flex items-center gap-2 mb-2"><Globe className="w-4 h-4 text-green-600" /><span className="text-xs font-medium text-gray-600">Missing Schema</span></div>
|
|
193
|
+
<div className="text-2xl font-semibold text-gray-900">{missingSchema}</div>
|
|
194
|
+
<div className="text-xs text-gray-500 mt-1">No Schema.org markup</div>
|
|
195
|
+
</div>
|
|
196
|
+
</div>
|
|
197
|
+
|
|
198
|
+
<div className="bg-linear-to-r from-indigo-50 to-purple-50 border border-indigo-200 rounded-lg p-4 mb-4">
|
|
199
|
+
<div className="flex items-start gap-3">
|
|
200
|
+
<Bot className="w-5 h-5 text-indigo-600 mt-0.5 shrink-0" />
|
|
201
|
+
<div className="flex-1">
|
|
202
|
+
<h3 className="text-sm font-semibold text-indigo-900 mb-1">AI SEO Recommendations</h3>
|
|
203
|
+
<p className="text-sm text-indigo-700 mb-2">{missingMeta} pages missing meta descriptions. {missingCanonical.length} pages without canonical URLs. AI can auto-generate these from page content.</p>
|
|
204
|
+
<div className="flex items-center gap-2">
|
|
205
|
+
<button onClick={() => onNavigate?.('/settings')} className="px-3 py-1.5 text-xs bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors">Configure AI</button>
|
|
206
|
+
<button className="px-3 py-1.5 text-xs border border-indigo-300 text-indigo-700 rounded-lg hover:bg-indigo-50 transition-colors">Auto-fix All</button>
|
|
207
|
+
</div>
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
210
|
+
</div>
|
|
211
|
+
|
|
212
|
+
<div className="bg-white rounded-lg border border-gray-200 mb-4">
|
|
213
|
+
<div className="p-3 flex flex-col sm:flex-row gap-3">
|
|
214
|
+
<div className="relative flex-1">
|
|
215
|
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
|
216
|
+
<input type="text" placeholder="Search pages by URL or title..." value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} className="w-full pl-9 pr-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
|
217
|
+
</div>
|
|
218
|
+
<select value={filterScore} onChange={(e) => setFilterScore(e.target.value)} className="px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
|
219
|
+
<option value="all">All Scores</option>
|
|
220
|
+
<option value="good">Good (80+)</option>
|
|
221
|
+
<option value="warning">Needs Work (50-79)</option>
|
|
222
|
+
<option value="critical">Critical (<50)</option>
|
|
223
|
+
</select>
|
|
224
|
+
</div>
|
|
225
|
+
</div>
|
|
226
|
+
|
|
227
|
+
<div className="bg-white rounded-lg border border-gray-200 flex-1 overflow-hidden">
|
|
228
|
+
<div className="overflow-x-auto h-full">
|
|
229
|
+
<table className="w-full">
|
|
230
|
+
<thead className="bg-gray-50 border-b border-gray-200 sticky top-0">
|
|
231
|
+
<tr>
|
|
232
|
+
<th className="px-4 py-2 text-left text-xs font-medium text-gray-700">Page</th>
|
|
233
|
+
<th className="px-4 py-2 text-left text-xs font-medium text-gray-700">Score</th>
|
|
234
|
+
<th className="px-4 py-2 text-left text-xs font-medium text-gray-700">Readability</th>
|
|
235
|
+
<th className="px-4 py-2 text-left text-xs font-medium text-gray-700">Schema</th>
|
|
236
|
+
<th className="px-4 py-2 text-left text-xs font-medium text-gray-700">Meta</th>
|
|
237
|
+
<th className="px-4 py-2 text-left text-xs font-medium text-gray-700">Issues</th>
|
|
238
|
+
<th className="px-4 py-2 text-left text-xs font-medium text-gray-700">Actions</th>
|
|
239
|
+
</tr>
|
|
240
|
+
</thead>
|
|
241
|
+
<tbody className="divide-y divide-gray-200">
|
|
242
|
+
{filtered.map((page: any) => (
|
|
243
|
+
<tr key={page.id} className="hover:bg-gray-50 transition-colors">
|
|
244
|
+
<td className="px-4 py-3"><div className="text-sm font-medium text-gray-900">{page.title}</div><div className="text-xs text-gray-500">{page.url}</div></td>
|
|
245
|
+
<td className="px-4 py-3"><div className="flex items-center gap-2">{scoreIcon(page.score)}<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${scoreBadge(page.score)}`}>{page.score}</span></div></td>
|
|
246
|
+
<td className="px-4 py-3">{page.readability > 0 ? <div className="flex items-center gap-2"><BookOpen className="w-3.5 h-3.5 text-gray-400" /><span className={`text-sm ${page.readability >= 80 ? 'text-green-700' : page.readability >= 60 ? 'text-yellow-700' : 'text-red-700'}`}>{page.readability}</span></div> : <span className="text-xs text-gray-400">—</span>}</td>
|
|
247
|
+
<td className="px-4 py-3">{page.schemaType ? <span className="px-2 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">{page.schemaType}</span> : <span className="px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">Missing</span>}</td>
|
|
248
|
+
<td className="px-4 py-3"><div className="flex flex-col gap-0.5"><span className={`text-xs ${page.metaTitle ? 'text-green-700' : 'text-red-600'}`}>{page.metaTitle ? '✓ Title' : '✗ Title'}</span><span className={`text-xs ${page.metaDescription ? 'text-green-700' : 'text-red-600'}`}>{page.metaDescription ? '✓ Desc' : '✗ Desc'}</span></div></td>
|
|
249
|
+
<td className="px-4 py-3">{page.issues > 0 ? <span className="px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">{page.issues}</span> : <span className="px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">✓</span>}</td>
|
|
250
|
+
<td className="px-4 py-3"><div className="flex items-center gap-1"><button onClick={() => onNavigate?.(`/pages/${page.id}`)} className="p-1.5 hover:bg-gray-100 rounded transition-colors" title="Edit"><ArrowUpRight className="w-4 h-4 text-gray-600" /></button><button className="p-1.5 hover:bg-gray-100 rounded transition-colors" title="AI analyze"><Bot className="w-4 h-4 text-indigo-600" /></button></div></td>
|
|
251
|
+
</tr>
|
|
252
|
+
))}
|
|
253
|
+
</tbody>
|
|
254
|
+
</table>
|
|
255
|
+
</div>
|
|
256
|
+
</div>
|
|
257
|
+
</Tabs.Content>
|
|
258
|
+
|
|
259
|
+
{/* REDIRECTS TAB */}
|
|
260
|
+
<Tabs.Content value="redirects" className="flex flex-col flex-1 min-h-0">
|
|
261
|
+
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
|
|
262
|
+
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
|
263
|
+
<div className="text-xs text-gray-600 mb-1">Total Redirects</div>
|
|
264
|
+
<div className="text-2xl font-semibold text-gray-900">{redirects.length}</div>
|
|
265
|
+
</div>
|
|
266
|
+
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
|
267
|
+
<div className="text-xs text-gray-600 mb-1">Active</div>
|
|
268
|
+
<div className="text-2xl font-semibold text-green-600">{redirects.filter((r: any) => r.active).length}</div>
|
|
269
|
+
</div>
|
|
270
|
+
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
|
271
|
+
<div className="text-xs text-gray-600 mb-1">Total Hits</div>
|
|
272
|
+
<div className="text-2xl font-semibold text-blue-600">{redirects.reduce((s: number, r: any) => s + r.hits, 0).toLocaleString()}</div>
|
|
273
|
+
</div>
|
|
274
|
+
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
|
275
|
+
<div className="text-xs text-gray-600 mb-1">301 Permanent</div>
|
|
276
|
+
<div className="text-2xl font-semibold text-gray-900">{redirects.filter((r: any) => r.type === '301').length}</div>
|
|
277
|
+
</div>
|
|
278
|
+
</div>
|
|
279
|
+
|
|
280
|
+
<div className="flex items-center gap-3 mb-4">
|
|
281
|
+
<div className="relative flex-1 max-w-md">
|
|
282
|
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
|
283
|
+
<input type="text" placeholder="Search redirects..." value={redirectSearch} onChange={(e) => setRedirectSearch(e.target.value)} className="w-full pl-9 pr-3 py-2 text-sm border border-gray-300 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
|
284
|
+
</div>
|
|
285
|
+
<button onClick={() => setShowAddRedirect(true)} className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm shrink-0">
|
|
286
|
+
<Plus className="w-4 h-4" />Add Redirect
|
|
287
|
+
</button>
|
|
288
|
+
</div>
|
|
289
|
+
|
|
290
|
+
<div className="bg-white rounded-lg border border-gray-200 flex-1 overflow-hidden">
|
|
291
|
+
<div className="overflow-x-auto h-full">
|
|
292
|
+
<table className="w-full">
|
|
293
|
+
<thead className="bg-gray-50 border-b border-gray-200 sticky top-0">
|
|
294
|
+
<tr>
|
|
295
|
+
<th className="px-4 py-2 text-left text-xs font-medium text-gray-700">Source</th>
|
|
296
|
+
<th className="px-4 py-2 text-left text-xs font-medium text-gray-700">Destination</th>
|
|
297
|
+
<th className="px-4 py-2 text-left text-xs font-medium text-gray-700">Type</th>
|
|
298
|
+
<th className="px-4 py-2 text-left text-xs font-medium text-gray-700">Hits</th>
|
|
299
|
+
<th className="px-4 py-2 text-left text-xs font-medium text-gray-700">Status</th>
|
|
300
|
+
<th className="px-4 py-2 text-left text-xs font-medium text-gray-700">Actions</th>
|
|
301
|
+
</tr>
|
|
302
|
+
</thead>
|
|
303
|
+
<tbody className="divide-y divide-gray-200">
|
|
304
|
+
{filteredRedirects.map((r: any) => (
|
|
305
|
+
<tr key={r.id} className="hover:bg-gray-50 transition-colors">
|
|
306
|
+
<td className="px-4 py-3"><code className="rounded bg-gray-100 px-2 py-1 text-xs text-gray-900">{r.from}</code></td>
|
|
307
|
+
<td className="px-4 py-3"><code className="rounded bg-gray-100 px-2 py-1 text-xs text-gray-900">{r.to}</code></td>
|
|
308
|
+
<td className="px-4 py-3"><span className={`px-2 py-0.5 rounded-full text-xs font-medium ${r.type === '301' ? 'bg-blue-100 text-blue-800' : 'bg-purple-100 text-purple-800'}`}>{r.type}</span></td>
|
|
309
|
+
<td className="px-4 py-3 text-sm text-gray-600">{r.hits.toLocaleString()}</td>
|
|
310
|
+
<td className="px-4 py-3"><span className={`px-2 py-0.5 rounded-full text-xs font-medium ${r.active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'}`}>{r.active ? 'Active' : 'Inactive'}</span></td>
|
|
311
|
+
<td className="px-4 py-3"><div className="flex items-center gap-2"><button className="p-1.5 hover:bg-gray-100 rounded transition-colors"><Pencil className="w-4 h-4 text-gray-600" /></button><button onClick={() => handleDeleteRedirect(r.id)} className="p-1.5 hover:bg-gray-100 rounded transition-colors"><Trash2 className="w-4 h-4 text-red-600" /></button></div></td>
|
|
312
|
+
</tr>
|
|
313
|
+
))}
|
|
314
|
+
</tbody>
|
|
315
|
+
</table>
|
|
316
|
+
</div>
|
|
317
|
+
</div>
|
|
318
|
+
|
|
319
|
+
<Dialog.Root open={showAddRedirect} onOpenChange={setShowAddRedirect}>
|
|
320
|
+
<Dialog.Portal>
|
|
321
|
+
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/50" />
|
|
322
|
+
<Dialog.Content className="fixed left-1/2 top-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg bg-white p-6 shadow-lg">
|
|
323
|
+
<Dialog.Title className="mb-4 text-lg font-semibold text-gray-900">Add Redirect</Dialog.Title>
|
|
324
|
+
<form onSubmit={handleAddRedirect} className="space-y-4">
|
|
325
|
+
<div><label className="mb-1 block text-sm font-medium text-gray-700">Source URL</label><input type="text" value={newRedirect.source} onChange={(e) => setNewRedirect({ ...newRedirect, source: e.target.value })} placeholder="/old-page" className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" required /></div>
|
|
326
|
+
<div><label className="mb-1 block text-sm font-medium text-gray-700">Destination URL</label><input type="text" value={newRedirect.destination} onChange={(e) => setNewRedirect({ ...newRedirect, destination: e.target.value })} placeholder="/new-page" className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" required /></div>
|
|
327
|
+
<div><label className="mb-1 block text-sm font-medium text-gray-700">Type</label><select value={newRedirect.type} onChange={(e) => setNewRedirect({ ...newRedirect, type: e.target.value as '301' | '302' })} className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"><option value="301">301 (Permanent)</option><option value="302">302 (Temporary)</option></select></div>
|
|
328
|
+
<div className="flex justify-end gap-3 pt-4">
|
|
329
|
+
<Dialog.Close asChild><button type="button" className="rounded-lg border border-gray-300 px-4 py-2 text-sm hover:bg-gray-50">Cancel</button></Dialog.Close>
|
|
330
|
+
<button type="submit" className="rounded-lg bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700">Add Redirect</button>
|
|
331
|
+
</div>
|
|
332
|
+
</form>
|
|
333
|
+
</Dialog.Content>
|
|
334
|
+
</Dialog.Portal>
|
|
335
|
+
</Dialog.Root>
|
|
336
|
+
</Tabs.Content>
|
|
337
|
+
|
|
338
|
+
{/* CANONICALIZATION TAB */}
|
|
339
|
+
<Tabs.Content value="canonicals" className="flex flex-col flex-1 min-h-0 space-y-4">
|
|
340
|
+
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
|
341
|
+
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
|
342
|
+
<div className="flex items-center gap-2 mb-2"><ShieldCheck className="w-4 h-4 text-green-600" /><span className="text-xs font-medium text-gray-600">With Canonical</span></div>
|
|
343
|
+
<div className="text-2xl font-semibold text-green-700">{allCanonicals.length}</div>
|
|
344
|
+
</div>
|
|
345
|
+
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
|
346
|
+
<div className="flex items-center gap-2 mb-2"><AlertTriangle className="w-4 h-4 text-red-600" /><span className="text-xs font-medium text-gray-600">Missing Canonical</span></div>
|
|
347
|
+
<div className="text-2xl font-semibold text-red-700">{missingCanonical.length}</div>
|
|
348
|
+
</div>
|
|
349
|
+
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
|
350
|
+
<div className="flex items-center gap-2 mb-2"><Globe className="w-4 h-4 text-blue-600" /><span className="text-xs font-medium text-gray-600">Canonical Domains</span></div>
|
|
351
|
+
<div className="text-2xl font-semibold text-gray-900">{canonicalDomains.length}</div>
|
|
352
|
+
</div>
|
|
353
|
+
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
|
354
|
+
<div className="flex items-center gap-2 mb-2"><CheckCircle2 className="w-4 h-4 text-green-600" /><span className="text-xs font-medium text-gray-600">Self-Referencing</span></div>
|
|
355
|
+
<div className="text-2xl font-semibold text-green-700">{allCanonicals.length}</div>
|
|
356
|
+
<div className="text-xs text-gray-500 mt-1">Correct pattern</div>
|
|
357
|
+
</div>
|
|
358
|
+
</div>
|
|
359
|
+
|
|
360
|
+
{missingCanonical.length > 0 && (
|
|
361
|
+
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
|
362
|
+
<div className="flex items-start gap-3">
|
|
363
|
+
<AlertTriangle className="w-5 h-5 text-yellow-600 mt-0.5 shrink-0" />
|
|
364
|
+
<div className="flex-1">
|
|
365
|
+
<h3 className="text-sm font-semibold text-yellow-900 mb-1">{missingCanonical.length} page{missingCanonical.length !== 1 ? 's' : ''} missing canonical URLs</h3>
|
|
366
|
+
<p className="text-sm text-yellow-700 mb-2">Without canonical tags, search engines may index duplicate versions of these pages, diluting your ranking signals.</p>
|
|
367
|
+
<div className="flex flex-wrap gap-2">
|
|
368
|
+
{missingCanonical.map((p: any) => (
|
|
369
|
+
<button key={p.id} onClick={() => onNavigate?.(`/pages/${p.id}`)} className="px-2.5 py-1 text-xs bg-yellow-100 text-yellow-900 rounded-lg hover:bg-yellow-200 transition-colors">{p.title} ({p.url})</button>
|
|
370
|
+
))}
|
|
371
|
+
</div>
|
|
372
|
+
</div>
|
|
373
|
+
</div>
|
|
374
|
+
</div>
|
|
375
|
+
)}
|
|
376
|
+
|
|
377
|
+
<div className="bg-white rounded-lg border border-gray-200 flex-1 overflow-hidden">
|
|
378
|
+
<div className="overflow-x-auto h-full">
|
|
379
|
+
<table className="w-full">
|
|
380
|
+
<thead className="bg-gray-50 border-b border-gray-200 sticky top-0">
|
|
381
|
+
<tr>
|
|
382
|
+
<th className="px-4 py-2 text-left text-xs font-medium text-gray-700">Page</th>
|
|
383
|
+
<th className="px-4 py-2 text-left text-xs font-medium text-gray-700">URL</th>
|
|
384
|
+
<th className="px-4 py-2 text-left text-xs font-medium text-gray-700">Canonical URL</th>
|
|
385
|
+
<th className="px-4 py-2 text-left text-xs font-medium text-gray-700">Status</th>
|
|
386
|
+
<th className="px-4 py-2 text-left text-xs font-medium text-gray-700">Actions</th>
|
|
387
|
+
</tr>
|
|
388
|
+
</thead>
|
|
389
|
+
<tbody className="divide-y divide-gray-200">
|
|
390
|
+
{seoPages.map((page: any) => (
|
|
391
|
+
<tr key={page.id} className="hover:bg-gray-50 transition-colors">
|
|
392
|
+
<td className="px-4 py-3 text-sm font-medium text-gray-900">{page.title}</td>
|
|
393
|
+
<td className="px-4 py-3"><code className="text-xs bg-gray-100 px-2 py-1 rounded text-gray-700">{page.url}</code></td>
|
|
394
|
+
<td className="px-4 py-3">{page.canonical ? <code className="text-xs bg-gray-100 px-2 py-1 rounded text-gray-700 truncate max-w-[200px] block">{page.canonical}</code> : <span className="text-xs text-red-600 font-medium">Not set</span>}</td>
|
|
395
|
+
<td className="px-4 py-3">{page.canonical ? <span className="px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">✓ Set</span> : <span className="px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">Missing</span>}</td>
|
|
396
|
+
<td className="px-4 py-3"><button onClick={() => onNavigate?.(`/pages/${page.id}`)} className="p-1.5 hover:bg-gray-100 rounded transition-colors" title="Edit"><Pencil className="w-4 h-4 text-gray-600" /></button></td>
|
|
397
|
+
</tr>
|
|
398
|
+
))}
|
|
399
|
+
</tbody>
|
|
400
|
+
</table>
|
|
401
|
+
</div>
|
|
402
|
+
</div>
|
|
403
|
+
</Tabs.Content>
|
|
404
|
+
|
|
405
|
+
{/* LINK HEALTH TAB */}
|
|
406
|
+
<Tabs.Content value="links" className="flex flex-col flex-1 min-h-0 space-y-4">
|
|
407
|
+
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
|
408
|
+
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
|
409
|
+
<div className="text-xs text-gray-600 mb-1">Broken Links</div>
|
|
410
|
+
<div className="text-2xl font-semibold text-red-600">{linkHealth.filter((l: any) => l.status === 404).length}</div>
|
|
411
|
+
</div>
|
|
412
|
+
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
|
413
|
+
<div className="text-xs text-gray-600 mb-1">Redirect Chains</div>
|
|
414
|
+
<div className="text-2xl font-semibold text-yellow-600">{linkHealth.filter((l: any) => l.status === 301).length}</div>
|
|
415
|
+
</div>
|
|
416
|
+
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
|
417
|
+
<div className="text-xs text-gray-600 mb-1">Internal Issues</div>
|
|
418
|
+
<div className="text-2xl font-semibold text-gray-900">{linkHealth.filter((l: any) => l.type === 'internal').length}</div>
|
|
419
|
+
</div>
|
|
420
|
+
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
|
421
|
+
<div className="text-xs text-gray-600 mb-1">External Issues</div>
|
|
422
|
+
<div className="text-2xl font-semibold text-gray-900">{linkHealth.filter((l: any) => l.type === 'external').length}</div>
|
|
423
|
+
</div>
|
|
424
|
+
</div>
|
|
425
|
+
|
|
426
|
+
<div className="bg-white rounded-lg border border-gray-200 flex-1 overflow-hidden">
|
|
427
|
+
<div className="overflow-x-auto h-full">
|
|
428
|
+
<table className="w-full">
|
|
429
|
+
<thead className="bg-gray-50 border-b border-gray-200 sticky top-0">
|
|
430
|
+
<tr>
|
|
431
|
+
<th className="px-4 py-2 text-left text-xs font-medium text-gray-700">Found On</th>
|
|
432
|
+
<th className="px-4 py-2 text-left text-xs font-medium text-gray-700">Broken URL</th>
|
|
433
|
+
<th className="px-4 py-2 text-left text-xs font-medium text-gray-700">Status</th>
|
|
434
|
+
<th className="px-4 py-2 text-left text-xs font-medium text-gray-700">Type</th>
|
|
435
|
+
<th className="px-4 py-2 text-left text-xs font-medium text-gray-700">Last Checked</th>
|
|
436
|
+
<th className="px-4 py-2 text-left text-xs font-medium text-gray-700">Actions</th>
|
|
437
|
+
</tr>
|
|
438
|
+
</thead>
|
|
439
|
+
<tbody className="divide-y divide-gray-200">
|
|
440
|
+
{linkHealth.map((link: any) => (
|
|
441
|
+
<tr key={link.id} className="hover:bg-gray-50 transition-colors">
|
|
442
|
+
<td className="px-4 py-3"><code className="text-xs bg-gray-100 px-2 py-1 rounded text-gray-700">{link.page}</code></td>
|
|
443
|
+
<td className="px-4 py-3"><code className="text-xs bg-red-50 px-2 py-1 rounded text-red-800 truncate max-w-[200px] block">{link.url}</code></td>
|
|
444
|
+
<td className="px-4 py-3"><span className={`px-2 py-0.5 rounded-full text-xs font-medium ${link.status === 404 ? 'bg-red-100 text-red-800' : 'bg-yellow-100 text-yellow-800'}`}>{link.status}</span></td>
|
|
445
|
+
<td className="px-4 py-3"><span className={`px-2 py-0.5 rounded-full text-xs font-medium ${link.type === 'internal' ? 'bg-blue-100 text-blue-800' : 'bg-gray-100 text-gray-800'}`}>{link.type}</span></td>
|
|
446
|
+
<td className="px-4 py-3 text-sm text-gray-600">{link.lastChecked}</td>
|
|
447
|
+
<td className="px-4 py-3"><div className="flex items-center gap-1"><button className="p-1.5 hover:bg-gray-100 rounded transition-colors" title="Create redirect"><ArrowRightLeft className="w-4 h-4 text-gray-600" /></button><button className="p-1.5 hover:bg-gray-100 rounded transition-colors" title="Open URL"><ExternalLink className="w-4 h-4 text-gray-600" /></button></div></td>
|
|
448
|
+
</tr>
|
|
449
|
+
))}
|
|
450
|
+
</tbody>
|
|
451
|
+
</table>
|
|
452
|
+
</div>
|
|
453
|
+
</div>
|
|
454
|
+
</Tabs.Content>
|
|
455
|
+
</Tabs.Root>
|
|
456
|
+
</div>
|
|
457
|
+
);
|
|
458
|
+
}
|