@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.
Files changed (95) hide show
  1. package/LICENSE +21 -21
  2. package/dist/AdminRoot.d.ts.map +1 -1
  3. package/dist/AdminRoot.js +17 -11
  4. package/dist/AdminRoot.js.map +1 -1
  5. package/dist/actuate-admin.css +2 -0
  6. package/dist/components/TipTapEditor.js +78 -78
  7. package/dist/lib/useApiData.d.ts +8 -1
  8. package/dist/lib/useApiData.d.ts.map +1 -1
  9. package/dist/lib/useApiData.js +39 -7
  10. package/dist/lib/useApiData.js.map +1 -1
  11. package/dist/views/Dashboard.d.ts +2 -1
  12. package/dist/views/Dashboard.d.ts.map +1 -1
  13. package/dist/views/Dashboard.js +52 -7
  14. package/dist/views/Dashboard.js.map +1 -1
  15. package/package.json +10 -5
  16. package/src/AdminRoot.tsx +312 -0
  17. package/src/__tests__/lib/search.test.ts +138 -0
  18. package/src/__tests__/lib/utils.test.ts +19 -0
  19. package/src/__tests__/router/match-route.test.ts +47 -0
  20. package/src/__tests__/router/strip-base.test.ts +30 -0
  21. package/src/components/Breadcrumbs.tsx +92 -0
  22. package/src/components/CommandPalette.tsx +384 -0
  23. package/src/components/ErrorBoundary.tsx +52 -0
  24. package/src/components/FocalPointPicker.tsx +54 -0
  25. package/src/components/FolderTree.tsx +427 -0
  26. package/src/components/LivePreview.tsx +136 -0
  27. package/src/components/LocaleProvider.tsx +51 -0
  28. package/src/components/LocaleSwitcher.tsx +51 -0
  29. package/src/components/MediaPickerModal.tsx +183 -0
  30. package/src/components/PresenceIndicator.tsx +71 -0
  31. package/src/components/SEOPanel.tsx +767 -0
  32. package/src/components/ThemeProvider.tsx +98 -0
  33. package/src/components/TipTapEditor.tsx +469 -0
  34. package/src/components/VersionHistory.tsx +167 -0
  35. package/src/components/ui/Avatar.tsx +42 -0
  36. package/src/components/ui/Badge.tsx +25 -0
  37. package/src/components/ui/Button.tsx +52 -0
  38. package/src/components/ui/CommandPalette.tsx +119 -0
  39. package/src/components/ui/ConfirmDialog.tsx +52 -0
  40. package/src/components/ui/DataTable.tsx +194 -0
  41. package/src/components/ui/EmptyState.tsx +29 -0
  42. package/src/components/ui/Modal.tsx +48 -0
  43. package/src/components/ui/Pagination.tsx +79 -0
  44. package/src/components/ui/SearchInput.tsx +44 -0
  45. package/src/components/ui/Skeleton.tsx +48 -0
  46. package/src/components/ui/Toast.tsx +66 -0
  47. package/src/components/ui/index.ts +24 -0
  48. package/src/fields/ArrayField.tsx +92 -0
  49. package/src/fields/BlockBuilderField.tsx +421 -0
  50. package/src/fields/DateField.tsx +41 -0
  51. package/src/fields/FieldRenderer.tsx +84 -0
  52. package/src/fields/GroupField.tsx +41 -0
  53. package/src/fields/MediaField.tsx +48 -0
  54. package/src/fields/NavBuilderField.tsx +78 -0
  55. package/src/fields/NumberField.tsx +45 -0
  56. package/src/fields/RelationshipField.tsx +245 -0
  57. package/src/fields/RichTextField.tsx +26 -0
  58. package/src/fields/SelectField.tsx +117 -0
  59. package/src/fields/SlugField.tsx +65 -0
  60. package/src/fields/TextField.tsx +48 -0
  61. package/src/fields/ToggleField.tsx +36 -0
  62. package/src/fields/block-types.ts +95 -0
  63. package/src/fields/index.ts +17 -0
  64. package/src/hooks/useContentLock.ts +52 -0
  65. package/src/hooks/useDebounce.ts +14 -0
  66. package/src/hooks/useKeyboardShortcuts.ts +32 -0
  67. package/src/index.ts +55 -0
  68. package/src/layout/Header.tsx +135 -0
  69. package/src/layout/Layout.tsx +77 -0
  70. package/src/layout/Sidebar.tsx +216 -0
  71. package/src/lib/api.ts +67 -0
  72. package/src/lib/search.ts +59 -0
  73. package/src/lib/useApiData.ts +95 -0
  74. package/src/lib/utils.ts +6 -0
  75. package/src/router/index.ts +81 -0
  76. package/src/styles/build-input.css +11 -0
  77. package/src/styles/tailwind.css +11 -6
  78. package/src/styles/theme.css +182 -181
  79. package/src/views/CollectionList.tsx +270 -0
  80. package/src/views/Dashboard.tsx +300 -0
  81. package/src/views/DocumentEdit.tsx +377 -0
  82. package/src/views/FormEditor.tsx +533 -0
  83. package/src/views/FormSubmissions.tsx +316 -0
  84. package/src/views/Forms.tsx +106 -0
  85. package/src/views/Login.tsx +322 -0
  86. package/src/views/MediaBrowser.tsx +774 -0
  87. package/src/views/PageEditor.tsx +192 -0
  88. package/src/views/Pages.tsx +354 -0
  89. package/src/views/PostEditor.tsx +251 -0
  90. package/src/views/Posts.tsx +243 -0
  91. package/src/views/Redirects.tsx +293 -0
  92. package/src/views/SEO.tsx +458 -0
  93. package/src/views/Settings.tsx +811 -0
  94. package/src/views/SetupWizard.tsx +207 -0
  95. 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
+ }