@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,19 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { cn } from '../../lib/utils.js';
3
+
4
+ describe('cn', () => {
5
+ it('merges multiple class names into a single string', () => {
6
+ expect(cn('foo', 'bar')).toBe('foo bar');
7
+ expect(cn('a', 'b', 'c')).toBe('a b c');
8
+ });
9
+
10
+ it('omits falsy conditional classes', () => {
11
+ expect(cn('base', false && 'hidden', 'visible')).toBe('base visible');
12
+ expect(cn('x', undefined, null, 0, 'y')).toBe('x y');
13
+ });
14
+
15
+ it('resolves conflicting Tailwind utilities in favor of the last one', () => {
16
+ expect(cn('p-4', 'p-2')).toBe('p-2');
17
+ expect(cn('text-sm', 'text-lg')).toBe('text-lg');
18
+ });
19
+ });
@@ -0,0 +1,47 @@
1
+ import { describe, it, expect } from 'vitest';
2
+
3
+ function matchRoute(currentPath: string, pattern: string): Record<string, string> | null {
4
+ const patternParts = pattern.split('/').filter(Boolean);
5
+ const pathParts = currentPath.split('/').filter(Boolean);
6
+ if (pattern === '/' && pathParts.length === 0) return {};
7
+ if (patternParts.length !== pathParts.length) return null;
8
+ const params: Record<string, string> = {};
9
+ for (let i = 0; i < patternParts.length; i++) {
10
+ const patternPart = patternParts[i]!;
11
+ const pathPart = pathParts[i]!;
12
+ if (patternPart.startsWith(':')) {
13
+ params[patternPart.slice(1)] = pathPart;
14
+ } else if (patternPart !== pathPart) {
15
+ return null;
16
+ }
17
+ }
18
+ return params;
19
+ }
20
+
21
+ describe('matchRoute', () => {
22
+ it('matches the root pattern against an empty path', () => {
23
+ expect(matchRoute('/', '/')).toEqual({});
24
+ });
25
+
26
+ it('matches static multi-segment paths', () => {
27
+ expect(matchRoute('/collections/posts', '/collections/posts')).toEqual({});
28
+ });
29
+
30
+ it('extracts named parameters from dynamic segments', () => {
31
+ expect(matchRoute('/collections/posts/abc-123', '/collections/posts/:id')).toEqual({
32
+ id: 'abc-123',
33
+ });
34
+ expect(
35
+ matchRoute('/users/u1/settings', '/users/:userId/settings'),
36
+ ).toEqual({ userId: 'u1' });
37
+ });
38
+
39
+ it('returns null when a static segment does not match', () => {
40
+ expect(matchRoute('/collections/pages', '/collections/posts')).toBeNull();
41
+ });
42
+
43
+ it('returns null when segment counts differ', () => {
44
+ expect(matchRoute('/a', '/a/b')).toBeNull();
45
+ expect(matchRoute('/a/b', '/a')).toBeNull();
46
+ });
47
+ });
@@ -0,0 +1,30 @@
1
+ import { describe, it, expect } from 'vitest';
2
+
3
+ // Copy of the internal stripBase function for testing
4
+ function stripBase(pathname: string, basePath: string): string {
5
+ if (pathname.startsWith(basePath)) {
6
+ const rest = pathname.slice(basePath.length);
7
+ return rest === '' || rest === '/' ? '/' : rest.startsWith('/') ? rest : `/${rest}`;
8
+ }
9
+ return '/';
10
+ }
11
+
12
+ describe('stripBase', () => {
13
+ it('strips the base path and returns the remainder with a leading slash', () => {
14
+ expect(stripBase('/admin/dashboard', '/admin')).toBe('/dashboard');
15
+ expect(stripBase('/admin/collections/posts', '/admin')).toBe('/collections/posts');
16
+ });
17
+
18
+ it('returns root when the pathname is exactly the base or base plus trailing slash', () => {
19
+ expect(stripBase('/admin', '/admin')).toBe('/');
20
+ expect(stripBase('/admin/', '/admin')).toBe('/');
21
+ });
22
+
23
+ it('returns root when the pathname does not start with the base path', () => {
24
+ expect(stripBase('/public/page', '/admin')).toBe('/');
25
+ });
26
+
27
+ it('normalizes remainder that lacks a leading slash', () => {
28
+ expect(stripBase('/adminfoo', '/admin')).toBe('/foo');
29
+ });
30
+ });
@@ -0,0 +1,92 @@
1
+ 'use client';
2
+
3
+ import { ChevronRight, Home } from 'lucide-react';
4
+
5
+ const LABEL_MAP: Record<string, string> = {
6
+ posts: 'Posts',
7
+ pages: 'Pages',
8
+ media: 'Media Library',
9
+ forms: 'Forms',
10
+ seo: 'SEO',
11
+ redirects: 'Redirects',
12
+ canonicals: 'Canonicalization',
13
+ links: 'Link Health',
14
+ users: 'Users',
15
+ settings: 'Settings',
16
+ collections: 'Collections',
17
+ submissions: 'Submissions',
18
+ new: 'New',
19
+ edit: 'Edit',
20
+ };
21
+
22
+ function labelFor(segment: string): string {
23
+ return LABEL_MAP[segment] ?? segment.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
24
+ }
25
+
26
+ function isId(segment: string): boolean {
27
+ return /^\d+$/.test(segment) || /^[0-9a-f]{8,}$/i.test(segment);
28
+ }
29
+
30
+ export interface BreadcrumbsProps {
31
+ currentPath: string;
32
+ onNavigate: (path: string) => void;
33
+ }
34
+
35
+ export function Breadcrumbs({ currentPath, onNavigate }: BreadcrumbsProps) {
36
+ const segments = currentPath.split('/').filter(Boolean);
37
+
38
+ if (segments.length === 0) return null;
39
+
40
+ const crumbs: { label: string; path: string }[] = [];
41
+ let accumulated = '';
42
+
43
+ for (let i = 0; i < segments.length; i++) {
44
+ const seg = segments[i]!;
45
+ accumulated += `/${seg}`;
46
+
47
+ let label: string;
48
+ if (isId(seg)) {
49
+ const parentLabel = i > 0 ? labelFor(segments[i - 1]!) : '';
50
+ label = parentLabel
51
+ ? `Edit ${parentLabel.replace(/s$/, '')}`
52
+ : `#${seg}`;
53
+ } else {
54
+ label = labelFor(seg);
55
+ }
56
+
57
+ crumbs.push({ label, path: accumulated });
58
+ }
59
+
60
+ return (
61
+ <nav aria-label="Breadcrumb" className="flex items-center gap-1 text-sm py-2.5 px-4 bg-white border-b border-gray-200 overflow-x-auto">
62
+ <button
63
+ type="button"
64
+ onClick={() => onNavigate('/')}
65
+ className="flex items-center gap-1 text-gray-500 hover:text-blue-600 transition-colors shrink-0"
66
+ >
67
+ <Home className="w-3.5 h-3.5" />
68
+ <span className="hidden sm:inline">Dashboard</span>
69
+ </button>
70
+
71
+ {crumbs.map((crumb, i) => {
72
+ const isLast = i === crumbs.length - 1;
73
+ return (
74
+ <span key={crumb.path} className="flex items-center gap-1 shrink-0">
75
+ <ChevronRight className="w-3.5 h-3.5 text-gray-400" />
76
+ {isLast ? (
77
+ <span className="font-medium text-gray-900">{crumb.label}</span>
78
+ ) : (
79
+ <button
80
+ type="button"
81
+ onClick={() => onNavigate(crumb.path)}
82
+ className="text-gray-500 hover:text-blue-600 transition-colors"
83
+ >
84
+ {crumb.label}
85
+ </button>
86
+ )}
87
+ </span>
88
+ );
89
+ })}
90
+ </nav>
91
+ );
92
+ }
@@ -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} &middot; {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} &middot; {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
+ }