@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,300 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { FileText, File, Layout, Image, Users, User, Calendar, Loader2, AlertTriangle, Database } from 'lucide-react';
|
|
4
|
+
import { useApiData } from '../lib/useApiData.js';
|
|
5
|
+
|
|
6
|
+
interface DashboardStats {
|
|
7
|
+
totalDocuments: number;
|
|
8
|
+
totalMedia: number;
|
|
9
|
+
totalUsers: number;
|
|
10
|
+
collectionCounts: Record<string, number>;
|
|
11
|
+
recentDocuments: { id: string; title: string; status: string; collection: string; updatedAt: string; createdById: string }[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface HealthData {
|
|
15
|
+
status: 'healthy' | 'degraded';
|
|
16
|
+
version: string;
|
|
17
|
+
secretConfigured: boolean;
|
|
18
|
+
models: Record<string, boolean>;
|
|
19
|
+
databaseConnected: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface CollectionMeta {
|
|
23
|
+
slug: string;
|
|
24
|
+
type?: string;
|
|
25
|
+
labels?: { singular?: string; plural?: string };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface DashboardProps {
|
|
29
|
+
config?: any;
|
|
30
|
+
onNavigate?: (path: string) => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function resolveCollections(config: any): CollectionMeta[] {
|
|
34
|
+
if (!config?.collections) return [];
|
|
35
|
+
const raw = config.collections;
|
|
36
|
+
const list: any[] = Array.isArray(raw) ? raw : Object.values(raw);
|
|
37
|
+
return list
|
|
38
|
+
.filter((c) => !c.admin?.hidden)
|
|
39
|
+
.map((c) => ({
|
|
40
|
+
slug: c.slug,
|
|
41
|
+
type: c.type,
|
|
42
|
+
labels: c.labels,
|
|
43
|
+
}));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function collectionLabel(col: CollectionMeta, plural = true): string {
|
|
47
|
+
if (plural) return col.labels?.plural ?? col.slug.charAt(0).toUpperCase() + col.slug.slice(1);
|
|
48
|
+
return col.labels?.singular ?? col.slug.charAt(0).toUpperCase() + col.slug.slice(1);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const STAT_COLORS = [
|
|
52
|
+
{ bg: 'bg-blue-100', text: 'text-blue-600' },
|
|
53
|
+
{ bg: 'bg-purple-100', text: 'text-purple-600' },
|
|
54
|
+
{ bg: 'bg-indigo-100', text: 'text-indigo-600' },
|
|
55
|
+
{ bg: 'bg-teal-100', text: 'text-teal-600' },
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
function collectionIcon(col: CollectionMeta) {
|
|
59
|
+
if (col.type === 'page') return File;
|
|
60
|
+
return FileText;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function Dashboard({ config, onNavigate }: DashboardProps) {
|
|
64
|
+
const nav = (path: string) => onNavigate?.(path);
|
|
65
|
+
const { data, loading, error, exhausted, refetch } = useApiData<DashboardStats>('/stats');
|
|
66
|
+
const { data: health } = useApiData<HealthData>('/health');
|
|
67
|
+
|
|
68
|
+
const collections = resolveCollections(config);
|
|
69
|
+
const hasCollections = collections.length > 0;
|
|
70
|
+
|
|
71
|
+
const totalMedia = data?.totalMedia ?? 0;
|
|
72
|
+
const totalUsers = data?.totalUsers ?? 0;
|
|
73
|
+
const collectionCounts = data?.collectionCounts ?? {};
|
|
74
|
+
const recentDocs = data?.recentDocuments ?? [];
|
|
75
|
+
|
|
76
|
+
const primaryCollection = hasCollections ? collections[0]! : null;
|
|
77
|
+
|
|
78
|
+
if (loading) {
|
|
79
|
+
return (
|
|
80
|
+
<div className="p-3 pr-6 sm:p-4 sm:pr-8 flex items-center justify-center h-64">
|
|
81
|
+
<Loader2 className="w-6 h-6 animate-spin text-blue-600" />
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<div className="p-3 pr-6 sm:p-4 sm:pr-8">
|
|
88
|
+
{health && health.status === 'degraded' && (
|
|
89
|
+
<div className="mb-4 flex items-center gap-3 rounded-lg border border-blue-200 bg-blue-50 p-3">
|
|
90
|
+
<Database className="w-5 h-5 text-blue-600 shrink-0" />
|
|
91
|
+
<div className="flex-1">
|
|
92
|
+
<span className="text-sm font-medium text-blue-900">Database Setup Required</span>
|
|
93
|
+
<p className="text-xs text-blue-700 mt-0.5">
|
|
94
|
+
{!health.databaseConnected
|
|
95
|
+
? 'Cannot connect to the database. Check your DATABASE_URL environment variable.'
|
|
96
|
+
: !health.secretConfigured
|
|
97
|
+
? '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 to create the required tables.`
|
|
99
|
+
}
|
|
100
|
+
</p>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
)}
|
|
104
|
+
|
|
105
|
+
{error && exhausted && (
|
|
106
|
+
<div className="mb-4 flex items-center gap-3 rounded-lg border border-amber-200 bg-amber-50 p-3">
|
|
107
|
+
<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. This is normal if your database hasn't been set up yet.</span>
|
|
109
|
+
<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
|
+
</div>
|
|
111
|
+
)}
|
|
112
|
+
|
|
113
|
+
<div className="mb-4 sm:mb-6">
|
|
114
|
+
<h1 className="text-xl sm:text-2xl font-semibold text-gray-900 mb-1">Dashboard</h1>
|
|
115
|
+
<p className="text-sm text-gray-600">Welcome back! Here's what's happening.</p>
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
{/* Stat cards: one per collection + media + users */}
|
|
119
|
+
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4 mb-4 sm:mb-6">
|
|
120
|
+
{hasCollections ? (
|
|
121
|
+
collections.slice(0, 2).map((col, i) => {
|
|
122
|
+
const colors = STAT_COLORS[i % STAT_COLORS.length]!;
|
|
123
|
+
const Icon = collectionIcon(col);
|
|
124
|
+
return (
|
|
125
|
+
<div key={col.slug} className="bg-white rounded-lg border border-gray-200 p-3 sm:p-4">
|
|
126
|
+
<div className="flex items-center justify-between">
|
|
127
|
+
<div>
|
|
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" />
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
</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>
|
|
193
|
+
</div>
|
|
194
|
+
|
|
195
|
+
<div className="grid grid-cols-1 lg:grid-cols-12 gap-3 sm:gap-4">
|
|
196
|
+
{/* Recent documents -- labeled by primary collection or generic */}
|
|
197
|
+
<div className="lg:col-span-8 bg-white rounded-lg border border-gray-200">
|
|
198
|
+
<div className="p-3 sm:p-4 border-b border-gray-200">
|
|
199
|
+
<h2 className="text-sm sm:text-base font-semibold text-gray-900">
|
|
200
|
+
Recent {primaryCollection ? collectionLabel(primaryCollection) : 'Documents'}
|
|
201
|
+
</h2>
|
|
202
|
+
</div>
|
|
203
|
+
<div className="divide-y divide-gray-200">
|
|
204
|
+
{recentDocs.length === 0 ? (
|
|
205
|
+
<div className="p-8 text-center">
|
|
206
|
+
<p className="text-sm text-gray-500 mb-2">
|
|
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
|
+
)}
|
|
214
|
+
</div>
|
|
215
|
+
) : recentDocs.map((doc) => (
|
|
216
|
+
<div key={doc.id} className="p-3 sm:p-4 hover:bg-gray-50 transition-colors">
|
|
217
|
+
<div className="flex items-start justify-between gap-3">
|
|
218
|
+
<div className="flex-1 min-w-0">
|
|
219
|
+
<h3 className="text-sm font-medium text-gray-900 mb-1 truncate">{doc.title ?? 'Untitled'}</h3>
|
|
220
|
+
<div className="flex flex-wrap items-center gap-2 sm:gap-3 text-xs text-gray-600">
|
|
221
|
+
<span className="flex items-center gap-1">
|
|
222
|
+
<User className="w-3 h-3" />
|
|
223
|
+
<span className="hidden sm:inline">{doc.createdById}</span>
|
|
224
|
+
</span>
|
|
225
|
+
<span className="flex items-center gap-1">
|
|
226
|
+
<Calendar className="w-3 h-3" />
|
|
227
|
+
{new Date(doc.updatedAt).toLocaleDateString()}
|
|
228
|
+
</span>
|
|
229
|
+
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${
|
|
230
|
+
doc.status === 'PUBLISHED' ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
|
|
231
|
+
}`}>
|
|
232
|
+
{doc.status}
|
|
233
|
+
</span>
|
|
234
|
+
</div>
|
|
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
|
+
</div>
|
|
243
|
+
</div>
|
|
244
|
+
))}
|
|
245
|
+
</div>
|
|
246
|
+
</div>
|
|
247
|
+
|
|
248
|
+
{/* Quick actions -- derived from collections */}
|
|
249
|
+
<div className="lg:col-span-4 bg-white rounded-lg border border-gray-200">
|
|
250
|
+
<div className="p-3 sm:p-4 border-b border-gray-200">
|
|
251
|
+
<h2 className="text-sm sm:text-base font-semibold text-gray-900">Quick Actions</h2>
|
|
252
|
+
</div>
|
|
253
|
+
<div className="p-3 sm:p-4 space-y-2">
|
|
254
|
+
{hasCollections ? (
|
|
255
|
+
collections.slice(0, 4).map((col, i) => {
|
|
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>
|
|
274
|
+
</button>
|
|
275
|
+
<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">
|
|
276
|
+
<div className="w-8 h-8 bg-purple-100 rounded-lg flex items-center justify-center group-hover:bg-purple-200 transition-colors">
|
|
277
|
+
<Layout className="w-4 h-4 text-purple-600" />
|
|
278
|
+
</div>
|
|
279
|
+
<span className="text-sm font-medium text-gray-900">New Page</span>
|
|
280
|
+
</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
|
+
</div>
|
|
293
|
+
<span className="text-sm font-medium text-gray-900">Manage Users</span>
|
|
294
|
+
</button>
|
|
295
|
+
</div>
|
|
296
|
+
</div>
|
|
297
|
+
</div>
|
|
298
|
+
</div>
|
|
299
|
+
);
|
|
300
|
+
}
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
4
|
+
import { AlertTriangle, Eye, EyeOff, Save, Copy, Loader2, Clock } from 'lucide-react';
|
|
5
|
+
import { toast } from 'sonner';
|
|
6
|
+
import { FieldRenderer } from '../fields/FieldRenderer.js';
|
|
7
|
+
import { Button } from '../components/ui/Button.js';
|
|
8
|
+
import { Badge } from '../components/ui/Badge.js';
|
|
9
|
+
import { LivePreview } from '../components/LivePreview.js';
|
|
10
|
+
import { VersionHistory } from '../components/VersionHistory.js';
|
|
11
|
+
import { PresenceIndicator } from '../components/PresenceIndicator.js';
|
|
12
|
+
import { SEOPanel } from '../components/SEOPanel.js';
|
|
13
|
+
import type { SEOData } from '../components/SEOPanel.js';
|
|
14
|
+
import { cmsApi } from '../lib/api.js';
|
|
15
|
+
|
|
16
|
+
export interface DocumentEditProps {
|
|
17
|
+
collectionSlug: string;
|
|
18
|
+
documentId?: string;
|
|
19
|
+
config: any;
|
|
20
|
+
onNavigate?: (path: string) => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function DocumentEdit({ collectionSlug, documentId, config, onNavigate }: DocumentEditProps) {
|
|
24
|
+
const isNew = !documentId;
|
|
25
|
+
const [values, setValues] = useState<Record<string, any>>({});
|
|
26
|
+
const [initialValues, setInitialValues] = useState<Record<string, any>>({});
|
|
27
|
+
const [seoData, setSeoData] = useState<SEOData>({});
|
|
28
|
+
const [initialSeoData, setInitialSeoData] = useState<SEOData>({});
|
|
29
|
+
const [saving, setSaving] = useState(false);
|
|
30
|
+
const [loading, setLoading] = useState(!isNew);
|
|
31
|
+
const [showPreview, setShowPreview] = useState(false);
|
|
32
|
+
const [showVersions, setShowVersions] = useState(false);
|
|
33
|
+
const [docStatus, setDocStatus] = useState<string>('DRAFT');
|
|
34
|
+
const hasLoadedRef = useRef(false);
|
|
35
|
+
|
|
36
|
+
const collections = config?.collections;
|
|
37
|
+
const collection = Array.isArray(collections)
|
|
38
|
+
? collections.find((c: any) => c.slug === collectionSlug)
|
|
39
|
+
: collections?.[collectionSlug];
|
|
40
|
+
|
|
41
|
+
const previewUrl = collection?.admin?.preview ? collection.admin.preview({}) : undefined;
|
|
42
|
+
const fields: any[] = collection?.fields
|
|
43
|
+
? (Array.isArray(collection.fields)
|
|
44
|
+
? collection.fields
|
|
45
|
+
: Object.entries(collection.fields).map(([name, def]: [string, any]) => ({ name, ...def })))
|
|
46
|
+
: [];
|
|
47
|
+
|
|
48
|
+
const useAsTitleField = collection?.admin?.useAsTitle ?? 'title';
|
|
49
|
+
const displayTitle = isNew
|
|
50
|
+
? `New ${collection?.labels?.singular ?? collectionSlug}`
|
|
51
|
+
: `Edit ${values[useAsTitleField] ?? 'Document'}`;
|
|
52
|
+
|
|
53
|
+
const isDirty = JSON.stringify(values) !== JSON.stringify(initialValues)
|
|
54
|
+
|| JSON.stringify(seoData) !== JSON.stringify(initialSeoData);
|
|
55
|
+
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (!isNew && documentId && !hasLoadedRef.current) {
|
|
58
|
+
hasLoadedRef.current = true;
|
|
59
|
+
loadDocument();
|
|
60
|
+
}
|
|
61
|
+
}, [documentId, isNew]);
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (!isDirty) return;
|
|
65
|
+
const handler = (e: BeforeUnloadEvent) => {
|
|
66
|
+
e.preventDefault();
|
|
67
|
+
};
|
|
68
|
+
window.addEventListener('beforeunload', handler);
|
|
69
|
+
return () => window.removeEventListener('beforeunload', handler);
|
|
70
|
+
}, [isDirty]);
|
|
71
|
+
|
|
72
|
+
const SEO_FIELDS: (keyof SEOData)[] = [
|
|
73
|
+
'metaTitle', 'metaDescription', 'focusKeyphrase', 'canonical',
|
|
74
|
+
'noIndex', 'noFollow', 'ogTitle', 'ogDescription', 'ogImage',
|
|
75
|
+
'twitterTitle', 'twitterDescription', 'twitterImage',
|
|
76
|
+
'isCornerstone', 'schemaType',
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
async function loadDocument() {
|
|
80
|
+
setLoading(true);
|
|
81
|
+
const res = await cmsApi<any>(`/collections/${collectionSlug}/${documentId}`);
|
|
82
|
+
if (res.data) {
|
|
83
|
+
const doc = res.data;
|
|
84
|
+
const docData = (doc.data && typeof doc.data === 'object') ? doc.data : {};
|
|
85
|
+
const merged = { ...docData, title: doc.title ?? docData.title, slug: doc.slug ?? docData.slug };
|
|
86
|
+
setValues(merged);
|
|
87
|
+
setInitialValues(merged);
|
|
88
|
+
setDocStatus(doc.status ?? 'DRAFT');
|
|
89
|
+
|
|
90
|
+
const loadedSeo: SEOData = {};
|
|
91
|
+
for (const key of SEO_FIELDS) {
|
|
92
|
+
if (docData[key] !== undefined) {
|
|
93
|
+
(loadedSeo as any)[key] = docData[key];
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
setSeoData(loadedSeo);
|
|
97
|
+
setInitialSeoData(loadedSeo);
|
|
98
|
+
} else if (res.error) {
|
|
99
|
+
toast.error(`Failed to load document: ${res.error}`);
|
|
100
|
+
}
|
|
101
|
+
setLoading(false);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function handleFieldChange(name: string, value: any) {
|
|
105
|
+
setValues((prev) => ({ ...prev, [name]: value }));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function handleSave() {
|
|
109
|
+
setSaving(true);
|
|
110
|
+
const payload = { ...values, ...seoData };
|
|
111
|
+
try {
|
|
112
|
+
if (isNew) {
|
|
113
|
+
const res = await cmsApi<any>(`/collections/${collectionSlug}`, {
|
|
114
|
+
method: 'POST',
|
|
115
|
+
body: JSON.stringify(payload),
|
|
116
|
+
});
|
|
117
|
+
if (res.error) {
|
|
118
|
+
toast.error(res.error);
|
|
119
|
+
} else {
|
|
120
|
+
toast.success('Document created');
|
|
121
|
+
const newId = res.data?.id;
|
|
122
|
+
setInitialValues(values);
|
|
123
|
+
setInitialSeoData(seoData);
|
|
124
|
+
if (newId && onNavigate) {
|
|
125
|
+
onNavigate(`/${collectionSlug}/${newId}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
} else {
|
|
129
|
+
const res = await cmsApi<any>(`/collections/${collectionSlug}/${documentId}`, {
|
|
130
|
+
method: 'PUT',
|
|
131
|
+
body: JSON.stringify(payload),
|
|
132
|
+
});
|
|
133
|
+
if (res.error) {
|
|
134
|
+
toast.error(res.error);
|
|
135
|
+
} else {
|
|
136
|
+
toast.success('Changes saved');
|
|
137
|
+
setInitialValues(values);
|
|
138
|
+
setInitialSeoData(seoData);
|
|
139
|
+
if (res.data?.status) setDocStatus(res.data.status);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
} catch {
|
|
143
|
+
toast.error('An unexpected error occurred');
|
|
144
|
+
}
|
|
145
|
+
setSaving(false);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function handlePublish() {
|
|
149
|
+
if (isNew) return;
|
|
150
|
+
setSaving(true);
|
|
151
|
+
const res = await cmsApi<any>(`/collections/${collectionSlug}/${documentId}`, {
|
|
152
|
+
method: 'PUT',
|
|
153
|
+
body: JSON.stringify({ ...values, ...seoData, status: 'PUBLISHED' }),
|
|
154
|
+
});
|
|
155
|
+
if (res.error) {
|
|
156
|
+
toast.error(res.error);
|
|
157
|
+
} else {
|
|
158
|
+
toast.success('Document published');
|
|
159
|
+
setDocStatus('PUBLISHED');
|
|
160
|
+
setInitialValues(values);
|
|
161
|
+
setInitialSeoData(seoData);
|
|
162
|
+
}
|
|
163
|
+
setSaving(false);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function handleUnpublish() {
|
|
167
|
+
if (isNew) return;
|
|
168
|
+
setSaving(true);
|
|
169
|
+
const res = await cmsApi<any>(`/collections/${collectionSlug}/${documentId}`, {
|
|
170
|
+
method: 'PUT',
|
|
171
|
+
body: JSON.stringify({ ...values, ...seoData, status: 'DRAFT' }),
|
|
172
|
+
});
|
|
173
|
+
if (res.error) {
|
|
174
|
+
toast.error(res.error);
|
|
175
|
+
} else {
|
|
176
|
+
toast.success('Document unpublished');
|
|
177
|
+
setDocStatus('DRAFT');
|
|
178
|
+
setInitialValues(values);
|
|
179
|
+
setInitialSeoData(seoData);
|
|
180
|
+
}
|
|
181
|
+
setSaving(false);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function handleDuplicate() {
|
|
185
|
+
if (isNew || !documentId) return;
|
|
186
|
+
const res = await cmsApi<any>(`/collections/${collectionSlug}`, {
|
|
187
|
+
method: 'POST',
|
|
188
|
+
body: JSON.stringify({
|
|
189
|
+
...values,
|
|
190
|
+
title: values.title ? `${values.title} (Copy)` : undefined,
|
|
191
|
+
slug: values.slug ? `${values.slug}-copy` : undefined,
|
|
192
|
+
}),
|
|
193
|
+
});
|
|
194
|
+
if (res.error) {
|
|
195
|
+
toast.error(res.error);
|
|
196
|
+
} else {
|
|
197
|
+
toast.success('Document duplicated');
|
|
198
|
+
const newId = res.data?.id;
|
|
199
|
+
if (newId && onNavigate) {
|
|
200
|
+
onNavigate(`/${collectionSlug}/${newId}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const mainFields = fields.filter(
|
|
206
|
+
(f: any) => !['status', 'publishedAt', 'featured', 'featuredImage'].includes(f.name),
|
|
207
|
+
);
|
|
208
|
+
const sidebarFields = fields.filter((f: any) =>
|
|
209
|
+
['publishedAt', 'featured', 'featuredImage'].includes(f.name),
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
const contentField = fields.find(
|
|
213
|
+
(f: any) => f.type === 'richText' || f.name === 'content' || f.name === 'body',
|
|
214
|
+
);
|
|
215
|
+
const htmlContent = contentField ? (values[contentField.name] ?? '') : '';
|
|
216
|
+
const docSlug = values.slug ?? '';
|
|
217
|
+
|
|
218
|
+
const statusColor = docStatus === 'PUBLISHED' ? 'bg-green-100 text-green-800'
|
|
219
|
+
: docStatus === 'SCHEDULED' ? 'bg-blue-100 text-blue-800'
|
|
220
|
+
: 'bg-yellow-100 text-yellow-800';
|
|
221
|
+
|
|
222
|
+
if (loading) {
|
|
223
|
+
return (
|
|
224
|
+
<div className="flex items-center justify-center min-h-[300px]">
|
|
225
|
+
<Loader2 className="w-8 h-8 animate-spin text-gray-400" />
|
|
226
|
+
</div>
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return (
|
|
231
|
+
<div className="space-y-4">
|
|
232
|
+
<div className="flex items-center justify-between">
|
|
233
|
+
<div className="flex items-center gap-3">
|
|
234
|
+
<h1 className="text-2xl font-bold">{displayTitle}</h1>
|
|
235
|
+
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${statusColor}`}>
|
|
236
|
+
{docStatus}
|
|
237
|
+
</span>
|
|
238
|
+
{documentId && <PresenceIndicator documentId={documentId} />}
|
|
239
|
+
{isDirty && (
|
|
240
|
+
<span className="text-xs text-amber-600 font-medium">Unsaved changes</span>
|
|
241
|
+
)}
|
|
242
|
+
</div>
|
|
243
|
+
<div className="flex items-center gap-2">
|
|
244
|
+
<button
|
|
245
|
+
onClick={() => setShowPreview((v) => !v)}
|
|
246
|
+
className="inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm text-[var(--muted-foreground)] hover:bg-[var(--muted)] hover:text-[var(--foreground)]"
|
|
247
|
+
title={showPreview ? 'Hide preview' : 'Show preview'}
|
|
248
|
+
>
|
|
249
|
+
{showPreview ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
|
250
|
+
Preview
|
|
251
|
+
</button>
|
|
252
|
+
{!isNew && (
|
|
253
|
+
<>
|
|
254
|
+
<button
|
|
255
|
+
onClick={() => setShowVersions(true)}
|
|
256
|
+
className="inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm text-[var(--muted-foreground)] hover:bg-[var(--muted)] hover:text-[var(--foreground)]"
|
|
257
|
+
title="Version history"
|
|
258
|
+
>
|
|
259
|
+
<Clock className="h-4 w-4" />
|
|
260
|
+
History
|
|
261
|
+
</button>
|
|
262
|
+
<button
|
|
263
|
+
onClick={handleDuplicate}
|
|
264
|
+
className="inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm text-[var(--muted-foreground)] hover:bg-[var(--muted)] hover:text-[var(--foreground)]"
|
|
265
|
+
title="Duplicate this document"
|
|
266
|
+
>
|
|
267
|
+
<Copy className="h-4 w-4" />
|
|
268
|
+
Duplicate
|
|
269
|
+
</button>
|
|
270
|
+
</>
|
|
271
|
+
)}
|
|
272
|
+
</div>
|
|
273
|
+
</div>
|
|
274
|
+
|
|
275
|
+
<div className={showPreview ? 'grid grid-cols-2 gap-6' : ''}>
|
|
276
|
+
<div className="space-y-6">
|
|
277
|
+
{fields.length === 0 ? (
|
|
278
|
+
<div className="flex flex-col items-center justify-center py-16 text-center rounded-lg border border-[var(--border)]">
|
|
279
|
+
<div className="w-12 h-12 rounded-full bg-gray-100 flex items-center justify-center mb-4">
|
|
280
|
+
<AlertTriangle className="w-6 h-6 text-gray-400" />
|
|
281
|
+
</div>
|
|
282
|
+
<h3 className="text-sm font-medium text-gray-900 mb-1">Collection schema not found</h3>
|
|
283
|
+
<p className="text-sm text-gray-500">No fields are defined for this collection.</p>
|
|
284
|
+
</div>
|
|
285
|
+
) : (
|
|
286
|
+
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
|
287
|
+
<div className="space-y-6 lg:col-span-2">
|
|
288
|
+
{mainFields.map((field: any) => (
|
|
289
|
+
<FieldRenderer
|
|
290
|
+
key={field.name}
|
|
291
|
+
field={field}
|
|
292
|
+
value={values[field.name]}
|
|
293
|
+
onChange={(val) => handleFieldChange(field.name, val)}
|
|
294
|
+
/>
|
|
295
|
+
))}
|
|
296
|
+
</div>
|
|
297
|
+
|
|
298
|
+
<div className="space-y-6">
|
|
299
|
+
<div className="rounded-lg border border-[var(--border)] bg-[var(--card)] p-4">
|
|
300
|
+
<h3 className="mb-4 font-semibold">Status</h3>
|
|
301
|
+
<div className="space-y-3">
|
|
302
|
+
{!isNew && docStatus === 'DRAFT' && (
|
|
303
|
+
<Button variant="primary" className="w-full" onClick={handlePublish} loading={saving}>
|
|
304
|
+
Publish
|
|
305
|
+
</Button>
|
|
306
|
+
)}
|
|
307
|
+
{!isNew && docStatus === 'PUBLISHED' && (
|
|
308
|
+
<Button variant="secondary" className="w-full" onClick={handleUnpublish} loading={saving}>
|
|
309
|
+
Unpublish
|
|
310
|
+
</Button>
|
|
311
|
+
)}
|
|
312
|
+
</div>
|
|
313
|
+
</div>
|
|
314
|
+
|
|
315
|
+
{sidebarFields.length > 0 && (
|
|
316
|
+
<div className="rounded-lg border border-[var(--border)] bg-[var(--card)] p-4">
|
|
317
|
+
<h3 className="mb-4 font-semibold">Metadata</h3>
|
|
318
|
+
{sidebarFields.map((field: any) => (
|
|
319
|
+
<div key={field.name} className="mb-4">
|
|
320
|
+
<FieldRenderer
|
|
321
|
+
field={field}
|
|
322
|
+
value={values[field.name]}
|
|
323
|
+
onChange={(val) => handleFieldChange(field.name, val)}
|
|
324
|
+
/>
|
|
325
|
+
</div>
|
|
326
|
+
))}
|
|
327
|
+
</div>
|
|
328
|
+
)}
|
|
329
|
+
|
|
330
|
+
<SEOPanel
|
|
331
|
+
title={values[useAsTitleField] ?? values.title ?? ''}
|
|
332
|
+
slug={docSlug}
|
|
333
|
+
content={htmlContent}
|
|
334
|
+
seoData={seoData}
|
|
335
|
+
onChange={setSeoData}
|
|
336
|
+
siteUrl={config?.siteUrl}
|
|
337
|
+
/>
|
|
338
|
+
</div>
|
|
339
|
+
</div>
|
|
340
|
+
)}
|
|
341
|
+
|
|
342
|
+
<div className="sticky bottom-0 flex items-center justify-end gap-3 border-t border-[var(--border)] bg-[var(--background)] px-4 py-3">
|
|
343
|
+
<Button variant="secondary" onClick={() => onNavigate?.(`/${collectionSlug}`)}>Cancel</Button>
|
|
344
|
+
<Button variant="primary" loading={saving} onClick={handleSave} data-shortcut="save">
|
|
345
|
+
<Save className="h-4 w-4 mr-1.5" />
|
|
346
|
+
{isNew ? 'Create' : 'Save Changes'}
|
|
347
|
+
</Button>
|
|
348
|
+
</div>
|
|
349
|
+
</div>
|
|
350
|
+
|
|
351
|
+
{showPreview && (
|
|
352
|
+
<LivePreview
|
|
353
|
+
collection={collectionSlug}
|
|
354
|
+
documentId={documentId}
|
|
355
|
+
previewUrl={previewUrl}
|
|
356
|
+
values={values}
|
|
357
|
+
onClose={() => setShowPreview(false)}
|
|
358
|
+
/>
|
|
359
|
+
)}
|
|
360
|
+
</div>
|
|
361
|
+
|
|
362
|
+
{!isNew && documentId && (
|
|
363
|
+
<VersionHistory
|
|
364
|
+
collectionSlug={collectionSlug}
|
|
365
|
+
documentId={documentId}
|
|
366
|
+
open={showVersions}
|
|
367
|
+
onClose={() => setShowVersions(false)}
|
|
368
|
+
onRestore={(data) => {
|
|
369
|
+
const restoredData = data as Record<string, any>;
|
|
370
|
+
setValues(restoredData);
|
|
371
|
+
setInitialValues(restoredData);
|
|
372
|
+
}}
|
|
373
|
+
/>
|
|
374
|
+
)}
|
|
375
|
+
</div>
|
|
376
|
+
);
|
|
377
|
+
}
|