@actuate-media/cms-admin 0.2.1 → 0.4.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 +2 -2
- 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/layout/Sidebar.d.ts +7 -0
- package/dist/layout/Sidebar.d.ts.map +1 -1
- package/dist/layout/Sidebar.js +34 -10
- package/dist/layout/Sidebar.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 +81 -32
- package/dist/views/Dashboard.js.map +1 -1
- package/dist/views/DocumentEdit.d.ts.map +1 -1
- package/dist/views/DocumentEdit.js +49 -5
- package/dist/views/DocumentEdit.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/dist/views/Settings.d.ts +2 -1
- package/dist/views/Settings.d.ts.map +1 -1
- package/dist/views/Settings.js +31 -3
- package/dist/views/Settings.js.map +1 -1
- package/package.json +3 -2
- package/src/AdminRoot.tsx +2 -2
- package/src/components/ContentOverviewChart.tsx +70 -0
- package/src/components/SEOPerformance.tsx +134 -0
- package/src/layout/Sidebar.tsx +70 -22
- package/src/views/Dashboard.tsx +175 -192
- package/src/views/DocumentEdit.tsx +82 -4
- package/src/views/Pages.tsx +132 -58
- package/src/views/Settings.tsx +79 -2
package/src/views/Dashboard.tsx
CHANGED
|
@@ -1,14 +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;
|
|
16
|
+
formCount: number;
|
|
17
|
+
avgSeoScore: number;
|
|
10
18
|
collectionCounts: Record<string, number>;
|
|
11
|
-
|
|
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
|
+
}[];
|
|
12
28
|
}
|
|
13
29
|
|
|
14
30
|
interface HealthData {
|
|
@@ -27,6 +43,7 @@ interface CollectionMeta {
|
|
|
27
43
|
|
|
28
44
|
export interface DashboardProps {
|
|
29
45
|
config?: any;
|
|
46
|
+
session?: any;
|
|
30
47
|
onNavigate?: (path: string) => void;
|
|
31
48
|
}
|
|
32
49
|
|
|
@@ -36,11 +53,7 @@ function resolveCollections(config: any): CollectionMeta[] {
|
|
|
36
53
|
const list: any[] = Array.isArray(raw) ? raw : Object.values(raw);
|
|
37
54
|
return list
|
|
38
55
|
.filter((c) => !c.admin?.hidden)
|
|
39
|
-
.map((c) => ({
|
|
40
|
-
slug: c.slug,
|
|
41
|
-
type: c.type,
|
|
42
|
-
labels: c.labels,
|
|
43
|
-
}));
|
|
56
|
+
.map((c) => ({ slug: c.slug, type: c.type, labels: c.labels }));
|
|
44
57
|
}
|
|
45
58
|
|
|
46
59
|
function collectionLabel(col: CollectionMeta, plural = true): string {
|
|
@@ -48,45 +61,96 @@ function collectionLabel(col: CollectionMeta, plural = true): string {
|
|
|
48
61
|
return col.labels?.singular ?? col.slug.charAt(0).toUpperCase() + col.slug.slice(1);
|
|
49
62
|
}
|
|
50
63
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
+
}
|
|
57
87
|
|
|
58
|
-
function
|
|
59
|
-
|
|
60
|
-
|
|
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
|
+
}
|
|
61
96
|
}
|
|
62
97
|
|
|
63
|
-
|
|
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) {
|
|
64
107
|
const nav = (path: string) => onNavigate?.(path);
|
|
65
108
|
const { data, loading, error, exhausted, refetch } = useApiData<DashboardStats>('/stats');
|
|
66
109
|
const { data: health } = useApiData<HealthData>('/health');
|
|
110
|
+
const [activityPage, setActivityPage] = useState(0);
|
|
67
111
|
|
|
68
112
|
const collections = resolveCollections(config);
|
|
69
|
-
const
|
|
113
|
+
const userName = session?.name ?? session?.email?.split('@')[0] ?? 'Admin';
|
|
70
114
|
|
|
71
|
-
const totalMedia = data?.totalMedia ?? 0;
|
|
72
|
-
const totalUsers = data?.totalUsers ?? 0;
|
|
73
115
|
const collectionCounts = data?.collectionCounts ?? {};
|
|
116
|
+
const statusCounts = data?.statusCounts ?? {};
|
|
74
117
|
const recentDocs = data?.recentDocuments ?? [];
|
|
75
118
|
|
|
76
|
-
const
|
|
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 }[] = [];
|
|
124
|
+
|
|
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 });
|
|
77
141
|
|
|
78
142
|
if (loading) {
|
|
79
143
|
return (
|
|
80
|
-
<div className="p-
|
|
144
|
+
<div className="p-4 sm:p-6 flex items-center justify-center h-64">
|
|
81
145
|
<Loader2 className="w-6 h-6 animate-spin text-blue-600" />
|
|
82
146
|
</div>
|
|
83
147
|
);
|
|
84
148
|
}
|
|
85
149
|
|
|
86
150
|
return (
|
|
87
|
-
<div className="p-
|
|
151
|
+
<div className="p-4 sm:p-6 space-y-6">
|
|
88
152
|
{health && health.status === 'degraded' && (
|
|
89
|
-
<div className="
|
|
153
|
+
<div className="flex items-center gap-3 rounded-lg border border-blue-200 bg-blue-50 p-3">
|
|
90
154
|
<Database className="w-5 h-5 text-blue-600 shrink-0" />
|
|
91
155
|
<div className="flex-1">
|
|
92
156
|
<span className="text-sm font-medium text-blue-900">Database Setup Required</span>
|
|
@@ -95,7 +159,7 @@ export function Dashboard({ config, onNavigate }: DashboardProps) {
|
|
|
95
159
|
? 'Cannot connect to the database. Check your DATABASE_URL environment variable.'
|
|
96
160
|
: !health.secretConfigured
|
|
97
161
|
? 'CMS secret not configured. Set CMS_SECRET or CMS_SESSION_SECRET (min 32 characters).'
|
|
98
|
-
: `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.`
|
|
99
163
|
}
|
|
100
164
|
</p>
|
|
101
165
|
</div>
|
|
@@ -103,198 +167,117 @@ export function Dashboard({ config, onNavigate }: DashboardProps) {
|
|
|
103
167
|
)}
|
|
104
168
|
|
|
105
169
|
{error && exhausted && (
|
|
106
|
-
<div className="
|
|
170
|
+
<div className="flex items-center gap-3 rounded-lg border border-amber-200 bg-amber-50 p-3">
|
|
107
171
|
<AlertTriangle className="w-5 h-5 text-amber-600 shrink-0" />
|
|
108
|
-
<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>
|
|
109
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>
|
|
110
174
|
</div>
|
|
111
175
|
)}
|
|
112
176
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
<
|
|
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>
|
|
116
181
|
</div>
|
|
117
182
|
|
|
118
|
-
{/* Stat cards
|
|
119
|
-
<div className="grid grid-cols-2
|
|
120
|
-
{
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
<div
|
|
126
|
-
<div className=
|
|
127
|
-
<
|
|
128
|
-
<p className="text-xs text-gray-600 mb-1">{collectionLabel(col)}</p>
|
|
129
|
-
<p className="text-xl sm:text-2xl font-semibold text-gray-900">{(collectionCounts[col.slug] ?? 0).toLocaleString()}</p>
|
|
130
|
-
<p className="text-xs text-gray-400 mt-1">—</p>
|
|
131
|
-
</div>
|
|
132
|
-
<div className={`w-8 h-8 sm:w-10 sm:h-10 ${colors.bg} rounded-lg flex items-center justify-center`}>
|
|
133
|
-
<Icon className={`w-4 h-4 sm:w-5 sm:h-5 ${colors.text}`} />
|
|
134
|
-
</div>
|
|
135
|
-
</div>
|
|
136
|
-
</div>
|
|
137
|
-
);
|
|
138
|
-
})
|
|
139
|
-
) : (
|
|
140
|
-
<>
|
|
141
|
-
<div className="bg-white rounded-lg border border-gray-200 p-3 sm:p-4">
|
|
142
|
-
<div className="flex items-center justify-between">
|
|
143
|
-
<div>
|
|
144
|
-
<p className="text-xs text-gray-600 mb-1">Documents</p>
|
|
145
|
-
<p className="text-xl sm:text-2xl font-semibold text-gray-900">{(data?.totalDocuments ?? 0).toLocaleString()}</p>
|
|
146
|
-
<p className="text-xs text-gray-400 mt-1">—</p>
|
|
147
|
-
</div>
|
|
148
|
-
<div className="w-8 h-8 sm:w-10 sm:h-10 bg-blue-100 rounded-lg flex items-center justify-center">
|
|
149
|
-
<FileText className="w-4 h-4 sm:w-5 sm:h-5 text-blue-600" />
|
|
150
|
-
</div>
|
|
151
|
-
</div>
|
|
152
|
-
</div>
|
|
153
|
-
<div className="bg-white rounded-lg border border-gray-200 p-3 sm:p-4">
|
|
154
|
-
<div className="flex items-center justify-between">
|
|
155
|
-
<div>
|
|
156
|
-
<p className="text-xs text-gray-600 mb-1">Pages</p>
|
|
157
|
-
<p className="text-xl sm:text-2xl font-semibold text-gray-900">{(collectionCounts['pages'] ?? 0).toLocaleString()}</p>
|
|
158
|
-
<p className="text-xs text-gray-400 mt-1">—</p>
|
|
159
|
-
</div>
|
|
160
|
-
<div className="w-8 h-8 sm:w-10 sm:h-10 bg-purple-100 rounded-lg flex items-center justify-center">
|
|
161
|
-
<Layout className="w-4 h-4 sm:w-5 sm:h-5 text-purple-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}`} />
|
|
162
193
|
</div>
|
|
163
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>
|
|
164
197
|
</div>
|
|
165
|
-
|
|
166
|
-
)}
|
|
167
|
-
|
|
168
|
-
<div className="bg-white rounded-lg border border-gray-200 p-3 sm:p-4">
|
|
169
|
-
<div className="flex items-center justify-between">
|
|
170
|
-
<div>
|
|
171
|
-
<p className="text-xs text-gray-600 mb-1">Media Files</p>
|
|
172
|
-
<p className="text-xl sm:text-2xl font-semibold text-gray-900">{totalMedia.toLocaleString()}</p>
|
|
173
|
-
<p className="text-xs text-gray-400 mt-1">—</p>
|
|
174
|
-
</div>
|
|
175
|
-
<div className="w-8 h-8 sm:w-10 sm:h-10 bg-green-100 rounded-lg flex items-center justify-center">
|
|
176
|
-
<Image className="w-4 h-4 sm:w-5 sm:h-5 text-green-600" />
|
|
177
|
-
</div>
|
|
178
|
-
</div>
|
|
179
|
-
</div>
|
|
180
|
-
|
|
181
|
-
<div className="bg-white rounded-lg border border-gray-200 p-3 sm:p-4">
|
|
182
|
-
<div className="flex items-center justify-between">
|
|
183
|
-
<div>
|
|
184
|
-
<p className="text-xs text-gray-600 mb-1">Total Users</p>
|
|
185
|
-
<p className="text-xl sm:text-2xl font-semibold text-gray-900">{totalUsers}</p>
|
|
186
|
-
<p className="text-xs text-gray-400 mt-1">—</p>
|
|
187
|
-
</div>
|
|
188
|
-
<div className="w-8 h-8 sm:w-10 sm:h-10 bg-amber-100 rounded-lg flex items-center justify-center">
|
|
189
|
-
<Users className="w-4 h-4 sm:w-5 sm:h-5 text-amber-600" />
|
|
190
|
-
</div>
|
|
191
|
-
</div>
|
|
192
|
-
</div>
|
|
198
|
+
);
|
|
199
|
+
})}
|
|
193
200
|
</div>
|
|
194
201
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
</h2>
|
|
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>
|
|
202
208
|
</div>
|
|
203
|
-
<div className="divide-y divide-gray-
|
|
204
|
-
{
|
|
209
|
+
<div className="divide-y divide-gray-100">
|
|
210
|
+
{visibleDocs.length === 0 ? (
|
|
205
211
|
<div className="p-8 text-center">
|
|
206
|
-
<p className="text-sm text-gray-
|
|
207
|
-
No {primaryCollection ? collectionLabel(primaryCollection).toLowerCase() : 'documents'} yet
|
|
208
|
-
</p>
|
|
209
|
-
{primaryCollection && (
|
|
210
|
-
<button onClick={() => nav(`/${primaryCollection.slug}/new`)} className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
|
|
211
|
-
Create your first {collectionLabel(primaryCollection, false).toLowerCase()}
|
|
212
|
-
</button>
|
|
213
|
-
)}
|
|
212
|
+
<p className="text-sm text-gray-400">No content yet</p>
|
|
214
213
|
</div>
|
|
215
|
-
) :
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
<
|
|
224
|
-
</
|
|
225
|
-
<
|
|
226
|
-
<
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
doc.
|
|
231
|
-
|
|
232
|
-
{doc.status}
|
|
233
|
-
</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>
|
|
234
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>
|
|
235
235
|
</div>
|
|
236
|
-
<button
|
|
237
|
-
onClick={() => nav(`/${doc.collection}/${doc.id}`)}
|
|
238
|
-
className="text-xs sm:text-sm text-blue-600 hover:text-blue-700 whitespace-nowrap"
|
|
239
|
-
>
|
|
240
|
-
Edit
|
|
241
|
-
</button>
|
|
242
236
|
</div>
|
|
243
|
-
|
|
244
|
-
)
|
|
237
|
+
);
|
|
238
|
+
})}
|
|
245
239
|
</div>
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
const colors = STAT_COLORS[i % STAT_COLORS.length]!;
|
|
257
|
-
const Icon = collectionIcon(col);
|
|
258
|
-
return (
|
|
259
|
-
<button key={col.slug} onClick={() => nav(`/${col.slug}/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">
|
|
260
|
-
<div className={`w-8 h-8 ${colors.bg} rounded-lg flex items-center justify-center group-hover:opacity-80 transition-colors`}>
|
|
261
|
-
<Icon className={`w-4 h-4 ${colors.text}`} />
|
|
262
|
-
</div>
|
|
263
|
-
<span className="text-sm font-medium text-gray-900">New {collectionLabel(col, false)}</span>
|
|
264
|
-
</button>
|
|
265
|
-
);
|
|
266
|
-
})
|
|
267
|
-
) : (
|
|
268
|
-
<>
|
|
269
|
-
<button onClick={() => nav('/posts/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">
|
|
270
|
-
<div className="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center group-hover:bg-blue-200 transition-colors">
|
|
271
|
-
<FileText className="w-4 h-4 text-blue-600" />
|
|
272
|
-
</div>
|
|
273
|
-
<span className="text-sm font-medium text-gray-900">New Post</span>
|
|
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" />
|
|
274
250
|
</button>
|
|
275
|
-
<
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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" />
|
|
280
258
|
</button>
|
|
281
|
-
</>
|
|
282
|
-
)}
|
|
283
|
-
<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">
|
|
284
|
-
<div className="w-8 h-8 bg-green-100 rounded-lg flex items-center justify-center group-hover:bg-green-200 transition-colors">
|
|
285
|
-
<Image className="w-4 h-4 text-green-600" />
|
|
286
|
-
</div>
|
|
287
|
-
<span className="text-sm font-medium text-gray-900">Upload Media</span>
|
|
288
|
-
</button>
|
|
289
|
-
<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">
|
|
290
|
-
<div className="w-8 h-8 bg-amber-100 rounded-lg flex items-center justify-center group-hover:bg-amber-200 transition-colors">
|
|
291
|
-
<Users className="w-4 h-4 text-amber-600" />
|
|
292
259
|
</div>
|
|
293
|
-
|
|
294
|
-
|
|
260
|
+
</div>
|
|
261
|
+
)}
|
|
262
|
+
</div>
|
|
263
|
+
|
|
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>
|
|
268
|
+
</div>
|
|
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
|
+
/>
|
|
295
275
|
</div>
|
|
296
276
|
</div>
|
|
297
277
|
</div>
|
|
278
|
+
|
|
279
|
+
{/* SEO Performance */}
|
|
280
|
+
<SEOPerformance onNavigate={nav} />
|
|
298
281
|
</div>
|
|
299
282
|
);
|
|
300
283
|
}
|
|
@@ -4,6 +4,7 @@ import { useState, useEffect, useCallback, useRef } from 'react';
|
|
|
4
4
|
import { AlertTriangle, Eye, EyeOff, Save, Copy, Loader2, Clock } from 'lucide-react';
|
|
5
5
|
import { toast } from 'sonner';
|
|
6
6
|
import { FieldRenderer } from '../fields/FieldRenderer.js';
|
|
7
|
+
import { RelationshipField } from '../fields/RelationshipField.js';
|
|
7
8
|
import { Button } from '../components/ui/Button.js';
|
|
8
9
|
import { Badge } from '../components/ui/Badge.js';
|
|
9
10
|
import { LivePreview } from '../components/LivePreview.js';
|
|
@@ -26,6 +27,8 @@ export function DocumentEdit({ collectionSlug, documentId, config, onNavigate }:
|
|
|
26
27
|
const [initialValues, setInitialValues] = useState<Record<string, any>>({});
|
|
27
28
|
const [seoData, setSeoData] = useState<SEOData>({});
|
|
28
29
|
const [initialSeoData, setInitialSeoData] = useState<SEOData>({});
|
|
30
|
+
const [layoutAssignments, setLayoutAssignments] = useState<Record<string, string>>({});
|
|
31
|
+
const [initialLayoutAssignments, setInitialLayoutAssignments] = useState<Record<string, string>>({});
|
|
29
32
|
const [saving, setSaving] = useState(false);
|
|
30
33
|
const [loading, setLoading] = useState(!isNew);
|
|
31
34
|
const [showPreview, setShowPreview] = useState(false);
|
|
@@ -38,6 +41,16 @@ export function DocumentEdit({ collectionSlug, documentId, config, onNavigate }:
|
|
|
38
41
|
? collections.find((c: any) => c.slug === collectionSlug)
|
|
39
42
|
: collections?.[collectionSlug];
|
|
40
43
|
|
|
44
|
+
const layoutConfig = config?.layout;
|
|
45
|
+
const layoutRegions: Array<{ name: string; collection: string; label: string }> = layoutConfig?.regions
|
|
46
|
+
? Object.entries(layoutConfig.regions).map(([name, region]: [string, any]) => ({
|
|
47
|
+
name,
|
|
48
|
+
collection: region.collection,
|
|
49
|
+
label: region.label ?? name.charAt(0).toUpperCase() + name.slice(1),
|
|
50
|
+
}))
|
|
51
|
+
: [];
|
|
52
|
+
const hasLayout = layoutRegions.length > 0 && (collection?.type === 'page' || collection?.urlPrefix !== undefined);
|
|
53
|
+
|
|
41
54
|
const previewUrl = collection?.admin?.preview ? collection.admin.preview({}) : undefined;
|
|
42
55
|
const fields: any[] = collection?.fields
|
|
43
56
|
? (Array.isArray(collection.fields)
|
|
@@ -51,7 +64,8 @@ export function DocumentEdit({ collectionSlug, documentId, config, onNavigate }:
|
|
|
51
64
|
: `Edit ${values[useAsTitleField] ?? 'Document'}`;
|
|
52
65
|
|
|
53
66
|
const isDirty = JSON.stringify(values) !== JSON.stringify(initialValues)
|
|
54
|
-
|| JSON.stringify(seoData) !== JSON.stringify(initialSeoData)
|
|
67
|
+
|| JSON.stringify(seoData) !== JSON.stringify(initialSeoData)
|
|
68
|
+
|| JSON.stringify(layoutAssignments) !== JSON.stringify(initialLayoutAssignments);
|
|
55
69
|
|
|
56
70
|
useEffect(() => {
|
|
57
71
|
if (!isNew && documentId && !hasLoadedRef.current) {
|
|
@@ -95,6 +109,12 @@ export function DocumentEdit({ collectionSlug, documentId, config, onNavigate }:
|
|
|
95
109
|
}
|
|
96
110
|
setSeoData(loadedSeo);
|
|
97
111
|
setInitialSeoData(loadedSeo);
|
|
112
|
+
|
|
113
|
+
if (docData._layout && typeof docData._layout === 'object') {
|
|
114
|
+
const loaded = docData._layout as Record<string, string>;
|
|
115
|
+
setLayoutAssignments(loaded);
|
|
116
|
+
setInitialLayoutAssignments(loaded);
|
|
117
|
+
}
|
|
98
118
|
} else if (res.error) {
|
|
99
119
|
toast.error(`Failed to load document: ${res.error}`);
|
|
100
120
|
}
|
|
@@ -107,7 +127,8 @@ export function DocumentEdit({ collectionSlug, documentId, config, onNavigate }:
|
|
|
107
127
|
|
|
108
128
|
async function handleSave() {
|
|
109
129
|
setSaving(true);
|
|
110
|
-
const
|
|
130
|
+
const layoutPayload = Object.keys(layoutAssignments).length > 0 ? { _layout: layoutAssignments } : {};
|
|
131
|
+
const payload = { ...values, ...seoData, ...layoutPayload };
|
|
111
132
|
try {
|
|
112
133
|
if (isNew) {
|
|
113
134
|
const res = await cmsApi<any>(`/collections/${collectionSlug}`, {
|
|
@@ -121,6 +142,7 @@ export function DocumentEdit({ collectionSlug, documentId, config, onNavigate }:
|
|
|
121
142
|
const newId = res.data?.id;
|
|
122
143
|
setInitialValues(values);
|
|
123
144
|
setInitialSeoData(seoData);
|
|
145
|
+
setInitialLayoutAssignments(layoutAssignments);
|
|
124
146
|
if (newId && onNavigate) {
|
|
125
147
|
onNavigate(`/${collectionSlug}/${newId}`);
|
|
126
148
|
}
|
|
@@ -136,6 +158,7 @@ export function DocumentEdit({ collectionSlug, documentId, config, onNavigate }:
|
|
|
136
158
|
toast.success('Changes saved');
|
|
137
159
|
setInitialValues(values);
|
|
138
160
|
setInitialSeoData(seoData);
|
|
161
|
+
setInitialLayoutAssignments(layoutAssignments);
|
|
139
162
|
if (res.data?.status) setDocStatus(res.data.status);
|
|
140
163
|
}
|
|
141
164
|
}
|
|
@@ -148,9 +171,10 @@ export function DocumentEdit({ collectionSlug, documentId, config, onNavigate }:
|
|
|
148
171
|
async function handlePublish() {
|
|
149
172
|
if (isNew) return;
|
|
150
173
|
setSaving(true);
|
|
174
|
+
const layoutPayload = Object.keys(layoutAssignments).length > 0 ? { _layout: layoutAssignments } : {};
|
|
151
175
|
const res = await cmsApi<any>(`/collections/${collectionSlug}/${documentId}`, {
|
|
152
176
|
method: 'PUT',
|
|
153
|
-
body: JSON.stringify({ ...values, ...seoData, status: 'PUBLISHED' }),
|
|
177
|
+
body: JSON.stringify({ ...values, ...seoData, ...layoutPayload, status: 'PUBLISHED' }),
|
|
154
178
|
});
|
|
155
179
|
if (res.error) {
|
|
156
180
|
toast.error(res.error);
|
|
@@ -159,6 +183,7 @@ export function DocumentEdit({ collectionSlug, documentId, config, onNavigate }:
|
|
|
159
183
|
setDocStatus('PUBLISHED');
|
|
160
184
|
setInitialValues(values);
|
|
161
185
|
setInitialSeoData(seoData);
|
|
186
|
+
setInitialLayoutAssignments(layoutAssignments);
|
|
162
187
|
}
|
|
163
188
|
setSaving(false);
|
|
164
189
|
}
|
|
@@ -166,9 +191,10 @@ export function DocumentEdit({ collectionSlug, documentId, config, onNavigate }:
|
|
|
166
191
|
async function handleUnpublish() {
|
|
167
192
|
if (isNew) return;
|
|
168
193
|
setSaving(true);
|
|
194
|
+
const layoutPayload = Object.keys(layoutAssignments).length > 0 ? { _layout: layoutAssignments } : {};
|
|
169
195
|
const res = await cmsApi<any>(`/collections/${collectionSlug}/${documentId}`, {
|
|
170
196
|
method: 'PUT',
|
|
171
|
-
body: JSON.stringify({ ...values, ...seoData, status: 'DRAFT' }),
|
|
197
|
+
body: JSON.stringify({ ...values, ...seoData, ...layoutPayload, status: 'DRAFT' }),
|
|
172
198
|
});
|
|
173
199
|
if (res.error) {
|
|
174
200
|
toast.error(res.error);
|
|
@@ -177,6 +203,7 @@ export function DocumentEdit({ collectionSlug, documentId, config, onNavigate }:
|
|
|
177
203
|
setDocStatus('DRAFT');
|
|
178
204
|
setInitialValues(values);
|
|
179
205
|
setInitialSeoData(seoData);
|
|
206
|
+
setInitialLayoutAssignments(layoutAssignments);
|
|
180
207
|
}
|
|
181
208
|
setSaving(false);
|
|
182
209
|
}
|
|
@@ -335,6 +362,57 @@ export function DocumentEdit({ collectionSlug, documentId, config, onNavigate }:
|
|
|
335
362
|
onChange={setSeoData}
|
|
336
363
|
siteUrl={config?.siteUrl}
|
|
337
364
|
/>
|
|
365
|
+
|
|
366
|
+
{hasLayout && (
|
|
367
|
+
<div className="rounded-lg border border-[var(--border)] bg-[var(--card)] p-4">
|
|
368
|
+
<h3 className="mb-1 font-semibold">Layout</h3>
|
|
369
|
+
<p className="mb-4 text-xs text-[var(--muted-foreground)]">
|
|
370
|
+
Assign header/footer variants. Child pages inherit from ancestors.
|
|
371
|
+
</p>
|
|
372
|
+
<div className="space-y-4">
|
|
373
|
+
{layoutRegions.map((region) => (
|
|
374
|
+
<div key={region.name}>
|
|
375
|
+
<RelationshipField
|
|
376
|
+
label={region.label}
|
|
377
|
+
value={layoutAssignments[region.name] ?? ''}
|
|
378
|
+
onChange={(val) => {
|
|
379
|
+
setLayoutAssignments((prev) => {
|
|
380
|
+
const next = { ...prev };
|
|
381
|
+
if (val && typeof val === 'string') {
|
|
382
|
+
next[region.name] = val;
|
|
383
|
+
} else {
|
|
384
|
+
delete next[region.name];
|
|
385
|
+
}
|
|
386
|
+
return next;
|
|
387
|
+
});
|
|
388
|
+
}}
|
|
389
|
+
relationTo={region.collection}
|
|
390
|
+
helpText={
|
|
391
|
+
!layoutAssignments[region.name]
|
|
392
|
+
? 'Inheriting from parent page or site default'
|
|
393
|
+
: undefined
|
|
394
|
+
}
|
|
395
|
+
/>
|
|
396
|
+
{layoutAssignments[region.name] && (
|
|
397
|
+
<button
|
|
398
|
+
type="button"
|
|
399
|
+
onClick={() => {
|
|
400
|
+
setLayoutAssignments((prev) => {
|
|
401
|
+
const next = { ...prev };
|
|
402
|
+
delete next[region.name];
|
|
403
|
+
return next;
|
|
404
|
+
});
|
|
405
|
+
}}
|
|
406
|
+
className="mt-1 text-xs text-[var(--primary)] hover:underline"
|
|
407
|
+
>
|
|
408
|
+
Clear override (inherit from parent)
|
|
409
|
+
</button>
|
|
410
|
+
)}
|
|
411
|
+
</div>
|
|
412
|
+
))}
|
|
413
|
+
</div>
|
|
414
|
+
</div>
|
|
415
|
+
)}
|
|
338
416
|
</div>
|
|
339
417
|
</div>
|
|
340
418
|
)}
|