@actuate-media/cms-admin 0.2.0 → 0.3.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.js +1 -1
- package/dist/AdminRoot.js.map +1 -1
- package/dist/actuate-admin.css +1 -1
- package/dist/components/ContentOverviewChart.d.ts +8 -0
- package/dist/components/ContentOverviewChart.d.ts.map +1 -0
- package/dist/components/ContentOverviewChart.js +20 -0
- package/dist/components/ContentOverviewChart.js.map +1 -0
- package/dist/components/SEOPerformance.d.ts +5 -0
- package/dist/components/SEOPerformance.d.ts.map +1 -0
- package/dist/components/SEOPerformance.js +24 -0
- package/dist/components/SEOPerformance.js.map +1 -0
- package/dist/views/Dashboard.d.ts +3 -1
- package/dist/views/Dashboard.d.ts.map +1 -1
- package/dist/views/Dashboard.js +98 -9
- package/dist/views/Dashboard.js.map +1 -1
- package/dist/views/Pages.d.ts.map +1 -1
- package/dist/views/Pages.js +57 -2
- package/dist/views/Pages.js.map +1 -1
- package/package.json +3 -2
- package/src/AdminRoot.tsx +1 -1
- package/src/components/ContentOverviewChart.tsx +70 -0
- package/src/components/SEOPerformance.tsx +134 -0
- package/src/views/Dashboard.tsx +206 -130
- package/src/views/Pages.tsx +132 -58
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Search, AlertTriangle, FileWarning, ImageOff, LinkIcon, ChevronLeft, ChevronRight } from 'lucide-react';
|
|
4
|
+
import { useState } from 'react';
|
|
5
|
+
import { useApiData } from '../lib/useApiData.js';
|
|
6
|
+
|
|
7
|
+
interface SEOSummary {
|
|
8
|
+
totalPages: number;
|
|
9
|
+
issuesSummary: {
|
|
10
|
+
missingMetaDescriptions: number;
|
|
11
|
+
brokenInternalLinks: number;
|
|
12
|
+
missingAltText: number;
|
|
13
|
+
};
|
|
14
|
+
topContent: { id: string; title: string; collection: string; score: number }[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function ScoreBadge({ score }: { score: number }) {
|
|
18
|
+
const color = score >= 80 ? 'text-green-600' : score >= 60 ? 'text-amber-500' : 'text-red-500';
|
|
19
|
+
const bg = score >= 80 ? 'bg-green-50' : score >= 60 ? 'bg-amber-50' : 'bg-red-50';
|
|
20
|
+
return (
|
|
21
|
+
<span className={`inline-flex items-center justify-center w-9 h-9 rounded-full text-sm font-semibold ${color} ${bg}`}>
|
|
22
|
+
{score}
|
|
23
|
+
</span>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SEOPerformanceProps {
|
|
28
|
+
onNavigate?: (path: string) => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function SEOPerformance({ onNavigate }: SEOPerformanceProps) {
|
|
32
|
+
const { data, loading, error } = useApiData<SEOSummary>('/seo/summary');
|
|
33
|
+
const [page, setPage] = useState(0);
|
|
34
|
+
const perPage = 5;
|
|
35
|
+
|
|
36
|
+
if (loading || error || !data) return null;
|
|
37
|
+
|
|
38
|
+
const issues = data.issuesSummary;
|
|
39
|
+
const totalIssues = issues.missingMetaDescriptions + issues.brokenInternalLinks + issues.missingAltText;
|
|
40
|
+
const topContent = data.topContent ?? [];
|
|
41
|
+
const totalPages = Math.max(1, Math.ceil(topContent.length / perPage));
|
|
42
|
+
const visible = topContent.slice(page * perPage, (page + 1) * perPage);
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div className="bg-white rounded-lg border border-gray-200">
|
|
46
|
+
<div className="p-4 border-b border-gray-200 flex items-center justify-between">
|
|
47
|
+
<div className="flex items-center gap-2">
|
|
48
|
+
<Search className="w-4 h-4 text-gray-500" />
|
|
49
|
+
<h2 className="text-sm font-semibold text-gray-900">SEO Performance</h2>
|
|
50
|
+
</div>
|
|
51
|
+
{totalIssues > 0 && (
|
|
52
|
+
<span className="text-xs text-gray-500">{totalIssues} issue{totalIssues !== 1 ? 's' : ''} found</span>
|
|
53
|
+
)}
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
<div className="grid grid-cols-1 lg:grid-cols-12 divide-y lg:divide-y-0 lg:divide-x divide-gray-200">
|
|
57
|
+
<div className="lg:col-span-7 p-4">
|
|
58
|
+
<h3 className="text-sm font-medium text-gray-700 mb-3">Top Performing Content</h3>
|
|
59
|
+
<div className="space-y-2">
|
|
60
|
+
{visible.map((item) => (
|
|
61
|
+
<div key={item.id} className="flex items-center justify-between py-2">
|
|
62
|
+
<div className="flex-1 min-w-0 mr-3">
|
|
63
|
+
<p className="text-sm font-medium text-gray-900 truncate">{item.title}</p>
|
|
64
|
+
<p className="text-xs text-gray-500 capitalize">{item.collection}</p>
|
|
65
|
+
</div>
|
|
66
|
+
<ScoreBadge score={item.score} />
|
|
67
|
+
</div>
|
|
68
|
+
))}
|
|
69
|
+
{visible.length === 0 && (
|
|
70
|
+
<p className="text-sm text-gray-400 py-4 text-center">No published content yet</p>
|
|
71
|
+
)}
|
|
72
|
+
</div>
|
|
73
|
+
{topContent.length > perPage && (
|
|
74
|
+
<div className="flex items-center justify-between mt-3 pt-3 border-t border-gray-100 text-xs text-gray-500">
|
|
75
|
+
<span>Showing {page * perPage + 1}-{Math.min((page + 1) * perPage, topContent.length)} of {topContent.length}</span>
|
|
76
|
+
<div className="flex items-center gap-1">
|
|
77
|
+
<button
|
|
78
|
+
onClick={() => setPage(Math.max(0, page - 1))}
|
|
79
|
+
disabled={page === 0}
|
|
80
|
+
className="p-1 rounded hover:bg-gray-100 disabled:opacity-30"
|
|
81
|
+
>
|
|
82
|
+
<ChevronLeft className="w-3.5 h-3.5" />
|
|
83
|
+
</button>
|
|
84
|
+
<span>Page {page + 1} of {totalPages}</span>
|
|
85
|
+
<button
|
|
86
|
+
onClick={() => setPage(Math.min(totalPages - 1, page + 1))}
|
|
87
|
+
disabled={page >= totalPages - 1}
|
|
88
|
+
className="p-1 rounded hover:bg-gray-100 disabled:opacity-30"
|
|
89
|
+
>
|
|
90
|
+
<ChevronRight className="w-3.5 h-3.5" />
|
|
91
|
+
</button>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
)}
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
<div className="lg:col-span-5 p-4">
|
|
98
|
+
<h3 className="text-sm font-medium text-gray-700 mb-3">SEO Issues</h3>
|
|
99
|
+
<div className="space-y-3">
|
|
100
|
+
<div className="flex items-center justify-between">
|
|
101
|
+
<div className="flex items-center gap-2">
|
|
102
|
+
<FileWarning className="w-4 h-4 text-red-400" />
|
|
103
|
+
<span className="text-sm text-gray-700">Missing meta descriptions</span>
|
|
104
|
+
</div>
|
|
105
|
+
<span className="text-sm font-semibold text-gray-900">{issues.missingMetaDescriptions}</span>
|
|
106
|
+
</div>
|
|
107
|
+
<div className="flex items-center justify-between">
|
|
108
|
+
<div className="flex items-center gap-2">
|
|
109
|
+
<LinkIcon className="w-4 h-4 text-red-400" />
|
|
110
|
+
<span className="text-sm text-gray-700">Broken internal links</span>
|
|
111
|
+
</div>
|
|
112
|
+
<span className="text-sm font-semibold text-gray-900">{issues.brokenInternalLinks}</span>
|
|
113
|
+
</div>
|
|
114
|
+
<div className="flex items-center justify-between">
|
|
115
|
+
<div className="flex items-center gap-2">
|
|
116
|
+
<ImageOff className="w-4 h-4 text-red-400" />
|
|
117
|
+
<span className="text-sm text-gray-700">Missing alt text</span>
|
|
118
|
+
</div>
|
|
119
|
+
<span className="text-sm font-semibold text-gray-900">{issues.missingAltText}</span>
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
{totalIssues > 0 && (
|
|
123
|
+
<button
|
|
124
|
+
onClick={() => onNavigate?.('/seo')}
|
|
125
|
+
className="mt-4 text-sm text-blue-600 hover:text-blue-700 font-medium"
|
|
126
|
+
>
|
|
127
|
+
View All Issues
|
|
128
|
+
</button>
|
|
129
|
+
)}
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
);
|
|
134
|
+
}
|
package/src/views/Dashboard.tsx
CHANGED
|
@@ -1,13 +1,30 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
FileText, File, Image, Users, ClipboardList, Search,
|
|
5
|
+
Loader2, AlertTriangle, Database, ChevronLeft, ChevronRight,
|
|
6
|
+
} from 'lucide-react';
|
|
7
|
+
import { useState } from 'react';
|
|
4
8
|
import { useApiData } from '../lib/useApiData.js';
|
|
9
|
+
import { ContentOverviewChart } from '../components/ContentOverviewChart.js';
|
|
10
|
+
import { SEOPerformance } from '../components/SEOPerformance.js';
|
|
5
11
|
|
|
6
12
|
interface DashboardStats {
|
|
7
13
|
totalDocuments: number;
|
|
8
14
|
totalMedia: number;
|
|
9
15
|
totalUsers: number;
|
|
10
|
-
|
|
16
|
+
formCount: number;
|
|
17
|
+
avgSeoScore: number;
|
|
18
|
+
collectionCounts: Record<string, number>;
|
|
19
|
+
statusCounts: Record<string, number>;
|
|
20
|
+
recentDocuments: {
|
|
21
|
+
id: string;
|
|
22
|
+
title: string;
|
|
23
|
+
status: string;
|
|
24
|
+
collection: string;
|
|
25
|
+
updatedAt: string;
|
|
26
|
+
author: string;
|
|
27
|
+
}[];
|
|
11
28
|
}
|
|
12
29
|
|
|
13
30
|
interface HealthData {
|
|
@@ -18,32 +35,122 @@ interface HealthData {
|
|
|
18
35
|
databaseConnected: boolean;
|
|
19
36
|
}
|
|
20
37
|
|
|
38
|
+
interface CollectionMeta {
|
|
39
|
+
slug: string;
|
|
40
|
+
type?: string;
|
|
41
|
+
labels?: { singular?: string; plural?: string };
|
|
42
|
+
}
|
|
43
|
+
|
|
21
44
|
export interface DashboardProps {
|
|
45
|
+
config?: any;
|
|
46
|
+
session?: any;
|
|
22
47
|
onNavigate?: (path: string) => void;
|
|
23
48
|
}
|
|
24
49
|
|
|
25
|
-
|
|
50
|
+
function resolveCollections(config: any): CollectionMeta[] {
|
|
51
|
+
if (!config?.collections) return [];
|
|
52
|
+
const raw = config.collections;
|
|
53
|
+
const list: any[] = Array.isArray(raw) ? raw : Object.values(raw);
|
|
54
|
+
return list
|
|
55
|
+
.filter((c) => !c.admin?.hidden)
|
|
56
|
+
.map((c) => ({ slug: c.slug, type: c.type, labels: c.labels }));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function collectionLabel(col: CollectionMeta, plural = true): string {
|
|
60
|
+
if (plural) return col.labels?.plural ?? col.slug.charAt(0).toUpperCase() + col.slug.slice(1);
|
|
61
|
+
return col.labels?.singular ?? col.slug.charAt(0).toUpperCase() + col.slug.slice(1);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function relativeTime(dateStr: string): string {
|
|
65
|
+
const now = Date.now();
|
|
66
|
+
const then = new Date(dateStr).getTime();
|
|
67
|
+
const diff = now - then;
|
|
68
|
+
const mins = Math.floor(diff / 60000);
|
|
69
|
+
if (mins < 1) return 'just now';
|
|
70
|
+
if (mins < 60) return `${mins} min ago`;
|
|
71
|
+
const hours = Math.floor(mins / 60);
|
|
72
|
+
if (hours < 24) return `${hours} hour${hours !== 1 ? 's' : ''} ago`;
|
|
73
|
+
const days = Math.floor(hours / 24);
|
|
74
|
+
if (days < 30) return `${days} day${days !== 1 ? 's' : ''} ago`;
|
|
75
|
+
return new Date(dateStr).toLocaleDateString();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function statusColor(status: string): string {
|
|
79
|
+
switch (status) {
|
|
80
|
+
case 'PUBLISHED': return 'bg-green-100 text-green-800';
|
|
81
|
+
case 'DRAFT': return 'bg-gray-100 text-gray-700';
|
|
82
|
+
case 'SCHEDULED': return 'bg-purple-100 text-purple-800';
|
|
83
|
+
case 'IN_REVIEW': return 'bg-blue-100 text-blue-800';
|
|
84
|
+
default: return 'bg-gray-100 text-gray-700';
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function statusLabel(status: string): string {
|
|
89
|
+
switch (status) {
|
|
90
|
+
case 'PUBLISHED': return 'Published';
|
|
91
|
+
case 'DRAFT': return 'Draft';
|
|
92
|
+
case 'SCHEDULED': return 'Scheduled';
|
|
93
|
+
case 'IN_REVIEW': return 'In Review';
|
|
94
|
+
default: return status;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const STAT_COLORS = [
|
|
99
|
+
{ bg: 'bg-blue-50', icon: 'bg-blue-100', text: 'text-blue-600' },
|
|
100
|
+
{ bg: 'bg-purple-50', icon: 'bg-purple-100', text: 'text-purple-600' },
|
|
101
|
+
{ bg: 'bg-teal-50', icon: 'bg-teal-100', text: 'text-teal-600' },
|
|
102
|
+
{ bg: 'bg-green-50', icon: 'bg-green-100', text: 'text-green-600' },
|
|
103
|
+
{ bg: 'bg-amber-50', icon: 'bg-amber-100', text: 'text-amber-600' },
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
export function Dashboard({ config, session, onNavigate }: DashboardProps) {
|
|
26
107
|
const nav = (path: string) => onNavigate?.(path);
|
|
27
108
|
const { data, loading, error, exhausted, refetch } = useApiData<DashboardStats>('/stats');
|
|
28
109
|
const { data: health } = useApiData<HealthData>('/health');
|
|
110
|
+
const [activityPage, setActivityPage] = useState(0);
|
|
111
|
+
|
|
112
|
+
const collections = resolveCollections(config);
|
|
113
|
+
const userName = session?.name ?? session?.email?.split('@')[0] ?? 'Admin';
|
|
114
|
+
|
|
115
|
+
const collectionCounts = data?.collectionCounts ?? {};
|
|
116
|
+
const statusCounts = data?.statusCounts ?? {};
|
|
117
|
+
const recentDocs = data?.recentDocuments ?? [];
|
|
118
|
+
|
|
119
|
+
const perPage = 5;
|
|
120
|
+
const totalActivityPages = Math.max(1, Math.ceil(recentDocs.length / perPage));
|
|
121
|
+
const visibleDocs = recentDocs.slice(activityPage * perPage, (activityPage + 1) * perPage);
|
|
122
|
+
|
|
123
|
+
const statCards: { label: string; value: number; icon: typeof FileText }[] = [];
|
|
29
124
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
125
|
+
if (collections.length > 0) {
|
|
126
|
+
for (const col of collections.slice(0, 2)) {
|
|
127
|
+
statCards.push({
|
|
128
|
+
label: collectionLabel(col),
|
|
129
|
+
value: collectionCounts[col.slug] ?? 0,
|
|
130
|
+
icon: col.type === 'page' ? File : FileText,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
} else {
|
|
134
|
+
statCards.push({ label: 'Pages', value: collectionCounts['pages'] ?? 0, icon: File });
|
|
135
|
+
statCards.push({ label: 'Posts', value: collectionCounts['posts'] ?? 0, icon: FileText });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
statCards.push({ label: 'Forms', value: data?.formCount ?? 0, icon: ClipboardList });
|
|
139
|
+
statCards.push({ label: 'Media', value: data?.totalMedia ?? 0, icon: Image });
|
|
140
|
+
statCards.push({ label: 'Avg. SEO Rating', value: data?.avgSeoScore ?? 0, icon: Search });
|
|
34
141
|
|
|
35
142
|
if (loading) {
|
|
36
143
|
return (
|
|
37
|
-
<div className="p-
|
|
144
|
+
<div className="p-4 sm:p-6 flex items-center justify-center h-64">
|
|
38
145
|
<Loader2 className="w-6 h-6 animate-spin text-blue-600" />
|
|
39
146
|
</div>
|
|
40
147
|
);
|
|
41
148
|
}
|
|
42
149
|
|
|
43
150
|
return (
|
|
44
|
-
<div className="p-
|
|
151
|
+
<div className="p-4 sm:p-6 space-y-6">
|
|
45
152
|
{health && health.status === 'degraded' && (
|
|
46
|
-
<div className="
|
|
153
|
+
<div className="flex items-center gap-3 rounded-lg border border-blue-200 bg-blue-50 p-3">
|
|
47
154
|
<Database className="w-5 h-5 text-blue-600 shrink-0" />
|
|
48
155
|
<div className="flex-1">
|
|
49
156
|
<span className="text-sm font-medium text-blue-900">Database Setup Required</span>
|
|
@@ -52,7 +159,7 @@ export function Dashboard({ onNavigate }: DashboardProps) {
|
|
|
52
159
|
? 'Cannot connect to the database. Check your DATABASE_URL environment variable.'
|
|
53
160
|
: !health.secretConfigured
|
|
54
161
|
? 'CMS secret not configured. Set CMS_SECRET or CMS_SESSION_SECRET (min 32 characters).'
|
|
55
|
-
: `Some CMS models are missing: ${Object.entries(health.models).filter(([, v]) => !v).map(([k]) => k).join(', ')}. Run your database migrations
|
|
162
|
+
: `Some CMS models are missing: ${Object.entries(health.models).filter(([, v]) => !v).map(([k]) => k).join(', ')}. Run your database migrations.`
|
|
56
163
|
}
|
|
57
164
|
</p>
|
|
58
165
|
</div>
|
|
@@ -60,148 +167,117 @@ export function Dashboard({ onNavigate }: DashboardProps) {
|
|
|
60
167
|
)}
|
|
61
168
|
|
|
62
169
|
{error && exhausted && (
|
|
63
|
-
<div className="
|
|
170
|
+
<div className="flex items-center gap-3 rounded-lg border border-amber-200 bg-amber-50 p-3">
|
|
64
171
|
<AlertTriangle className="w-5 h-5 text-amber-600 shrink-0" />
|
|
65
|
-
<span className="text-sm text-amber-800 flex-1">Some dashboard data may be unavailable
|
|
172
|
+
<span className="text-sm text-amber-800 flex-1">Some dashboard data may be unavailable.</span>
|
|
66
173
|
<button onClick={refetch} className="px-3 py-1 text-sm text-amber-700 border border-amber-300 rounded-lg hover:bg-amber-100 transition-colors">Retry</button>
|
|
67
174
|
</div>
|
|
68
175
|
)}
|
|
69
176
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
<
|
|
177
|
+
{/* Header */}
|
|
178
|
+
<div>
|
|
179
|
+
<h1 className="text-xl sm:text-2xl font-semibold text-gray-900">Welcome back, {userName}</h1>
|
|
180
|
+
<p className="text-sm text-gray-500 mt-0.5">Here's what's happening with your content today</p>
|
|
73
181
|
</div>
|
|
74
182
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
<div className="bg-white rounded-lg border border-gray-200 p-3 sm:p-4">
|
|
90
|
-
<div className="flex items-center justify-between">
|
|
91
|
-
<div>
|
|
92
|
-
<p className="text-xs text-gray-600 mb-1">Total Pages</p>
|
|
93
|
-
<p className="text-xl sm:text-2xl font-semibold text-gray-900">0</p>
|
|
94
|
-
<p className="text-xs text-gray-400 mt-1">—</p>
|
|
95
|
-
</div>
|
|
96
|
-
<div className="w-8 h-8 sm:w-10 sm:h-10 bg-purple-100 rounded-lg flex items-center justify-center">
|
|
97
|
-
<Layout className="w-4 h-4 sm:w-5 sm:h-5 text-purple-600" />
|
|
98
|
-
</div>
|
|
99
|
-
</div>
|
|
100
|
-
</div>
|
|
101
|
-
|
|
102
|
-
<div className="bg-white rounded-lg border border-gray-200 p-3 sm:p-4">
|
|
103
|
-
<div className="flex items-center justify-between">
|
|
104
|
-
<div>
|
|
105
|
-
<p className="text-xs text-gray-600 mb-1">Media Files</p>
|
|
106
|
-
<p className="text-xl sm:text-2xl font-semibold text-gray-900">{totalMedia.toLocaleString()}</p>
|
|
107
|
-
<p className="text-xs text-gray-400 mt-1">—</p>
|
|
108
|
-
</div>
|
|
109
|
-
<div className="w-8 h-8 sm:w-10 sm:h-10 bg-green-100 rounded-lg flex items-center justify-center">
|
|
110
|
-
<Image className="w-4 h-4 sm:w-5 sm:h-5 text-green-600" />
|
|
111
|
-
</div>
|
|
112
|
-
</div>
|
|
113
|
-
</div>
|
|
114
|
-
|
|
115
|
-
<div className="bg-white rounded-lg border border-gray-200 p-3 sm:p-4">
|
|
116
|
-
<div className="flex items-center justify-between">
|
|
117
|
-
<div>
|
|
118
|
-
<p className="text-xs text-gray-600 mb-1">Total Users</p>
|
|
119
|
-
<p className="text-xl sm:text-2xl font-semibold text-gray-900">{totalUsers}</p>
|
|
120
|
-
<p className="text-xs text-gray-400 mt-1">—</p>
|
|
121
|
-
</div>
|
|
122
|
-
<div className="w-8 h-8 sm:w-10 sm:h-10 bg-amber-100 rounded-lg flex items-center justify-center">
|
|
123
|
-
<Users className="w-4 h-4 sm:w-5 sm:h-5 text-amber-600" />
|
|
183
|
+
{/* Stat cards */}
|
|
184
|
+
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-5 gap-4">
|
|
185
|
+
{statCards.map((card, i) => {
|
|
186
|
+
const colors = STAT_COLORS[i % STAT_COLORS.length]!;
|
|
187
|
+
const Icon = card.icon;
|
|
188
|
+
return (
|
|
189
|
+
<div key={card.label} className="bg-white rounded-xl border border-gray-200 p-4">
|
|
190
|
+
<div className="flex items-center justify-between mb-3">
|
|
191
|
+
<div className={`w-9 h-9 rounded-lg flex items-center justify-center ${colors.icon}`}>
|
|
192
|
+
<Icon className={`w-4.5 h-4.5 ${colors.text}`} />
|
|
193
|
+
</div>
|
|
194
|
+
</div>
|
|
195
|
+
<p className="text-2xl font-semibold text-gray-900">{card.value.toLocaleString()}</p>
|
|
196
|
+
<p className="text-xs text-gray-500 mt-0.5">{card.label}</p>
|
|
124
197
|
</div>
|
|
125
|
-
|
|
126
|
-
|
|
198
|
+
);
|
|
199
|
+
})}
|
|
127
200
|
</div>
|
|
128
201
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
202
|
+
{/* Middle row: Recent Activity + Content Overview */}
|
|
203
|
+
<div className="grid grid-cols-1 lg:grid-cols-12 gap-4">
|
|
204
|
+
{/* Recent Activity */}
|
|
205
|
+
<div className="lg:col-span-8 bg-white rounded-xl border border-gray-200">
|
|
206
|
+
<div className="p-4 border-b border-gray-200">
|
|
207
|
+
<h2 className="text-sm font-semibold text-gray-900">Recent Activity</h2>
|
|
133
208
|
</div>
|
|
134
|
-
<div className="divide-y divide-gray-
|
|
135
|
-
{
|
|
209
|
+
<div className="divide-y divide-gray-100">
|
|
210
|
+
{visibleDocs.length === 0 ? (
|
|
136
211
|
<div className="p-8 text-center">
|
|
137
|
-
<p className="text-sm text-gray-
|
|
138
|
-
<button onClick={() => nav('/posts/new')} className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">Create your first post</button>
|
|
212
|
+
<p className="text-sm text-gray-400">No content yet</p>
|
|
139
213
|
</div>
|
|
140
|
-
) :
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
<
|
|
149
|
-
</
|
|
150
|
-
<
|
|
151
|
-
<
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
{post.status}
|
|
158
|
-
</span>
|
|
214
|
+
) : visibleDocs.map((doc) => {
|
|
215
|
+
const colMeta = collections.find((c) => c.slug === doc.collection);
|
|
216
|
+
const typeLabel = colMeta ? collectionLabel(colMeta, false) : doc.collection;
|
|
217
|
+
return (
|
|
218
|
+
<div key={doc.id} className="px-4 py-3 hover:bg-gray-50 transition-colors">
|
|
219
|
+
<div className="flex items-center justify-between gap-3">
|
|
220
|
+
<div className="flex-1 min-w-0">
|
|
221
|
+
<div className="flex items-center gap-2 mb-0.5">
|
|
222
|
+
<h3 className="text-sm font-medium text-gray-900 truncate">{doc.title ?? 'Untitled'}</h3>
|
|
223
|
+
</div>
|
|
224
|
+
<div className="flex items-center gap-2 text-xs text-gray-500">
|
|
225
|
+
<span className="capitalize">{typeLabel}</span>
|
|
226
|
+
<span>·</span>
|
|
227
|
+
<span>{doc.author}</span>
|
|
228
|
+
<span>·</span>
|
|
229
|
+
<span>{relativeTime(doc.updatedAt)}</span>
|
|
230
|
+
</div>
|
|
159
231
|
</div>
|
|
232
|
+
<span className={`px-2.5 py-0.5 rounded-full text-xs font-medium whitespace-nowrap ${statusColor(doc.status)}`}>
|
|
233
|
+
{statusLabel(doc.status)}
|
|
234
|
+
</span>
|
|
160
235
|
</div>
|
|
161
|
-
<button
|
|
162
|
-
onClick={() => nav(`/posts/${post.id}`)}
|
|
163
|
-
className="text-xs sm:text-sm text-blue-600 hover:text-blue-700 whitespace-nowrap"
|
|
164
|
-
>
|
|
165
|
-
Edit
|
|
166
|
-
</button>
|
|
167
236
|
</div>
|
|
168
|
-
|
|
169
|
-
)
|
|
237
|
+
);
|
|
238
|
+
})}
|
|
170
239
|
</div>
|
|
240
|
+
{recentDocs.length > perPage && (
|
|
241
|
+
<div className="px-4 py-3 border-t border-gray-100 flex items-center justify-between text-xs text-gray-500">
|
|
242
|
+
<span>Showing {activityPage * perPage + 1}-{Math.min((activityPage + 1) * perPage, recentDocs.length)} of {recentDocs.length}</span>
|
|
243
|
+
<div className="flex items-center gap-1">
|
|
244
|
+
<button
|
|
245
|
+
onClick={() => setActivityPage(Math.max(0, activityPage - 1))}
|
|
246
|
+
disabled={activityPage === 0}
|
|
247
|
+
className="p-1 rounded hover:bg-gray-100 disabled:opacity-30"
|
|
248
|
+
>
|
|
249
|
+
<ChevronLeft className="w-3.5 h-3.5" />
|
|
250
|
+
</button>
|
|
251
|
+
<span>Page {activityPage + 1} of {totalActivityPages}</span>
|
|
252
|
+
<button
|
|
253
|
+
onClick={() => setActivityPage(Math.min(totalActivityPages - 1, activityPage + 1))}
|
|
254
|
+
disabled={activityPage >= totalActivityPages - 1}
|
|
255
|
+
className="p-1 rounded hover:bg-gray-100 disabled:opacity-30"
|
|
256
|
+
>
|
|
257
|
+
<ChevronRight className="w-3.5 h-3.5" />
|
|
258
|
+
</button>
|
|
259
|
+
</div>
|
|
260
|
+
</div>
|
|
261
|
+
)}
|
|
171
262
|
</div>
|
|
172
263
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
264
|
+
{/* Content Overview */}
|
|
265
|
+
<div className="lg:col-span-4 bg-white rounded-xl border border-gray-200">
|
|
266
|
+
<div className="p-4 border-b border-gray-200">
|
|
267
|
+
<h2 className="text-sm font-semibold text-gray-900">Content Overview</h2>
|
|
176
268
|
</div>
|
|
177
|
-
<div className="p-
|
|
178
|
-
<
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
</button>
|
|
184
|
-
<button onClick={() => nav('/pages/new')} className="flex items-center gap-3 p-2.5 sm:p-3 rounded-lg hover:bg-gray-50 transition-colors group w-full text-left">
|
|
185
|
-
<div className="w-8 h-8 bg-purple-100 rounded-lg flex items-center justify-center group-hover:bg-purple-200 transition-colors">
|
|
186
|
-
<Layout className="w-4 h-4 text-purple-600" />
|
|
187
|
-
</div>
|
|
188
|
-
<span className="text-sm font-medium text-gray-900">New Page</span>
|
|
189
|
-
</button>
|
|
190
|
-
<button onClick={() => nav('/media')} className="flex items-center gap-3 p-2.5 sm:p-3 rounded-lg hover:bg-gray-50 transition-colors group w-full text-left">
|
|
191
|
-
<div className="w-8 h-8 bg-green-100 rounded-lg flex items-center justify-center group-hover:bg-green-200 transition-colors">
|
|
192
|
-
<Image className="w-4 h-4 text-green-600" />
|
|
193
|
-
</div>
|
|
194
|
-
<span className="text-sm font-medium text-gray-900">Upload Media</span>
|
|
195
|
-
</button>
|
|
196
|
-
<button onClick={() => nav('/users')} className="flex items-center gap-3 p-2.5 sm:p-3 rounded-lg hover:bg-gray-50 transition-colors group w-full text-left">
|
|
197
|
-
<div className="w-8 h-8 bg-amber-100 rounded-lg flex items-center justify-center group-hover:bg-amber-200 transition-colors">
|
|
198
|
-
<Users className="w-4 h-4 text-amber-600" />
|
|
199
|
-
</div>
|
|
200
|
-
<span className="text-sm font-medium text-gray-900">Manage Users</span>
|
|
201
|
-
</button>
|
|
269
|
+
<div className="p-4 flex items-center justify-center min-h-[220px]">
|
|
270
|
+
<ContentOverviewChart
|
|
271
|
+
published={statusCounts['PUBLISHED'] ?? 0}
|
|
272
|
+
drafts={statusCounts['DRAFT'] ?? 0}
|
|
273
|
+
scheduled={statusCounts['SCHEDULED'] ?? 0}
|
|
274
|
+
/>
|
|
202
275
|
</div>
|
|
203
276
|
</div>
|
|
204
277
|
</div>
|
|
278
|
+
|
|
279
|
+
{/* SEO Performance */}
|
|
280
|
+
<SEOPerformance onNavigate={nav} />
|
|
205
281
|
</div>
|
|
206
282
|
);
|
|
207
283
|
}
|