@actuate-media/cms-admin 0.1.4 → 0.2.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/LICENSE +21 -21
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +16 -10
- 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.map +1 -1
- package/dist/views/Dashboard.js +8 -3
- 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 +207 -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,207 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { FileText, 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
|
+
recentDocuments: { id: number; title: string; status: string; date: string; author: string }[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface HealthData {
|
|
14
|
+
status: 'healthy' | 'degraded';
|
|
15
|
+
version: string;
|
|
16
|
+
secretConfigured: boolean;
|
|
17
|
+
models: Record<string, boolean>;
|
|
18
|
+
databaseConnected: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface DashboardProps {
|
|
22
|
+
onNavigate?: (path: string) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function Dashboard({ onNavigate }: DashboardProps) {
|
|
26
|
+
const nav = (path: string) => onNavigate?.(path);
|
|
27
|
+
const { data, loading, error, exhausted, refetch } = useApiData<DashboardStats>('/stats');
|
|
28
|
+
const { data: health } = useApiData<HealthData>('/health');
|
|
29
|
+
|
|
30
|
+
const totalPosts = data?.totalDocuments ?? 0;
|
|
31
|
+
const totalMedia = data?.totalMedia ?? 0;
|
|
32
|
+
const totalUsers = data?.totalUsers ?? 0;
|
|
33
|
+
const recentPosts = data?.recentDocuments ?? [];
|
|
34
|
+
|
|
35
|
+
if (loading) {
|
|
36
|
+
return (
|
|
37
|
+
<div className="p-3 pr-6 sm:p-4 sm:pr-8 flex items-center justify-center h-64">
|
|
38
|
+
<Loader2 className="w-6 h-6 animate-spin text-blue-600" />
|
|
39
|
+
</div>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<div className="p-3 pr-6 sm:p-4 sm:pr-8">
|
|
45
|
+
{health && health.status === 'degraded' && (
|
|
46
|
+
<div className="mb-4 flex items-center gap-3 rounded-lg border border-blue-200 bg-blue-50 p-3">
|
|
47
|
+
<Database className="w-5 h-5 text-blue-600 shrink-0" />
|
|
48
|
+
<div className="flex-1">
|
|
49
|
+
<span className="text-sm font-medium text-blue-900">Database Setup Required</span>
|
|
50
|
+
<p className="text-xs text-blue-700 mt-0.5">
|
|
51
|
+
{!health.databaseConnected
|
|
52
|
+
? 'Cannot connect to the database. Check your DATABASE_URL environment variable.'
|
|
53
|
+
: !health.secretConfigured
|
|
54
|
+
? '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 to create the required tables.`
|
|
56
|
+
}
|
|
57
|
+
</p>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
)}
|
|
61
|
+
|
|
62
|
+
{error && exhausted && (
|
|
63
|
+
<div className="mb-4 flex items-center gap-3 rounded-lg border border-amber-200 bg-amber-50 p-3">
|
|
64
|
+
<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. This is normal if your database hasn't been set up yet.</span>
|
|
66
|
+
<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
|
+
</div>
|
|
68
|
+
)}
|
|
69
|
+
|
|
70
|
+
<div className="mb-4 sm:mb-6">
|
|
71
|
+
<h1 className="text-xl sm:text-2xl font-semibold text-gray-900 mb-1">Dashboard</h1>
|
|
72
|
+
<p className="text-sm text-gray-600">Welcome back! Here's what's happening.</p>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4 mb-4 sm:mb-6">
|
|
76
|
+
<div className="bg-white rounded-lg border border-gray-200 p-3 sm:p-4">
|
|
77
|
+
<div className="flex items-center justify-between">
|
|
78
|
+
<div>
|
|
79
|
+
<p className="text-xs text-gray-600 mb-1">Total Posts</p>
|
|
80
|
+
<p className="text-xl sm:text-2xl font-semibold text-gray-900">{totalPosts}</p>
|
|
81
|
+
<p className="text-xs text-gray-400 mt-1">—</p>
|
|
82
|
+
</div>
|
|
83
|
+
<div className="w-8 h-8 sm:w-10 sm:h-10 bg-blue-100 rounded-lg flex items-center justify-center">
|
|
84
|
+
<FileText className="w-4 h-4 sm:w-5 sm:h-5 text-blue-600" />
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
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" />
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
|
|
129
|
+
<div className="grid grid-cols-1 lg:grid-cols-12 gap-3 sm:gap-4">
|
|
130
|
+
<div className="lg:col-span-8 bg-white rounded-lg border border-gray-200">
|
|
131
|
+
<div className="p-3 sm:p-4 border-b border-gray-200">
|
|
132
|
+
<h2 className="text-sm sm:text-base font-semibold text-gray-900">Recent Posts</h2>
|
|
133
|
+
</div>
|
|
134
|
+
<div className="divide-y divide-gray-200">
|
|
135
|
+
{recentPosts.length === 0 ? (
|
|
136
|
+
<div className="p-8 text-center">
|
|
137
|
+
<p className="text-sm text-gray-500 mb-2">No posts yet</p>
|
|
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>
|
|
139
|
+
</div>
|
|
140
|
+
) : recentPosts.map((post) => (
|
|
141
|
+
<div key={post.id} className="p-3 sm:p-4 hover:bg-gray-50 transition-colors">
|
|
142
|
+
<div className="flex items-start justify-between gap-3">
|
|
143
|
+
<div className="flex-1 min-w-0">
|
|
144
|
+
<h3 className="text-sm font-medium text-gray-900 mb-1 truncate">{post.title}</h3>
|
|
145
|
+
<div className="flex flex-wrap items-center gap-2 sm:gap-3 text-xs text-gray-600">
|
|
146
|
+
<span className="flex items-center gap-1">
|
|
147
|
+
<User className="w-3 h-3" />
|
|
148
|
+
<span className="hidden sm:inline">{post.author}</span>
|
|
149
|
+
</span>
|
|
150
|
+
<span className="flex items-center gap-1">
|
|
151
|
+
<Calendar className="w-3 h-3" />
|
|
152
|
+
{post.date}
|
|
153
|
+
</span>
|
|
154
|
+
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${
|
|
155
|
+
post.status === 'Published' ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
|
|
156
|
+
}`}>
|
|
157
|
+
{post.status}
|
|
158
|
+
</span>
|
|
159
|
+
</div>
|
|
160
|
+
</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
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
))}
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
|
|
173
|
+
<div className="lg:col-span-4 bg-white rounded-lg border border-gray-200">
|
|
174
|
+
<div className="p-3 sm:p-4 border-b border-gray-200">
|
|
175
|
+
<h2 className="text-sm sm:text-base font-semibold text-gray-900">Quick Actions</h2>
|
|
176
|
+
</div>
|
|
177
|
+
<div className="p-3 sm:p-4 space-y-2">
|
|
178
|
+
<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">
|
|
179
|
+
<div className="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center group-hover:bg-blue-200 transition-colors">
|
|
180
|
+
<FileText className="w-4 h-4 text-blue-600" />
|
|
181
|
+
</div>
|
|
182
|
+
<span className="text-sm font-medium text-gray-900">New Post</span>
|
|
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>
|
|
202
|
+
</div>
|
|
203
|
+
</div>
|
|
204
|
+
</div>
|
|
205
|
+
</div>
|
|
206
|
+
);
|
|
207
|
+
}
|
|
@@ -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
|
+
}
|