@actuate-media/cms-admin 0.1.3 → 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/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/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 +7 -2
- package/src/styles/theme.css +2 -1
- 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,384 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
4
|
+
import { Command } from 'cmdk';
|
|
5
|
+
import {
|
|
6
|
+
Search,
|
|
7
|
+
FileText,
|
|
8
|
+
File,
|
|
9
|
+
Image,
|
|
10
|
+
Settings,
|
|
11
|
+
LayoutDashboard,
|
|
12
|
+
Plus,
|
|
13
|
+
Users,
|
|
14
|
+
ArrowRightLeft,
|
|
15
|
+
ClipboardList,
|
|
16
|
+
Upload,
|
|
17
|
+
BarChart3,
|
|
18
|
+
Clock,
|
|
19
|
+
} from 'lucide-react';
|
|
20
|
+
import { cmsApi } from '../lib/api.js';
|
|
21
|
+
|
|
22
|
+
interface SearchResultDocument {
|
|
23
|
+
id: string;
|
|
24
|
+
title: string | null;
|
|
25
|
+
slug: string | null;
|
|
26
|
+
collection: string;
|
|
27
|
+
status: string;
|
|
28
|
+
updatedAt: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface SearchResultMedia {
|
|
32
|
+
id: string;
|
|
33
|
+
filename: string;
|
|
34
|
+
altText: string | null;
|
|
35
|
+
mimeType: string;
|
|
36
|
+
storageKey: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface SearchResultUser {
|
|
40
|
+
id: string;
|
|
41
|
+
name: string;
|
|
42
|
+
email: string;
|
|
43
|
+
role: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface RecentItem {
|
|
47
|
+
id: string;
|
|
48
|
+
label: string;
|
|
49
|
+
path: string;
|
|
50
|
+
type: 'document' | 'media' | 'user' | 'action';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const RECENT_ITEMS_KEY = 'actuate-recent-items';
|
|
54
|
+
const MAX_RECENT_ITEMS = 5;
|
|
55
|
+
|
|
56
|
+
function loadRecentItems(): RecentItem[] {
|
|
57
|
+
try {
|
|
58
|
+
const raw = localStorage.getItem(RECENT_ITEMS_KEY);
|
|
59
|
+
return raw ? JSON.parse(raw) : [];
|
|
60
|
+
} catch {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function saveRecentItem(item: RecentItem): void {
|
|
66
|
+
try {
|
|
67
|
+
const items = loadRecentItems().filter((i) => i.id !== item.id);
|
|
68
|
+
items.unshift(item);
|
|
69
|
+
localStorage.setItem(RECENT_ITEMS_KEY, JSON.stringify(items.slice(0, MAX_RECENT_ITEMS)));
|
|
70
|
+
} catch { /* localStorage unavailable */ }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const staticActions = [
|
|
74
|
+
{ id: 'action-new-page', label: 'Create new page', icon: Plus, action: '/pages/new' },
|
|
75
|
+
{ id: 'action-new-post', label: 'Create new post', icon: Plus, action: '/posts/new' },
|
|
76
|
+
{ id: 'action-upload-media', label: 'Upload media', icon: Upload, action: '/media?upload=true' },
|
|
77
|
+
{ id: 'action-view-seo', label: 'View SEO', icon: BarChart3, action: '/seo' },
|
|
78
|
+
{ id: 'action-open-settings', label: 'Open settings', icon: Settings, action: '/settings' },
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
const navigationCommands = [
|
|
82
|
+
{ id: 'nav-dashboard', label: 'Dashboard', icon: LayoutDashboard, action: '/' },
|
|
83
|
+
{ id: 'nav-posts', label: 'Posts', icon: FileText, action: '/posts' },
|
|
84
|
+
{ id: 'nav-pages', label: 'Pages', icon: File, action: '/pages' },
|
|
85
|
+
{ id: 'nav-media', label: 'Media', icon: Image, action: '/media' },
|
|
86
|
+
{ id: 'nav-forms', label: 'Forms', icon: ClipboardList, action: '/forms' },
|
|
87
|
+
{ id: 'nav-redirects', label: 'Redirects', icon: ArrowRightLeft, action: '/redirects' },
|
|
88
|
+
{ id: 'nav-users', label: 'Users', icon: Users, action: '/users' },
|
|
89
|
+
{ id: 'nav-settings', label: 'Settings', icon: Settings, action: '/settings' },
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
export interface CommandPaletteProps {
|
|
93
|
+
open: boolean;
|
|
94
|
+
onOpenChange: (open: boolean) => void;
|
|
95
|
+
onNavigate: (path: string) => void;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function CommandPalette({ open, onOpenChange, onNavigate }: CommandPaletteProps) {
|
|
99
|
+
const [search, setSearch] = useState('');
|
|
100
|
+
const [documents, setDocuments] = useState<SearchResultDocument[]>([]);
|
|
101
|
+
const [media, setMedia] = useState<SearchResultMedia[]>([]);
|
|
102
|
+
const [users, setUsers] = useState<SearchResultUser[]>([]);
|
|
103
|
+
const [recentItems, setRecentItems] = useState<RecentItem[]>([]);
|
|
104
|
+
const [loading, setLoading] = useState(false);
|
|
105
|
+
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
106
|
+
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
const down = (e: KeyboardEvent) => {
|
|
109
|
+
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
|
|
110
|
+
e.preventDefault();
|
|
111
|
+
onOpenChange(!open);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
document.addEventListener('keydown', down);
|
|
115
|
+
return () => document.removeEventListener('keydown', down);
|
|
116
|
+
}, [open, onOpenChange]);
|
|
117
|
+
|
|
118
|
+
useEffect(() => {
|
|
119
|
+
if (open) {
|
|
120
|
+
setRecentItems(loadRecentItems());
|
|
121
|
+
}
|
|
122
|
+
}, [open]);
|
|
123
|
+
|
|
124
|
+
const fetchResults = useCallback(async (query: string) => {
|
|
125
|
+
if (!query.trim()) {
|
|
126
|
+
setDocuments([]);
|
|
127
|
+
setMedia([]);
|
|
128
|
+
setUsers([]);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
setLoading(true);
|
|
132
|
+
try {
|
|
133
|
+
const res = await cmsApi<{ documents: SearchResultDocument[]; media: SearchResultMedia[]; users: SearchResultUser[] }>(
|
|
134
|
+
`/search/global?q=${encodeURIComponent(query)}`,
|
|
135
|
+
);
|
|
136
|
+
if (res.data) {
|
|
137
|
+
setDocuments(res.data.documents ?? []);
|
|
138
|
+
setMedia(res.data.media ?? []);
|
|
139
|
+
setUsers(res.data.users ?? []);
|
|
140
|
+
}
|
|
141
|
+
} catch {
|
|
142
|
+
setDocuments([]);
|
|
143
|
+
setMedia([]);
|
|
144
|
+
setUsers([]);
|
|
145
|
+
} finally {
|
|
146
|
+
setLoading(false);
|
|
147
|
+
}
|
|
148
|
+
}, []);
|
|
149
|
+
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
152
|
+
if (!search.trim()) {
|
|
153
|
+
setDocuments([]);
|
|
154
|
+
setMedia([]);
|
|
155
|
+
setUsers([]);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
debounceRef.current = setTimeout(() => fetchResults(search), 250);
|
|
159
|
+
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
|
|
160
|
+
}, [search, fetchResults]);
|
|
161
|
+
|
|
162
|
+
const handleSelect = (action: string, recent?: RecentItem) => {
|
|
163
|
+
if (recent) {
|
|
164
|
+
saveRecentItem(recent);
|
|
165
|
+
}
|
|
166
|
+
onNavigate(action);
|
|
167
|
+
onOpenChange(false);
|
|
168
|
+
setSearch('');
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
if (!open) return null;
|
|
172
|
+
|
|
173
|
+
const hasQuery = search.trim().length > 0;
|
|
174
|
+
const hasResults = documents.length > 0 || media.length > 0 || users.length > 0;
|
|
175
|
+
|
|
176
|
+
return (
|
|
177
|
+
<div
|
|
178
|
+
className="fixed inset-0 z-50 bg-black/50 flex items-start justify-center pt-[20vh] px-4"
|
|
179
|
+
onClick={() => {
|
|
180
|
+
onOpenChange(false);
|
|
181
|
+
setSearch('');
|
|
182
|
+
}}
|
|
183
|
+
role="presentation"
|
|
184
|
+
>
|
|
185
|
+
<div
|
|
186
|
+
role="dialog"
|
|
187
|
+
aria-modal="true"
|
|
188
|
+
className="w-full max-w-2xl"
|
|
189
|
+
onClick={(e) => e.stopPropagation()}
|
|
190
|
+
>
|
|
191
|
+
<Command
|
|
192
|
+
className="bg-white rounded-lg shadow-2xl w-full overflow-hidden"
|
|
193
|
+
onKeyDown={(e) => {
|
|
194
|
+
if (e.key === 'Escape') {
|
|
195
|
+
onOpenChange(false);
|
|
196
|
+
setSearch('');
|
|
197
|
+
}
|
|
198
|
+
}}
|
|
199
|
+
shouldFilter={!hasQuery}
|
|
200
|
+
>
|
|
201
|
+
<div className="flex items-center border-b border-gray-200 px-4">
|
|
202
|
+
<Search className="w-5 h-5 text-gray-400 mr-3 shrink-0" />
|
|
203
|
+
<Command.Input
|
|
204
|
+
value={search}
|
|
205
|
+
onValueChange={setSearch}
|
|
206
|
+
placeholder="Search or jump to..."
|
|
207
|
+
className="w-full py-4 text-base bg-transparent focus:outline-none placeholder:text-gray-400"
|
|
208
|
+
/>
|
|
209
|
+
{loading && (
|
|
210
|
+
<div className="w-4 h-4 border-2 border-gray-300 border-t-gray-600 rounded-full animate-spin shrink-0 mr-2" />
|
|
211
|
+
)}
|
|
212
|
+
<kbd className="hidden sm:inline-block px-2 py-1 text-xs font-mono bg-gray-100 text-gray-600 rounded shrink-0">
|
|
213
|
+
ESC
|
|
214
|
+
</kbd>
|
|
215
|
+
</div>
|
|
216
|
+
|
|
217
|
+
<Command.List className="max-h-[400px] overflow-y-auto p-2">
|
|
218
|
+
<Command.Empty className="py-6 text-center text-sm text-gray-500">
|
|
219
|
+
{loading ? 'Searching...' : 'No results found.'}
|
|
220
|
+
</Command.Empty>
|
|
221
|
+
|
|
222
|
+
{!hasQuery && recentItems.length > 0 && (
|
|
223
|
+
<Command.Group heading="Recent" className="px-2 py-2">
|
|
224
|
+
<div className="text-xs font-medium text-gray-500 mb-2">RECENT</div>
|
|
225
|
+
{recentItems.map((item) => (
|
|
226
|
+
<Command.Item
|
|
227
|
+
key={`recent-${item.id}`}
|
|
228
|
+
value={`recent ${item.label}`}
|
|
229
|
+
onSelect={() => handleSelect(item.path, item)}
|
|
230
|
+
className="flex items-center gap-3 px-3 py-2 rounded-lg cursor-pointer hover:bg-gray-100 data-[selected=true]:bg-gray-100"
|
|
231
|
+
>
|
|
232
|
+
<Clock className="w-4 h-4 text-gray-400" />
|
|
233
|
+
<span>{item.label}</span>
|
|
234
|
+
<span className="ml-auto text-xs text-gray-400 capitalize">{item.type}</span>
|
|
235
|
+
</Command.Item>
|
|
236
|
+
))}
|
|
237
|
+
</Command.Group>
|
|
238
|
+
)}
|
|
239
|
+
|
|
240
|
+
{hasQuery && documents.length > 0 && (
|
|
241
|
+
<Command.Group heading="Documents" className="px-2 py-2">
|
|
242
|
+
<div className="text-xs font-medium text-gray-500 mb-2">DOCUMENTS</div>
|
|
243
|
+
{documents.map((doc) => (
|
|
244
|
+
<Command.Item
|
|
245
|
+
key={`doc-${doc.id}`}
|
|
246
|
+
value={doc.title ?? doc.slug ?? doc.id}
|
|
247
|
+
onSelect={() =>
|
|
248
|
+
handleSelect(`/collections/${doc.collection}/${doc.id}`, {
|
|
249
|
+
id: `doc-${doc.id}`,
|
|
250
|
+
label: doc.title ?? doc.slug ?? doc.id,
|
|
251
|
+
path: `/collections/${doc.collection}/${doc.id}`,
|
|
252
|
+
type: 'document',
|
|
253
|
+
})
|
|
254
|
+
}
|
|
255
|
+
className="flex items-center gap-3 px-3 py-2 rounded-lg cursor-pointer hover:bg-gray-100 data-[selected=true]:bg-gray-100"
|
|
256
|
+
>
|
|
257
|
+
<FileText className="w-4 h-4 text-gray-500" />
|
|
258
|
+
<div className="flex flex-col min-w-0">
|
|
259
|
+
<span className="truncate">{doc.title ?? doc.slug ?? doc.id}</span>
|
|
260
|
+
<span className="text-xs text-gray-400">{doc.collection} · {doc.status}</span>
|
|
261
|
+
</div>
|
|
262
|
+
</Command.Item>
|
|
263
|
+
))}
|
|
264
|
+
</Command.Group>
|
|
265
|
+
)}
|
|
266
|
+
|
|
267
|
+
{hasQuery && media.length > 0 && (
|
|
268
|
+
<Command.Group heading="Media" className="px-2 py-2 mt-1">
|
|
269
|
+
<div className="text-xs font-medium text-gray-500 mb-2">MEDIA</div>
|
|
270
|
+
{media.map((m) => (
|
|
271
|
+
<Command.Item
|
|
272
|
+
key={`media-${m.id}`}
|
|
273
|
+
value={m.filename}
|
|
274
|
+
onSelect={() =>
|
|
275
|
+
handleSelect(`/media?selected=${m.id}`, {
|
|
276
|
+
id: `media-${m.id}`,
|
|
277
|
+
label: m.filename,
|
|
278
|
+
path: `/media?selected=${m.id}`,
|
|
279
|
+
type: 'media',
|
|
280
|
+
})
|
|
281
|
+
}
|
|
282
|
+
className="flex items-center gap-3 px-3 py-2 rounded-lg cursor-pointer hover:bg-gray-100 data-[selected=true]:bg-gray-100"
|
|
283
|
+
>
|
|
284
|
+
<Image className="w-4 h-4 text-gray-500" />
|
|
285
|
+
<div className="flex flex-col min-w-0">
|
|
286
|
+
<span className="truncate">{m.filename}</span>
|
|
287
|
+
<span className="text-xs text-gray-400">{m.mimeType}</span>
|
|
288
|
+
</div>
|
|
289
|
+
</Command.Item>
|
|
290
|
+
))}
|
|
291
|
+
</Command.Group>
|
|
292
|
+
)}
|
|
293
|
+
|
|
294
|
+
{hasQuery && users.length > 0 && (
|
|
295
|
+
<Command.Group heading="Users" className="px-2 py-2 mt-1">
|
|
296
|
+
<div className="text-xs font-medium text-gray-500 mb-2">USERS</div>
|
|
297
|
+
{users.map((u) => (
|
|
298
|
+
<Command.Item
|
|
299
|
+
key={`user-${u.id}`}
|
|
300
|
+
value={`${u.name} ${u.email}`}
|
|
301
|
+
onSelect={() =>
|
|
302
|
+
handleSelect(`/users?selected=${u.id}`, {
|
|
303
|
+
id: `user-${u.id}`,
|
|
304
|
+
label: u.name,
|
|
305
|
+
path: `/users?selected=${u.id}`,
|
|
306
|
+
type: 'user',
|
|
307
|
+
})
|
|
308
|
+
}
|
|
309
|
+
className="flex items-center gap-3 px-3 py-2 rounded-lg cursor-pointer hover:bg-gray-100 data-[selected=true]:bg-gray-100"
|
|
310
|
+
>
|
|
311
|
+
<Users className="w-4 h-4 text-gray-500" />
|
|
312
|
+
<div className="flex flex-col min-w-0">
|
|
313
|
+
<span className="truncate">{u.name}</span>
|
|
314
|
+
<span className="text-xs text-gray-400">{u.email} · {u.role}</span>
|
|
315
|
+
</div>
|
|
316
|
+
</Command.Item>
|
|
317
|
+
))}
|
|
318
|
+
</Command.Group>
|
|
319
|
+
)}
|
|
320
|
+
|
|
321
|
+
{(!hasQuery || !hasResults) && (
|
|
322
|
+
<Command.Group heading="Navigation" className="px-2 py-2">
|
|
323
|
+
<div className="text-xs font-medium text-gray-500 mb-2">NAVIGATION</div>
|
|
324
|
+
{navigationCommands.map((cmd) => {
|
|
325
|
+
const Icon = cmd.icon;
|
|
326
|
+
return (
|
|
327
|
+
<Command.Item
|
|
328
|
+
key={cmd.id}
|
|
329
|
+
value={cmd.label}
|
|
330
|
+
onSelect={() => handleSelect(cmd.action)}
|
|
331
|
+
className="flex items-center gap-3 px-3 py-2 rounded-lg cursor-pointer hover:bg-gray-100 data-[selected=true]:bg-gray-100"
|
|
332
|
+
>
|
|
333
|
+
<Icon className="w-4 h-4 text-gray-500" />
|
|
334
|
+
<span>{cmd.label}</span>
|
|
335
|
+
</Command.Item>
|
|
336
|
+
);
|
|
337
|
+
})}
|
|
338
|
+
</Command.Group>
|
|
339
|
+
)}
|
|
340
|
+
|
|
341
|
+
<Command.Group heading="Actions" className="px-2 py-2 mt-2">
|
|
342
|
+
<div className="text-xs font-medium text-gray-500 mb-2">ACTIONS</div>
|
|
343
|
+
{staticActions.map((cmd) => {
|
|
344
|
+
const Icon = cmd.icon;
|
|
345
|
+
return (
|
|
346
|
+
<Command.Item
|
|
347
|
+
key={cmd.id}
|
|
348
|
+
value={cmd.label}
|
|
349
|
+
onSelect={() =>
|
|
350
|
+
handleSelect(cmd.action, {
|
|
351
|
+
id: cmd.id,
|
|
352
|
+
label: cmd.label,
|
|
353
|
+
path: cmd.action,
|
|
354
|
+
type: 'action',
|
|
355
|
+
})
|
|
356
|
+
}
|
|
357
|
+
className="flex items-center gap-3 px-3 py-2 rounded-lg cursor-pointer hover:bg-gray-100 data-[selected=true]:bg-gray-100"
|
|
358
|
+
>
|
|
359
|
+
<Icon className="w-4 h-4 text-gray-500" />
|
|
360
|
+
<span>{cmd.label}</span>
|
|
361
|
+
</Command.Item>
|
|
362
|
+
);
|
|
363
|
+
})}
|
|
364
|
+
</Command.Group>
|
|
365
|
+
</Command.List>
|
|
366
|
+
|
|
367
|
+
<div className="border-t border-gray-200 px-4 py-3 bg-gray-50 flex items-center justify-between text-xs text-gray-500">
|
|
368
|
+
<div className="flex items-center gap-4">
|
|
369
|
+
<span className="flex items-center gap-1">
|
|
370
|
+
<kbd className="px-1.5 py-0.5 bg-white border border-gray-300 rounded text-[10px]">↑</kbd>
|
|
371
|
+
<kbd className="px-1.5 py-0.5 bg-white border border-gray-300 rounded text-[10px]">↓</kbd>
|
|
372
|
+
to navigate
|
|
373
|
+
</span>
|
|
374
|
+
<span className="flex items-center gap-1">
|
|
375
|
+
<kbd className="px-1.5 py-0.5 bg-white border border-gray-300 rounded text-[10px]">↵</kbd>
|
|
376
|
+
to select
|
|
377
|
+
</span>
|
|
378
|
+
</div>
|
|
379
|
+
</div>
|
|
380
|
+
</Command>
|
|
381
|
+
</div>
|
|
382
|
+
</div>
|
|
383
|
+
);
|
|
384
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Component, type ReactNode, type ErrorInfo } from 'react';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
children: ReactNode;
|
|
7
|
+
fallback?: ReactNode;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface State {
|
|
11
|
+
hasError: boolean;
|
|
12
|
+
error: Error | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class ErrorBoundary extends Component<Props, State> {
|
|
16
|
+
constructor(props: Props) {
|
|
17
|
+
super(props);
|
|
18
|
+
this.state = { hasError: false, error: null };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
static getDerivedStateFromError(error: Error): State {
|
|
22
|
+
return { hasError: true, error };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
componentDidCatch(error: Error, info: ErrorInfo) {
|
|
26
|
+
console.error('[Actuate CMS] Unhandled error:', error, info.componentStack);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
render() {
|
|
30
|
+
if (this.state.hasError) {
|
|
31
|
+
if (this.props.fallback) return this.props.fallback;
|
|
32
|
+
return (
|
|
33
|
+
<div className="flex min-h-[200px] items-center justify-center p-6">
|
|
34
|
+
<div className="text-center">
|
|
35
|
+
<h2 className="text-lg font-semibold text-gray-900 mb-2">Something went wrong</h2>
|
|
36
|
+
<p className="text-sm text-gray-600 mb-4">
|
|
37
|
+
{this.state.error?.message ?? 'An unexpected error occurred'}
|
|
38
|
+
</p>
|
|
39
|
+
<button
|
|
40
|
+
onClick={() => this.setState({ hasError: false, error: null })}
|
|
41
|
+
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700"
|
|
42
|
+
>
|
|
43
|
+
Try Again
|
|
44
|
+
</button>
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return this.props.children;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useCallback, useRef } from 'react';
|
|
4
|
+
import { Crosshair } from 'lucide-react';
|
|
5
|
+
|
|
6
|
+
interface FocalPointPickerProps {
|
|
7
|
+
imageUrl: string;
|
|
8
|
+
focalX: number;
|
|
9
|
+
focalY: number;
|
|
10
|
+
onChange: (x: number, y: number) => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function FocalPointPicker({ imageUrl, focalX, focalY, onChange }: FocalPointPickerProps) {
|
|
14
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
15
|
+
const [dragging, setDragging] = useState(false);
|
|
16
|
+
|
|
17
|
+
const handlePosition = useCallback((e: React.MouseEvent) => {
|
|
18
|
+
const rect = containerRef.current?.getBoundingClientRect();
|
|
19
|
+
if (!rect) return;
|
|
20
|
+
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
|
21
|
+
const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height));
|
|
22
|
+
onChange(Math.round(x * 100) / 100, Math.round(y * 100) / 100);
|
|
23
|
+
}, [onChange]);
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div className="space-y-2">
|
|
27
|
+
<div className="flex items-center gap-2 text-sm font-medium text-[var(--foreground)]">
|
|
28
|
+
<Crosshair className="w-4 h-4" />
|
|
29
|
+
Focal Point
|
|
30
|
+
</div>
|
|
31
|
+
<div
|
|
32
|
+
ref={containerRef}
|
|
33
|
+
className="relative rounded-lg overflow-hidden border border-[var(--border)] cursor-crosshair select-none"
|
|
34
|
+
style={{ maxHeight: '200px' }}
|
|
35
|
+
onClick={handlePosition}
|
|
36
|
+
onMouseDown={() => setDragging(true)}
|
|
37
|
+
onMouseUp={() => setDragging(false)}
|
|
38
|
+
onMouseLeave={() => setDragging(false)}
|
|
39
|
+
onMouseMove={(e) => { if (dragging) handlePosition(e); }}
|
|
40
|
+
>
|
|
41
|
+
<img src={imageUrl} alt="Focal point preview" className="w-full h-full object-contain" draggable={false} />
|
|
42
|
+
<div
|
|
43
|
+
className="absolute w-6 h-6 -ml-3 -mt-3 rounded-full border-2 border-white shadow-lg bg-blue-500/50 pointer-events-none"
|
|
44
|
+
style={{ left: `${focalX * 100}%`, top: `${focalY * 100}%` }}
|
|
45
|
+
>
|
|
46
|
+
<div className="absolute inset-1 rounded-full bg-white" />
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
<p className="text-xs text-[var(--muted-foreground)]">
|
|
50
|
+
Click or drag to set the focal point ({Math.round(focalX * 100)}%, {Math.round(focalY * 100)}%)
|
|
51
|
+
</p>
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
}
|