@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.
Files changed (94) hide show
  1. package/LICENSE +21 -21
  2. package/dist/AdminRoot.d.ts.map +1 -1
  3. package/dist/AdminRoot.js +16 -10
  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.map +1 -1
  12. package/dist/views/Dashboard.js +8 -3
  13. package/dist/views/Dashboard.js.map +1 -1
  14. package/package.json +10 -5
  15. package/src/AdminRoot.tsx +312 -0
  16. package/src/__tests__/lib/search.test.ts +138 -0
  17. package/src/__tests__/lib/utils.test.ts +19 -0
  18. package/src/__tests__/router/match-route.test.ts +47 -0
  19. package/src/__tests__/router/strip-base.test.ts +30 -0
  20. package/src/components/Breadcrumbs.tsx +92 -0
  21. package/src/components/CommandPalette.tsx +384 -0
  22. package/src/components/ErrorBoundary.tsx +52 -0
  23. package/src/components/FocalPointPicker.tsx +54 -0
  24. package/src/components/FolderTree.tsx +427 -0
  25. package/src/components/LivePreview.tsx +136 -0
  26. package/src/components/LocaleProvider.tsx +51 -0
  27. package/src/components/LocaleSwitcher.tsx +51 -0
  28. package/src/components/MediaPickerModal.tsx +183 -0
  29. package/src/components/PresenceIndicator.tsx +71 -0
  30. package/src/components/SEOPanel.tsx +767 -0
  31. package/src/components/ThemeProvider.tsx +98 -0
  32. package/src/components/TipTapEditor.tsx +469 -0
  33. package/src/components/VersionHistory.tsx +167 -0
  34. package/src/components/ui/Avatar.tsx +42 -0
  35. package/src/components/ui/Badge.tsx +25 -0
  36. package/src/components/ui/Button.tsx +52 -0
  37. package/src/components/ui/CommandPalette.tsx +119 -0
  38. package/src/components/ui/ConfirmDialog.tsx +52 -0
  39. package/src/components/ui/DataTable.tsx +194 -0
  40. package/src/components/ui/EmptyState.tsx +29 -0
  41. package/src/components/ui/Modal.tsx +48 -0
  42. package/src/components/ui/Pagination.tsx +79 -0
  43. package/src/components/ui/SearchInput.tsx +44 -0
  44. package/src/components/ui/Skeleton.tsx +48 -0
  45. package/src/components/ui/Toast.tsx +66 -0
  46. package/src/components/ui/index.ts +24 -0
  47. package/src/fields/ArrayField.tsx +92 -0
  48. package/src/fields/BlockBuilderField.tsx +421 -0
  49. package/src/fields/DateField.tsx +41 -0
  50. package/src/fields/FieldRenderer.tsx +84 -0
  51. package/src/fields/GroupField.tsx +41 -0
  52. package/src/fields/MediaField.tsx +48 -0
  53. package/src/fields/NavBuilderField.tsx +78 -0
  54. package/src/fields/NumberField.tsx +45 -0
  55. package/src/fields/RelationshipField.tsx +245 -0
  56. package/src/fields/RichTextField.tsx +26 -0
  57. package/src/fields/SelectField.tsx +117 -0
  58. package/src/fields/SlugField.tsx +65 -0
  59. package/src/fields/TextField.tsx +48 -0
  60. package/src/fields/ToggleField.tsx +36 -0
  61. package/src/fields/block-types.ts +95 -0
  62. package/src/fields/index.ts +17 -0
  63. package/src/hooks/useContentLock.ts +52 -0
  64. package/src/hooks/useDebounce.ts +14 -0
  65. package/src/hooks/useKeyboardShortcuts.ts +32 -0
  66. package/src/index.ts +55 -0
  67. package/src/layout/Header.tsx +135 -0
  68. package/src/layout/Layout.tsx +77 -0
  69. package/src/layout/Sidebar.tsx +216 -0
  70. package/src/lib/api.ts +67 -0
  71. package/src/lib/search.ts +59 -0
  72. package/src/lib/useApiData.ts +95 -0
  73. package/src/lib/utils.ts +6 -0
  74. package/src/router/index.ts +81 -0
  75. package/src/styles/build-input.css +11 -0
  76. package/src/styles/tailwind.css +11 -6
  77. package/src/styles/theme.css +182 -181
  78. package/src/views/CollectionList.tsx +270 -0
  79. package/src/views/Dashboard.tsx +207 -0
  80. package/src/views/DocumentEdit.tsx +377 -0
  81. package/src/views/FormEditor.tsx +533 -0
  82. package/src/views/FormSubmissions.tsx +316 -0
  83. package/src/views/Forms.tsx +106 -0
  84. package/src/views/Login.tsx +322 -0
  85. package/src/views/MediaBrowser.tsx +774 -0
  86. package/src/views/PageEditor.tsx +192 -0
  87. package/src/views/Pages.tsx +354 -0
  88. package/src/views/PostEditor.tsx +251 -0
  89. package/src/views/Posts.tsx +243 -0
  90. package/src/views/Redirects.tsx +293 -0
  91. package/src/views/SEO.tsx +458 -0
  92. package/src/views/Settings.tsx +811 -0
  93. package/src/views/SetupWizard.tsx +207 -0
  94. package/src/views/Users.tsx +282 -0
