@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,427 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
ChevronRight, ChevronDown, FolderOpen, Folder as FolderIcon,
|
|
5
|
+
Plus, MoreHorizontal, Pencil, Trash2, FolderPlus, Clock,
|
|
6
|
+
Inbox, LayoutGrid, Loader2,
|
|
7
|
+
} from 'lucide-react';
|
|
8
|
+
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
9
|
+
import { toast } from 'sonner';
|
|
10
|
+
import { cmsApi } from '../lib/api.js';
|
|
11
|
+
|
|
12
|
+
export interface FolderNode {
|
|
13
|
+
id: string;
|
|
14
|
+
name: string;
|
|
15
|
+
scope: string;
|
|
16
|
+
parentId: string | null;
|
|
17
|
+
position: number;
|
|
18
|
+
children: FolderNode[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type SmartFolder = 'all' | 'recent' | 'uncategorized';
|
|
22
|
+
|
|
23
|
+
export type FolderSelection =
|
|
24
|
+
| { type: 'smart'; smart: SmartFolder }
|
|
25
|
+
| { type: 'folder'; folderId: string };
|
|
26
|
+
|
|
27
|
+
export interface FolderTreeProps {
|
|
28
|
+
scope: string;
|
|
29
|
+
selected: FolderSelection;
|
|
30
|
+
onSelect: (selection: FolderSelection) => void;
|
|
31
|
+
itemCounts?: Record<string, number>;
|
|
32
|
+
totalCount?: number;
|
|
33
|
+
recentCount?: number;
|
|
34
|
+
uncategorizedCount?: number;
|
|
35
|
+
onDropItem?: (itemId: string, folderId: string | null) => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function FolderTree({
|
|
39
|
+
scope,
|
|
40
|
+
selected,
|
|
41
|
+
onSelect,
|
|
42
|
+
itemCounts,
|
|
43
|
+
totalCount,
|
|
44
|
+
recentCount,
|
|
45
|
+
uncategorizedCount,
|
|
46
|
+
onDropItem,
|
|
47
|
+
}: FolderTreeProps) {
|
|
48
|
+
const [folders, setFolders] = useState<FolderNode[]>([]);
|
|
49
|
+
const [loading, setLoading] = useState(true);
|
|
50
|
+
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
|
51
|
+
const [editingId, setEditingId] = useState<string | null>(null);
|
|
52
|
+
const [editName, setEditName] = useState('');
|
|
53
|
+
const [contextMenu, setContextMenu] = useState<{ id: string; x: number; y: number } | null>(null);
|
|
54
|
+
const [creatingIn, setCreatingIn] = useState<string | null | false>(false);
|
|
55
|
+
const [newFolderName, setNewFolderName] = useState('');
|
|
56
|
+
const newFolderInputRef = useRef<HTMLInputElement>(null);
|
|
57
|
+
const editInputRef = useRef<HTMLInputElement>(null);
|
|
58
|
+
const [dragOverId, setDragOverId] = useState<string | null>(null);
|
|
59
|
+
|
|
60
|
+
const fetchFolders = useCallback(async () => {
|
|
61
|
+
const res = await cmsApi<FolderNode[]>(`/folders?scope=${scope}`);
|
|
62
|
+
if (res.data) setFolders(res.data);
|
|
63
|
+
setLoading(false);
|
|
64
|
+
}, [scope]);
|
|
65
|
+
|
|
66
|
+
useEffect(() => { fetchFolders(); }, [fetchFolders]);
|
|
67
|
+
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
if (creatingIn !== false && newFolderInputRef.current) {
|
|
70
|
+
newFolderInputRef.current.focus();
|
|
71
|
+
}
|
|
72
|
+
}, [creatingIn]);
|
|
73
|
+
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (editingId && editInputRef.current) {
|
|
76
|
+
editInputRef.current.focus();
|
|
77
|
+
editInputRef.current.select();
|
|
78
|
+
}
|
|
79
|
+
}, [editingId]);
|
|
80
|
+
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
const handleClick = () => setContextMenu(null);
|
|
83
|
+
document.addEventListener('click', handleClick);
|
|
84
|
+
return () => document.removeEventListener('click', handleClick);
|
|
85
|
+
}, []);
|
|
86
|
+
|
|
87
|
+
const toggleExpand = (id: string) => {
|
|
88
|
+
setExpanded(prev => {
|
|
89
|
+
const next = new Set(prev);
|
|
90
|
+
if (next.has(id)) next.delete(id);
|
|
91
|
+
else next.add(id);
|
|
92
|
+
return next;
|
|
93
|
+
});
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const handleCreate = async (parentId: string | null) => {
|
|
97
|
+
if (!newFolderName.trim()) {
|
|
98
|
+
setCreatingIn(false);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const res = await cmsApi('/folders', {
|
|
102
|
+
method: 'POST',
|
|
103
|
+
body: JSON.stringify({ name: newFolderName.trim(), scope, parentId }),
|
|
104
|
+
});
|
|
105
|
+
if (res.error) {
|
|
106
|
+
toast.error(res.error);
|
|
107
|
+
} else {
|
|
108
|
+
toast.success('Folder created');
|
|
109
|
+
if (parentId) setExpanded(prev => new Set(prev).add(parentId));
|
|
110
|
+
fetchFolders();
|
|
111
|
+
}
|
|
112
|
+
setNewFolderName('');
|
|
113
|
+
setCreatingIn(false);
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const handleRename = async (id: string) => {
|
|
117
|
+
if (!editName.trim()) {
|
|
118
|
+
setEditingId(null);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const res = await cmsApi(`/folders/${id}`, {
|
|
122
|
+
method: 'PUT',
|
|
123
|
+
body: JSON.stringify({ name: editName.trim() }),
|
|
124
|
+
});
|
|
125
|
+
if (res.error) toast.error(res.error);
|
|
126
|
+
else fetchFolders();
|
|
127
|
+
setEditingId(null);
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const handleDelete = async (id: string) => {
|
|
131
|
+
const res = await cmsApi(`/folders/${id}`, { method: 'DELETE' });
|
|
132
|
+
if (res.error) toast.error(res.error);
|
|
133
|
+
else {
|
|
134
|
+
toast.success('Folder deleted');
|
|
135
|
+
if (selected.type === 'folder' && selected.folderId === id) {
|
|
136
|
+
onSelect({ type: 'smart', smart: 'all' });
|
|
137
|
+
}
|
|
138
|
+
fetchFolders();
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const isSelected = (sel: FolderSelection) => {
|
|
143
|
+
if (selected.type !== sel.type) return false;
|
|
144
|
+
if (sel.type === 'smart') return (selected as any).smart === sel.smart;
|
|
145
|
+
return (selected as any).folderId === (sel as any).folderId;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const handleDragOver = (e: React.DragEvent, folderId: string | null) => {
|
|
149
|
+
e.preventDefault();
|
|
150
|
+
e.stopPropagation();
|
|
151
|
+
setDragOverId(folderId);
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const handleDragLeave = () => {
|
|
155
|
+
setDragOverId(null);
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const handleDrop = (e: React.DragEvent, folderId: string | null) => {
|
|
159
|
+
e.preventDefault();
|
|
160
|
+
e.stopPropagation();
|
|
161
|
+
setDragOverId(null);
|
|
162
|
+
const itemId = e.dataTransfer.getData('text/actuate-item-id');
|
|
163
|
+
if (itemId && onDropItem) {
|
|
164
|
+
onDropItem(itemId, folderId);
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const countForFolder = (id: string): number | undefined => itemCounts?.[id];
|
|
169
|
+
|
|
170
|
+
const renderFolder = (folder: FolderNode, depth: number) => {
|
|
171
|
+
const isExpanded = expanded.has(folder.id);
|
|
172
|
+
const hasChildren = folder.children.length > 0;
|
|
173
|
+
const isActive = isSelected({ type: 'folder', folderId: folder.id });
|
|
174
|
+
const isDragOver = dragOverId === folder.id;
|
|
175
|
+
const count = countForFolder(folder.id);
|
|
176
|
+
|
|
177
|
+
return (
|
|
178
|
+
<div key={folder.id}>
|
|
179
|
+
<div
|
|
180
|
+
className={`group flex items-center gap-1 px-2 py-1.5 rounded-md cursor-pointer text-sm transition-colors ${
|
|
181
|
+
isActive
|
|
182
|
+
? 'bg-blue-50 text-blue-700 font-medium'
|
|
183
|
+
: isDragOver
|
|
184
|
+
? 'bg-blue-100 ring-2 ring-blue-300'
|
|
185
|
+
: 'text-gray-700 hover:bg-gray-100'
|
|
186
|
+
}`}
|
|
187
|
+
style={{ paddingLeft: `${8 + depth * 16}px` }}
|
|
188
|
+
onClick={() => onSelect({ type: 'folder', folderId: folder.id })}
|
|
189
|
+
onContextMenu={(e) => {
|
|
190
|
+
e.preventDefault();
|
|
191
|
+
setContextMenu({ id: folder.id, x: e.clientX, y: e.clientY });
|
|
192
|
+
}}
|
|
193
|
+
onDragOver={(e) => handleDragOver(e, folder.id)}
|
|
194
|
+
onDragLeave={handleDragLeave}
|
|
195
|
+
onDrop={(e) => handleDrop(e, folder.id)}
|
|
196
|
+
>
|
|
197
|
+
<button
|
|
198
|
+
type="button"
|
|
199
|
+
className={`p-0.5 rounded hover:bg-gray-200 transition-colors ${hasChildren ? '' : 'invisible'}`}
|
|
200
|
+
onClick={(e) => { e.stopPropagation(); toggleExpand(folder.id); }}
|
|
201
|
+
>
|
|
202
|
+
{isExpanded ? (
|
|
203
|
+
<ChevronDown className="w-3.5 h-3.5" />
|
|
204
|
+
) : (
|
|
205
|
+
<ChevronRight className="w-3.5 h-3.5" />
|
|
206
|
+
)}
|
|
207
|
+
</button>
|
|
208
|
+
|
|
209
|
+
{isExpanded ? (
|
|
210
|
+
<FolderOpen className="w-4 h-4 text-blue-500 shrink-0" />
|
|
211
|
+
) : (
|
|
212
|
+
<FolderIcon className="w-4 h-4 text-gray-400 shrink-0" />
|
|
213
|
+
)}
|
|
214
|
+
|
|
215
|
+
{editingId === folder.id ? (
|
|
216
|
+
<input
|
|
217
|
+
ref={editInputRef}
|
|
218
|
+
type="text"
|
|
219
|
+
value={editName}
|
|
220
|
+
onChange={(e) => setEditName(e.target.value)}
|
|
221
|
+
onBlur={() => handleRename(folder.id)}
|
|
222
|
+
onKeyDown={(e) => {
|
|
223
|
+
if (e.key === 'Enter') handleRename(folder.id);
|
|
224
|
+
if (e.key === 'Escape') setEditingId(null);
|
|
225
|
+
}}
|
|
226
|
+
className="flex-1 min-w-0 text-sm border border-blue-300 rounded px-1 py-0 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
227
|
+
onClick={(e) => e.stopPropagation()}
|
|
228
|
+
/>
|
|
229
|
+
) : (
|
|
230
|
+
<span className="flex-1 min-w-0 truncate">{folder.name}</span>
|
|
231
|
+
)}
|
|
232
|
+
|
|
233
|
+
{count !== undefined && !editingId && (
|
|
234
|
+
<span className="text-xs text-gray-400 tabular-nums">{count}</span>
|
|
235
|
+
)}
|
|
236
|
+
|
|
237
|
+
<button
|
|
238
|
+
type="button"
|
|
239
|
+
className="p-0.5 rounded opacity-0 group-hover:opacity-100 hover:bg-gray-200 transition-all"
|
|
240
|
+
onClick={(e) => {
|
|
241
|
+
e.stopPropagation();
|
|
242
|
+
setContextMenu({ id: folder.id, x: e.clientX, y: e.clientY });
|
|
243
|
+
}}
|
|
244
|
+
>
|
|
245
|
+
<MoreHorizontal className="w-3.5 h-3.5 text-gray-500" />
|
|
246
|
+
</button>
|
|
247
|
+
</div>
|
|
248
|
+
|
|
249
|
+
{isExpanded && (
|
|
250
|
+
<div>
|
|
251
|
+
{folder.children.map(child => renderFolder(child, depth + 1))}
|
|
252
|
+
{creatingIn === folder.id && (
|
|
253
|
+
<div className="flex items-center gap-1 px-2 py-1" style={{ paddingLeft: `${24 + (depth + 1) * 16}px` }}>
|
|
254
|
+
<FolderIcon className="w-4 h-4 text-gray-400 shrink-0" />
|
|
255
|
+
<input
|
|
256
|
+
ref={newFolderInputRef}
|
|
257
|
+
type="text"
|
|
258
|
+
value={newFolderName}
|
|
259
|
+
onChange={(e) => setNewFolderName(e.target.value)}
|
|
260
|
+
onBlur={() => handleCreate(folder.id)}
|
|
261
|
+
onKeyDown={(e) => {
|
|
262
|
+
if (e.key === 'Enter') handleCreate(folder.id);
|
|
263
|
+
if (e.key === 'Escape') { setCreatingIn(false); setNewFolderName(''); }
|
|
264
|
+
}}
|
|
265
|
+
placeholder="Folder name..."
|
|
266
|
+
className="flex-1 min-w-0 text-sm border border-blue-300 rounded px-1 py-0 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
267
|
+
/>
|
|
268
|
+
</div>
|
|
269
|
+
)}
|
|
270
|
+
</div>
|
|
271
|
+
)}
|
|
272
|
+
</div>
|
|
273
|
+
);
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
if (loading) {
|
|
277
|
+
return (
|
|
278
|
+
<div className="flex items-center justify-center py-8">
|
|
279
|
+
<Loader2 className="w-5 h-5 animate-spin text-gray-400" />
|
|
280
|
+
</div>
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return (
|
|
285
|
+
<div className="flex flex-col h-full">
|
|
286
|
+
<div className="space-y-0.5 px-1 py-2">
|
|
287
|
+
<button
|
|
288
|
+
type="button"
|
|
289
|
+
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm transition-colors ${
|
|
290
|
+
isSelected({ type: 'smart', smart: 'all' })
|
|
291
|
+
? 'bg-blue-50 text-blue-700 font-medium'
|
|
292
|
+
: 'text-gray-700 hover:bg-gray-100'
|
|
293
|
+
}`}
|
|
294
|
+
onClick={() => onSelect({ type: 'smart', smart: 'all' })}
|
|
295
|
+
onDragOver={(e) => handleDragOver(e, null)}
|
|
296
|
+
onDragLeave={handleDragLeave}
|
|
297
|
+
onDrop={(e) => handleDrop(e, null)}
|
|
298
|
+
>
|
|
299
|
+
<LayoutGrid className="w-4 h-4 shrink-0" />
|
|
300
|
+
<span className="flex-1 text-left">All</span>
|
|
301
|
+
{totalCount !== undefined && <span className="text-xs text-gray-400 tabular-nums">{totalCount}</span>}
|
|
302
|
+
</button>
|
|
303
|
+
|
|
304
|
+
<button
|
|
305
|
+
type="button"
|
|
306
|
+
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm transition-colors ${
|
|
307
|
+
isSelected({ type: 'smart', smart: 'recent' })
|
|
308
|
+
? 'bg-blue-50 text-blue-700 font-medium'
|
|
309
|
+
: 'text-gray-700 hover:bg-gray-100'
|
|
310
|
+
}`}
|
|
311
|
+
onClick={() => onSelect({ type: 'smart', smart: 'recent' })}
|
|
312
|
+
>
|
|
313
|
+
<Clock className="w-4 h-4 shrink-0" />
|
|
314
|
+
<span className="flex-1 text-left">Recent</span>
|
|
315
|
+
{recentCount !== undefined && <span className="text-xs text-gray-400 tabular-nums">{recentCount}</span>}
|
|
316
|
+
</button>
|
|
317
|
+
|
|
318
|
+
<button
|
|
319
|
+
type="button"
|
|
320
|
+
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm transition-colors ${
|
|
321
|
+
isSelected({ type: 'smart', smart: 'uncategorized' })
|
|
322
|
+
? 'bg-blue-50 text-blue-700 font-medium'
|
|
323
|
+
: 'text-gray-700 hover:bg-gray-100'
|
|
324
|
+
}`}
|
|
325
|
+
onClick={() => onSelect({ type: 'smart', smart: 'uncategorized' })}
|
|
326
|
+
>
|
|
327
|
+
<Inbox className="w-4 h-4 shrink-0" />
|
|
328
|
+
<span className="flex-1 text-left">Uncategorized</span>
|
|
329
|
+
{uncategorizedCount !== undefined && <span className="text-xs text-gray-400 tabular-nums">{uncategorizedCount}</span>}
|
|
330
|
+
</button>
|
|
331
|
+
</div>
|
|
332
|
+
|
|
333
|
+
<div className="border-t border-gray-200 my-1" />
|
|
334
|
+
|
|
335
|
+
<div className="flex-1 overflow-y-auto px-1 py-1 space-y-0.5">
|
|
336
|
+
{folders.map(folder => renderFolder(folder, 0))}
|
|
337
|
+
|
|
338
|
+
{creatingIn === null && (
|
|
339
|
+
<div className="flex items-center gap-1 px-2 py-1">
|
|
340
|
+
<FolderIcon className="w-4 h-4 text-gray-400 shrink-0 ml-5" />
|
|
341
|
+
<input
|
|
342
|
+
ref={newFolderInputRef}
|
|
343
|
+
type="text"
|
|
344
|
+
value={newFolderName}
|
|
345
|
+
onChange={(e) => setNewFolderName(e.target.value)}
|
|
346
|
+
onBlur={() => handleCreate(null)}
|
|
347
|
+
onKeyDown={(e) => {
|
|
348
|
+
if (e.key === 'Enter') handleCreate(null);
|
|
349
|
+
if (e.key === 'Escape') { setCreatingIn(false); setNewFolderName(''); }
|
|
350
|
+
}}
|
|
351
|
+
placeholder="Folder name..."
|
|
352
|
+
className="flex-1 min-w-0 text-sm border border-blue-300 rounded px-1 py-0 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
353
|
+
/>
|
|
354
|
+
</div>
|
|
355
|
+
)}
|
|
356
|
+
</div>
|
|
357
|
+
|
|
358
|
+
<div className="border-t border-gray-200 p-2">
|
|
359
|
+
<button
|
|
360
|
+
type="button"
|
|
361
|
+
onClick={() => { setCreatingIn(null); setNewFolderName(''); }}
|
|
362
|
+
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-gray-600 hover:bg-gray-100 transition-colors"
|
|
363
|
+
>
|
|
364
|
+
<Plus className="w-4 h-4" />
|
|
365
|
+
New Folder
|
|
366
|
+
</button>
|
|
367
|
+
</div>
|
|
368
|
+
|
|
369
|
+
{contextMenu && (
|
|
370
|
+
<div
|
|
371
|
+
className="fixed z-50 bg-white rounded-lg border border-gray-200 shadow-lg py-1 min-w-[160px]"
|
|
372
|
+
style={{ left: contextMenu.x, top: contextMenu.y }}
|
|
373
|
+
>
|
|
374
|
+
<button
|
|
375
|
+
type="button"
|
|
376
|
+
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-100 transition-colors"
|
|
377
|
+
onClick={() => {
|
|
378
|
+
const folder = findFolder(folders, contextMenu.id);
|
|
379
|
+
if (folder) {
|
|
380
|
+
setEditingId(folder.id);
|
|
381
|
+
setEditName(folder.name);
|
|
382
|
+
}
|
|
383
|
+
setContextMenu(null);
|
|
384
|
+
}}
|
|
385
|
+
>
|
|
386
|
+
<Pencil className="w-3.5 h-3.5" />
|
|
387
|
+
Rename
|
|
388
|
+
</button>
|
|
389
|
+
<button
|
|
390
|
+
type="button"
|
|
391
|
+
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-100 transition-colors"
|
|
392
|
+
onClick={() => {
|
|
393
|
+
setCreatingIn(contextMenu.id);
|
|
394
|
+
setNewFolderName('');
|
|
395
|
+
setExpanded(prev => new Set(prev).add(contextMenu.id));
|
|
396
|
+
setContextMenu(null);
|
|
397
|
+
}}
|
|
398
|
+
>
|
|
399
|
+
<FolderPlus className="w-3.5 h-3.5" />
|
|
400
|
+
New Subfolder
|
|
401
|
+
</button>
|
|
402
|
+
<div className="border-t border-gray-200 my-1" />
|
|
403
|
+
<button
|
|
404
|
+
type="button"
|
|
405
|
+
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm text-red-600 hover:bg-red-50 transition-colors"
|
|
406
|
+
onClick={() => {
|
|
407
|
+
handleDelete(contextMenu.id);
|
|
408
|
+
setContextMenu(null);
|
|
409
|
+
}}
|
|
410
|
+
>
|
|
411
|
+
<Trash2 className="w-3.5 h-3.5" />
|
|
412
|
+
Delete
|
|
413
|
+
</button>
|
|
414
|
+
</div>
|
|
415
|
+
)}
|
|
416
|
+
</div>
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function findFolder(nodes: FolderNode[], id: string): FolderNode | null {
|
|
421
|
+
for (const n of nodes) {
|
|
422
|
+
if (n.id === id) return n;
|
|
423
|
+
const found = findFolder(n.children, id);
|
|
424
|
+
if (found) return found;
|
|
425
|
+
}
|
|
426
|
+
return null;
|
|
427
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useRef, useEffect, useCallback } from 'react';
|
|
4
|
+
import { Monitor, Tablet, Smartphone, RefreshCw, ExternalLink, X } from 'lucide-react';
|
|
5
|
+
import { cmsApi } from '../lib/api.js';
|
|
6
|
+
|
|
7
|
+
interface LivePreviewProps {
|
|
8
|
+
collection: string;
|
|
9
|
+
documentId?: string;
|
|
10
|
+
previewUrl?: string;
|
|
11
|
+
values: Record<string, unknown>;
|
|
12
|
+
onClose: () => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type Viewport = 'desktop' | 'tablet' | 'mobile';
|
|
16
|
+
|
|
17
|
+
const VIEWPORT_WIDTHS: Record<Viewport, string> = {
|
|
18
|
+
desktop: '100%',
|
|
19
|
+
tablet: '768px',
|
|
20
|
+
mobile: '375px',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export function LivePreview({ collection, documentId, previewUrl, values, onClose }: LivePreviewProps) {
|
|
24
|
+
const iframeRef = useRef<HTMLIFrameElement>(null);
|
|
25
|
+
const [viewport, setViewport] = useState<Viewport>('desktop');
|
|
26
|
+
const [loading, setLoading] = useState(true);
|
|
27
|
+
const [previewSrc, setPreviewSrc] = useState<string | null>(null);
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (!documentId || !previewUrl) {
|
|
31
|
+
setPreviewSrc(null);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function fetchToken() {
|
|
36
|
+
const res = await cmsApi<{ token: string }>('/preview/token', {
|
|
37
|
+
method: 'POST',
|
|
38
|
+
body: JSON.stringify({ collection, documentId }),
|
|
39
|
+
});
|
|
40
|
+
if (res.data?.token) {
|
|
41
|
+
const sep = previewUrl!.includes('?') ? '&' : '?';
|
|
42
|
+
setPreviewSrc(`${previewUrl}${sep}preview=true#token=${res.data.token}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
fetchToken();
|
|
47
|
+
}, [collection, documentId, previewUrl]);
|
|
48
|
+
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (!iframeRef.current?.contentWindow || !previewSrc) return;
|
|
51
|
+
let targetOrigin: string;
|
|
52
|
+
try {
|
|
53
|
+
targetOrigin = new URL(previewSrc).origin;
|
|
54
|
+
} catch {
|
|
55
|
+
targetOrigin = window.location.origin;
|
|
56
|
+
}
|
|
57
|
+
iframeRef.current.contentWindow.postMessage(
|
|
58
|
+
{ type: 'actuate-preview-update', data: values },
|
|
59
|
+
targetOrigin,
|
|
60
|
+
);
|
|
61
|
+
}, [values, previewSrc]);
|
|
62
|
+
|
|
63
|
+
const handleRefresh = useCallback(() => {
|
|
64
|
+
if (iframeRef.current) {
|
|
65
|
+
setLoading(true);
|
|
66
|
+
iframeRef.current.src = iframeRef.current.src;
|
|
67
|
+
}
|
|
68
|
+
}, []);
|
|
69
|
+
|
|
70
|
+
const handleOpenExternal = useCallback(() => {
|
|
71
|
+
if (previewSrc) window.open(previewSrc, '_blank');
|
|
72
|
+
}, [previewSrc]);
|
|
73
|
+
|
|
74
|
+
if (!previewSrc) {
|
|
75
|
+
return (
|
|
76
|
+
<div className="flex flex-col items-center justify-center h-full bg-gray-50 rounded-lg border border-dashed border-gray-300 p-8">
|
|
77
|
+
<p className="text-sm text-gray-500 text-center">
|
|
78
|
+
{!documentId
|
|
79
|
+
? 'Save the document first to enable preview'
|
|
80
|
+
: 'Configure a preview URL in the collection settings to enable live preview'}
|
|
81
|
+
</p>
|
|
82
|
+
<button onClick={onClose} className="mt-4 text-xs text-gray-400 hover:text-gray-600">
|
|
83
|
+
Close Preview
|
|
84
|
+
</button>
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<div className="flex flex-col h-full border-l border-gray-200 bg-white">
|
|
91
|
+
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-200 bg-gray-50">
|
|
92
|
+
<div className="flex items-center gap-1">
|
|
93
|
+
{([['desktop', Monitor], ['tablet', Tablet], ['mobile', Smartphone]] as const).map(([vp, Icon]) => (
|
|
94
|
+
<button
|
|
95
|
+
key={vp}
|
|
96
|
+
onClick={() => setViewport(vp)}
|
|
97
|
+
className={`p-1.5 rounded ${viewport === vp ? 'bg-white shadow-sm text-blue-600' : 'text-gray-400 hover:text-gray-600'}`}
|
|
98
|
+
title={vp}
|
|
99
|
+
>
|
|
100
|
+
<Icon className="w-4 h-4" />
|
|
101
|
+
</button>
|
|
102
|
+
))}
|
|
103
|
+
</div>
|
|
104
|
+
<div className="flex items-center gap-1">
|
|
105
|
+
<button onClick={handleRefresh} className="p-1.5 rounded text-gray-400 hover:text-gray-600" title="Refresh">
|
|
106
|
+
<RefreshCw className="w-4 h-4" />
|
|
107
|
+
</button>
|
|
108
|
+
<button onClick={handleOpenExternal} className="p-1.5 rounded text-gray-400 hover:text-gray-600" title="Open in new tab">
|
|
109
|
+
<ExternalLink className="w-4 h-4" />
|
|
110
|
+
</button>
|
|
111
|
+
<button onClick={onClose} className="p-1.5 rounded text-gray-400 hover:text-gray-600" title="Close">
|
|
112
|
+
<X className="w-4 h-4" />
|
|
113
|
+
</button>
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
<div className="flex-1 overflow-auto flex justify-center bg-gray-100 p-4">
|
|
118
|
+
<div style={{ width: VIEWPORT_WIDTHS[viewport], maxWidth: '100%', transition: 'width 0.3s' }} className="relative">
|
|
119
|
+
{loading && (
|
|
120
|
+
<div className="absolute inset-0 flex items-center justify-center bg-white/80 z-10 rounded-lg">
|
|
121
|
+
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
|
|
122
|
+
</div>
|
|
123
|
+
)}
|
|
124
|
+
<iframe
|
|
125
|
+
ref={iframeRef}
|
|
126
|
+
src={previewSrc}
|
|
127
|
+
className="w-full h-full bg-white rounded-lg shadow-lg border border-gray-200"
|
|
128
|
+
style={{ minHeight: '600px' }}
|
|
129
|
+
onLoad={() => setLoading(false)}
|
|
130
|
+
title="Page Preview"
|
|
131
|
+
/>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
|
|
4
|
+
|
|
5
|
+
interface LocaleContextValue {
|
|
6
|
+
activeLocale: string;
|
|
7
|
+
setLocale: (locale: string) => void;
|
|
8
|
+
locales: Array<{ code: string; label: string; dir?: 'ltr' | 'rtl' }>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const LocaleContext = createContext<LocaleContextValue>({
|
|
12
|
+
activeLocale: 'en',
|
|
13
|
+
setLocale: () => {},
|
|
14
|
+
locales: [],
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export function useLocale() {
|
|
18
|
+
return useContext(LocaleContext);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const STORAGE_KEY = 'actuate-locale';
|
|
22
|
+
|
|
23
|
+
export interface LocaleProviderProps {
|
|
24
|
+
config: { i18n?: { defaultLocale: string; locales: Array<{ code: string; label: string; dir?: 'ltr' | 'rtl' }>; fallbackLocale?: string } };
|
|
25
|
+
children: ReactNode;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function LocaleProvider({ config, children }: LocaleProviderProps) {
|
|
29
|
+
const locales = config.i18n?.locales ?? [];
|
|
30
|
+
const defaultLocale = config.i18n?.defaultLocale ?? 'en';
|
|
31
|
+
|
|
32
|
+
const [activeLocale, setActiveLocale] = useState(defaultLocale);
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
const stored = localStorage.getItem(STORAGE_KEY);
|
|
36
|
+
if (stored && locales.some((l) => l.code === stored)) {
|
|
37
|
+
setActiveLocale(stored);
|
|
38
|
+
}
|
|
39
|
+
}, [locales]);
|
|
40
|
+
|
|
41
|
+
const setLocale = useCallback((locale: string) => {
|
|
42
|
+
setActiveLocale(locale);
|
|
43
|
+
localStorage.setItem(STORAGE_KEY, locale);
|
|
44
|
+
}, []);
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<LocaleContext.Provider value={{ activeLocale, setLocale, locales }}>
|
|
48
|
+
{children}
|
|
49
|
+
</LocaleContext.Provider>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Globe, Check, ChevronDown } from 'lucide-react';
|
|
4
|
+
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
|
5
|
+
import { useLocale } from './LocaleProvider.js';
|
|
6
|
+
|
|
7
|
+
export function LocaleSwitcher() {
|
|
8
|
+
const { activeLocale, setLocale, locales } = useLocale();
|
|
9
|
+
|
|
10
|
+
if (locales.length < 2) return null;
|
|
11
|
+
|
|
12
|
+
const activeLabel = locales.find((l) => l.code === activeLocale)?.label ?? activeLocale;
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<DropdownMenu.Root>
|
|
16
|
+
<DropdownMenu.Trigger asChild>
|
|
17
|
+
<button
|
|
18
|
+
className="flex items-center gap-1.5 px-2 py-1.5 text-sm hover:bg-[var(--accent)] rounded-lg transition-colors"
|
|
19
|
+
aria-label="Switch locale"
|
|
20
|
+
>
|
|
21
|
+
<Globe className="w-4 h-4 text-[var(--muted-foreground)]" />
|
|
22
|
+
<span className="hidden sm:inline text-[var(--foreground)] text-sm font-medium">
|
|
23
|
+
{activeLabel}
|
|
24
|
+
</span>
|
|
25
|
+
<ChevronDown className="w-3 h-3 text-[var(--muted-foreground)]" />
|
|
26
|
+
</button>
|
|
27
|
+
</DropdownMenu.Trigger>
|
|
28
|
+
|
|
29
|
+
<DropdownMenu.Portal>
|
|
30
|
+
<DropdownMenu.Content
|
|
31
|
+
className="min-w-[160px] bg-[var(--popover)] text-[var(--popover-foreground)] rounded-lg border border-[var(--border)] shadow-lg p-1 z-50"
|
|
32
|
+
align="end"
|
|
33
|
+
sideOffset={5}
|
|
34
|
+
>
|
|
35
|
+
{locales.map((locale) => (
|
|
36
|
+
<DropdownMenu.Item
|
|
37
|
+
key={locale.code}
|
|
38
|
+
onSelect={() => setLocale(locale.code)}
|
|
39
|
+
className="flex items-center justify-between gap-2 px-3 py-2 text-sm hover:bg-[var(--accent)] rounded cursor-pointer outline-none"
|
|
40
|
+
>
|
|
41
|
+
<span>{locale.label}</span>
|
|
42
|
+
{locale.code === activeLocale && (
|
|
43
|
+
<Check className="w-4 h-4 text-[var(--primary)]" />
|
|
44
|
+
)}
|
|
45
|
+
</DropdownMenu.Item>
|
|
46
|
+
))}
|
|
47
|
+
</DropdownMenu.Content>
|
|
48
|
+
</DropdownMenu.Portal>
|
|
49
|
+
</DropdownMenu.Root>
|
|
50
|
+
);
|
|
51
|
+
}
|