@@ -0,0 +1,183 @@
1
+ 'use client';
2
+
3
+ import { useState, useRef } from 'react';
4
+ import { X, Upload, Search, ImageIcon, Loader2 } from 'lucide-react';
5
+ import { toast } from 'sonner';
6
+ import { cmsApi } from '../lib/api.js';
7
+ import { useApiData } from '../lib/useApiData.js';
8
+
9
+ interface MediaItem {
10
+ id: string;
11
+ filename: string;
12
+ storageKey: string;
13
+ mimeType: string;
14
+ fileSize: number;
15
+ width?: number;
16
+ height?: number;
17
+ }
18
+
19
+ export interface MediaPickerModalProps {
20
+ open: boolean;
21
+ onClose: () => void;
22
+ onSelect: (url: string, alt?: string) => void;
23
+ accept?: string;
24
+ }
25
+
26
+ export function MediaPickerModal({ open, onClose, onSelect, accept }: MediaPickerModalProps) {
27
+ const [tab, setTab] = useState<'library' | 'upload'>('library');
28
+ const [search, setSearch] = useState('');
29
+ const [uploading, setUploading] = useState(false);
30
+ const fileInputRef = useRef<HTMLInputElement>(null);
31
+
32
+ const { data, loading, refetch } = useApiData<{ data: { items: MediaItem[] } }>(
33
+ open ? `/media?pageSize=50${search ? `&search=${encodeURIComponent(search)}` : ''}` : null,
34
+ );
35
+
36
+ const items = (data as any)?.data?.items ?? (data as any)?.items ?? [];
37
+ const imageItems = items.filter((item: MediaItem) =>
38
+ item.mimeType.startsWith('image/'),
39
+ );
40
+
41
+ async function handleUpload(files: FileList | null) {
42
+ if (!files || files.length === 0) return;
43
+ setUploading(true);
44
+
45
+ const file = files[0]!;
46
+ const formData = new FormData();
47
+ formData.append('file', file);
48
+
49
+ const res = await cmsApi<MediaItem>('/media/upload', {
50
+ method: 'POST',
51
+ body: formData,
52
+ });
53
+
54
+ if (res.error) {
55
+ toast.error(res.error);
56
+ } else if (res.data) {
57
+ const mediaItem = res.data as any;
58
+ const url = mediaItem.storageKey ?? '';
59
+ toast.success(`Uploaded ${file.name}`);
60
+ onSelect(url, file.name);
61
+ onClose();
62
+ }
63
+
64
+ setUploading(false);
65
+ if (fileInputRef.current) fileInputRef.current.value = '';
66
+ }
67
+
68
+ function handleSelectItem(item: MediaItem) {
69
+ onSelect(item.storageKey, item.filename);
70
+ onClose();
71
+ }
72
+
73
+ if (!open) return null;
74
+
75
+ return (
76
+ <div className="fixed inset-0 z-50 flex items-center justify-center">
77
+ <div className="fixed inset-0 bg-black/40" onClick={onClose} />
78
+ <div className="relative bg-white rounded-xl shadow-2xl w-full max-w-2xl max-h-[80vh] flex flex-col mx-4">
79
+ <div className="flex items-center justify-between px-4 py-3 border-b border-gray-200">
80
+ <h2 className="text-lg font-semibold text-gray-900">Insert Image</h2>
81
+ <button onClick={onClose} className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors">
82
+ <X className="w-5 h-5 text-gray-500" />
83
+ </button>
84
+ </div>
85
+
86
+ <div className="flex border-b border-gray-200">
87
+ <button
88
+ onClick={() => setTab('library')}
89
+ className={`flex-1 px-4 py-2.5 text-sm font-medium transition-colors ${
90
+ tab === 'library'
91
+ ? 'text-blue-600 border-b-2 border-blue-600'
92
+ : 'text-gray-500 hover:text-gray-700'
93
+ }`}
94
+ >
95
+ Media Library
96
+ </button>
97
+ <button
98
+ onClick={() => setTab('upload')}
99
+ className={`flex-1 px-4 py-2.5 text-sm font-medium transition-colors ${
100
+ tab === 'upload'
101
+ ? 'text-blue-600 border-b-2 border-blue-600'
102
+ : 'text-gray-500 hover:text-gray-700'
103
+ }`}
104
+ >
105
+ Upload New
106
+ </button>
107
+ </div>
108
+
109
+ <div className="flex-1 overflow-y-auto p-4">
110
+ {tab === 'library' ? (
111
+ <>
112
+ <div className="relative mb-4">
113
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
114
+ <input
115
+ type="text"
116
+ value={search}
117
+ onChange={(e) => setSearch(e.target.value)}
118
+ placeholder="Search images..."
119
+ className="w-full pl-9 pr-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
120
+ />
121
+ </div>
122
+
123
+ {loading ? (
124
+ <div className="flex items-center justify-center py-12">
125
+ <Loader2 className="w-6 h-6 animate-spin text-gray-400" />
126
+ </div>
127
+ ) : imageItems.length === 0 ? (
128
+ <div className="text-center py-12 text-sm text-gray-500">
129
+ No images found. Try uploading one.
130
+ </div>
131
+ ) : (
132
+ <div className="grid grid-cols-3 sm:grid-cols-4 gap-3">
133
+ {imageItems.map((item: MediaItem) => (
134
+ <button
135
+ key={item.id}
136
+ onClick={() => handleSelectItem(item)}
137
+ className="group relative aspect-square rounded-lg border-2 border-gray-200 hover:border-blue-500 overflow-hidden bg-gray-100 transition-colors"
138
+ >
139
+ <div className="w-full h-full flex items-center justify-center">
140
+ <ImageIcon className="w-8 h-8 text-gray-300" />
141
+ </div>
142
+ <div className="absolute inset-x-0 bottom-0 bg-black/60 p-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
143
+ <p className="text-white text-xs truncate">{item.filename}</p>
144
+ </div>
145
+ </button>
146
+ ))}
147
+ </div>
148
+ )}
149
+ </>
150
+ ) : (
151
+ <div className="flex flex-col items-center justify-center py-12">
152
+ <input
153
+ ref={fileInputRef}
154
+ type="file"
155
+ accept={accept ?? 'image/*'}
156
+ className="hidden"
157
+ onChange={(e) => handleUpload(e.target.files)}
158
+ />
159
+ <div className="w-16 h-16 rounded-full bg-blue-50 flex items-center justify-center mb-4">
160
+ {uploading ? (
161
+ <Loader2 className="w-8 h-8 text-blue-600 animate-spin" />
162
+ ) : (
163
+ <Upload className="w-8 h-8 text-blue-600" />
164
+ )}
165
+ </div>
166
+ <p className="text-sm text-gray-600 mb-4">
167
+ {uploading ? 'Uploading...' : 'Select an image file to upload'}
168
+ </p>
169
+ {!uploading && (
170
+ <button
171
+ onClick={() => fileInputRef.current?.click()}
172
+ className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
173
+ >
174
+ Choose File
175
+ </button>
176
+ )}
177
+ </div>
178
+ )}
179
+ </div>
180
+ </div>
181
+ </div>
182
+ );
183
+ }
@@ -0,0 +1,71 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState, useRef } from 'react';
4
+
5
+ interface PresenceUser {
6
+ userId: string;
7
+ name: string;
8
+ connectedAt: string;
9
+ }
10
+
11
+ interface PresenceIndicatorProps {
12
+ documentId: string;
13
+ currentUserId?: string;
14
+ }
15
+
16
+ const COLORS = ['#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6', '#ec4899'];
17
+
18
+ export function PresenceIndicator({ documentId, currentUserId }: PresenceIndicatorProps) {
19
+ const [users, setUsers] = useState<PresenceUser[]>([]);
20
+ const eventSourceRef = useRef<EventSource | null>(null);
21
+ const heartbeatRef = useRef<ReturnType<typeof setInterval>>(undefined);
22
+
23
+ useEffect(() => {
24
+ if (!documentId) return;
25
+
26
+ const es = new EventSource(`/api/cms/presence/${documentId}`);
27
+ eventSourceRef.current = es;
28
+
29
+ es.addEventListener('presence', (event) => {
30
+ try {
31
+ const data = JSON.parse(event.data) as PresenceUser[];
32
+ setUsers(data.filter(u => u.userId !== currentUserId));
33
+ } catch {}
34
+ });
35
+
36
+ es.onerror = () => {
37
+ es.close();
38
+ };
39
+
40
+ heartbeatRef.current = setInterval(() => {
41
+ fetch(`/api/cms/presence/${documentId}/heartbeat`, { method: 'POST', credentials: 'include' }).catch(() => {});
42
+ }, 30_000);
43
+
44
+ return () => {
45
+ es.close();
46
+ if (heartbeatRef.current) clearInterval(heartbeatRef.current);
47
+ };
48
+ }, [documentId, currentUserId]);
49
+
50
+ if (users.length === 0) return null;
51
+
52
+ return (
53
+ <div className="flex items-center gap-1" title={`${users.length} other editor(s) active`}>
54
+ {users.slice(0, 5).map((user, i) => (
55
+ <div
56
+ key={user.userId}
57
+ className="w-7 h-7 rounded-full flex items-center justify-center text-white text-xs font-medium ring-2 ring-white"
58
+ style={{ backgroundColor: COLORS[i % COLORS.length] }}
59
+ title={user.name}
60
+ >
61
+ {user.name.charAt(0).toUpperCase()}
62
+ </div>
63
+ ))}
64
+ {users.length > 5 && (
65
+ <div className="w-7 h-7 rounded-full flex items-center justify-center bg-muted text-muted-foreground text-xs font-medium ring-2 ring-white">
66
+ +{users.length - 5}
67
+ </div>
68
+ )}
69
+ </div>
70
+ );
71
+ }