@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,774 @@
1
+ 'use client';
2
+
3
+ import {
4
+ Upload, Grid3x3, List, Search, Trash2, Download, FileText,
5
+ ArrowUpDown, ArrowUp, ArrowDown, X, Bot, Sparkles, Link2,
6
+ AlertTriangle, Copy, ExternalLink, ImageIcon, FileImage, Loader2,
7
+ FolderInput, GripVertical,
8
+ } from 'lucide-react';
9
+ import { useState, useMemo, useRef, useCallback } from 'react';
10
+ import { toast } from 'sonner';
11
+ import { sortByRelevance, type SortConfig, toggleSort } from '../lib/search.js';
12
+ import { useApiData } from '../lib/useApiData.js';
13
+ import { cmsApi } from '../lib/api.js';
14
+ import { FolderTree, type FolderSelection } from '../components/FolderTree.js';
15
+ import { FocalPointPicker } from '../components/FocalPointPicker.js';
16
+
17
+ interface MediaItem {
18
+ id: number;
19
+ name: string;
20
+ type: string;
21
+ size: string;
22
+ sizeBytes: number;
23
+ date: string;
24
+ url: string;
25
+ dimensions?: string;
26
+ format?: string;
27
+ altTag?: string;
28
+ title?: string;
29
+ usedOn?: { page: string; path: string }[];
30
+ }
31
+
32
+ type MediaSortKey = 'name' | 'type' | 'size' | 'date';
33
+
34
+ export interface MediaBrowserProps {
35
+ onNavigate?: (path: string) => void;
36
+ }
37
+
38
+ function buildMediaApiUrl(folderSel: FolderSelection): string {
39
+ const base = '/media?pageSize=100';
40
+ if (folderSel.type === 'smart') {
41
+ if (folderSel.smart === 'recent') return `${base}&sort=updatedAt&order=desc&pageSize=20`;
42
+ if (folderSel.smart === 'uncategorized') return `${base}&folderId=none`;
43
+ return base;
44
+ }
45
+ return `${base}&folderId=${folderSel.folderId}`;
46
+ }
47
+
48
+ export function MediaBrowser({ onNavigate }: MediaBrowserProps) {
49
+ const [folderSel, setFolderSel] = useState<FolderSelection>({ type: 'smart', smart: 'all' });
50
+ const [sidebarOpen, setSidebarOpen] = useState(true);
51
+
52
+ const apiUrl = useMemo(() => buildMediaApiUrl(folderSel), [folderSel]);
53
+ const { data, loading, error, refetch } = useApiData<{ data: MediaItem[]; total: number }>(apiUrl);
54
+
55
+ const allData = useApiData<{ data: MediaItem[]; total: number }>('/media?pageSize=1');
56
+ const uncatData = useApiData<{ data: MediaItem[]; total: number }>('/media?pageSize=1&folderId=none');
57
+
58
+ const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
59
+ const [searchQuery, setSearchQuery] = useState('');
60
+ const [filterType, setFilterType] = useState('all');
61
+ const [selectedMedia, setSelectedMedia] = useState<number[]>([]);
62
+ const [sortConfig, setSortConfig] = useState<SortConfig<MediaSortKey> | null>(null);
63
+ const [activeItem, setActiveItem] = useState<MediaItem | null>(null);
64
+
65
+ const [editAlt, setEditAlt] = useState('');
66
+ const [editTitle, setEditTitle] = useState('');
67
+ const [editFilename, setEditFilename] = useState('');
68
+ const [focalX, setFocalX] = useState(0.5);
69
+ const [focalY, setFocalY] = useState(0.5);
70
+ const [saving, setSaving] = useState(false);
71
+ const [aiGenerating, setAiGenerating] = useState<string | null>(null);
72
+ const [uploading, setUploading] = useState(false);
73
+ const fileInputRef = useRef<HTMLInputElement>(null);
74
+
75
+ const mediaItems = data?.data ?? [];
76
+
77
+ const filteredAndSorted = useMemo(() => {
78
+ let results = mediaItems.filter((item) => {
79
+ const matchesSearch = item.name.toLowerCase().includes(searchQuery.toLowerCase());
80
+ const matchesType = filterType === 'all' || item.type === filterType;
81
+ return matchesSearch && matchesType;
82
+ });
83
+
84
+ if (searchQuery.trim()) {
85
+ results = sortByRelevance(results, searchQuery, (m) => [m.name]);
86
+ } else if (sortConfig) {
87
+ results = [...results].sort((a, b) => {
88
+ let cmp: number;
89
+ if (sortConfig.key === 'size') {
90
+ cmp = a.sizeBytes - b.sizeBytes;
91
+ } else {
92
+ cmp = String(a[sortConfig.key]).localeCompare(String(b[sortConfig.key]));
93
+ }
94
+ return sortConfig.direction === 'asc' ? cmp : -cmp;
95
+ });
96
+ }
97
+ return results;
98
+ }, [mediaItems, searchQuery, filterType, sortConfig]);
99
+
100
+ const openDetail = (item: MediaItem) => {
101
+ setActiveItem(item);
102
+ setEditAlt(item.altTag ?? '');
103
+ setEditTitle(item.title ?? '');
104
+ setEditFilename(item.name);
105
+ setFocalX((item as any).focalPointX ?? 0.5);
106
+ setFocalY((item as any).focalPointY ?? 0.5);
107
+ };
108
+
109
+ const closeDetail = () => {
110
+ setActiveItem(null);
111
+ };
112
+
113
+ const handleCheckbox = (e: React.MouseEvent, id: number) => {
114
+ e.stopPropagation();
115
+ setSelectedMedia(prev => prev.includes(id) ? prev.filter(item => item !== id) : [...prev, id]);
116
+ };
117
+
118
+ const handleSelectAll = () => {
119
+ setSelectedMedia(prev => prev.length === filteredAndSorted.length ? [] : filteredAndSorted.map(item => item.id));
120
+ };
121
+
122
+ const handleSaveDetails = async () => {
123
+ if (!activeItem) return;
124
+ setSaving(true);
125
+ const res = await cmsApi(`/media/${activeItem.id}`, {
126
+ method: 'PUT',
127
+ body: JSON.stringify({
128
+ alt: editAlt,
129
+ title: editTitle,
130
+ filename: editFilename,
131
+ focalX,
132
+ focalY,
133
+ }),
134
+ });
135
+ setSaving(false);
136
+ if (res.error) {
137
+ toast.error(res.error);
138
+ } else {
139
+ toast.success('Media details saved');
140
+ refetch();
141
+ }
142
+ };
143
+
144
+ const deleteMedia = async (id: number) => {
145
+ const res = await cmsApi(`/media/${id}`, { method: 'DELETE' });
146
+ if (res.error) {
147
+ toast.error(res.error);
148
+ } else {
149
+ toast.success('Media deleted');
150
+ if (activeItem?.id === id) closeDetail();
151
+ refetch();
152
+ }
153
+ };
154
+
155
+ const handleAiGenerate = async (field: 'alt' | 'title' | 'optimize') => {
156
+ setAiGenerating(field);
157
+
158
+ if (field === 'optimize' && activeItem) {
159
+ const res = await cmsApi<MediaItem & {
160
+ optimization?: {
161
+ originalSize: number;
162
+ optimizedSize: number;
163
+ savings: number;
164
+ originalSizeFormatted: string;
165
+ optimizedSizeFormatted: string;
166
+ alreadyOptimized?: boolean;
167
+ };
168
+ }>(`/media/${activeItem.id}/optimize`, { method: 'POST' });
169
+
170
+ if (res.error) {
171
+ toast.error(res.error);
172
+ } else if ((res.data as any)?.optimization?.alreadyOptimized) {
173
+ toast.info('Image is already in WebP format — no further optimization needed');
174
+ } else if ((res.data as any)?.optimization) {
175
+ const opt = (res.data as any).optimization;
176
+ toast.success(
177
+ `Optimized: ${opt.originalSizeFormatted} → ${opt.optimizedSizeFormatted} (${opt.savings}% smaller)`,
178
+ );
179
+ refetch();
180
+ }
181
+
182
+ setAiGenerating(null);
183
+ return;
184
+ }
185
+
186
+ await new Promise(r => setTimeout(r, 1500));
187
+ if (field === 'alt') {
188
+ const generated = `A ${activeItem?.format?.toLowerCase()} image showing ${activeItem?.name.replace(/[-_]/g, ' ').replace(/\.\w+$/, '')} content`;
189
+ setEditAlt(generated);
190
+ toast.success('Alt tag generated by AI');
191
+ } else if (field === 'title') {
192
+ const generated = activeItem?.name.replace(/[-_]/g, ' ').replace(/\.\w+$/, '').replace(/\b\w/g, c => c.toUpperCase()) ?? '';
193
+ setEditTitle(generated);
194
+ toast.success('Title generated by AI');
195
+ }
196
+ setAiGenerating(null);
197
+ };
198
+
199
+ const handleCopyUrl = () => {
200
+ if (activeItem?.url) {
201
+ navigator.clipboard.writeText(activeItem.url);
202
+ toast.success('URL copied to clipboard');
203
+ }
204
+ };
205
+
206
+ const handleUploadFiles = async (files: FileList | null) => {
207
+ if (!files || files.length === 0) return;
208
+ setUploading(true);
209
+
210
+ let successCount = 0;
211
+
212
+ for (let i = 0; i < files.length; i++) {
213
+ const file = files[i]!;
214
+ const formData = new FormData();
215
+ formData.append('file', file);
216
+
217
+ const res = await cmsApi<MediaItem & {
218
+ optimization?: {
219
+ originalSize: number;
220
+ optimizedSize: number;
221
+ savings: number;
222
+ originalSizeFormatted: string;
223
+ optimizedSizeFormatted: string;
224
+ };
225
+ }>('/media/upload', { method: 'POST', body: formData });
226
+
227
+ if (res.error) {
228
+ toast.error(`Failed to upload ${file.name}: ${res.error}`);
229
+ } else {
230
+ const opt = (res.data as any)?.optimization;
231
+ if (opt && opt.savings > 0) {
232
+ toast.success(
233
+ `${file.name} → WebP (${opt.originalSizeFormatted} → ${opt.optimizedSizeFormatted}, ${opt.savings}% saved)`,
234
+ );
235
+ } else {
236
+ toast.success(`Uploaded ${file.name}`);
237
+ }
238
+ successCount++;
239
+ }
240
+ }
241
+
242
+ if (successCount > 0) refetch();
243
+ if (fileInputRef.current) fileInputRef.current.value = '';
244
+ setUploading(false);
245
+ };
246
+
247
+ const handleDropItem = useCallback(async (itemId: string, folderId: string | null) => {
248
+ const res = await cmsApi(`/media/${itemId}/folder`, {
249
+ method: 'PUT',
250
+ body: JSON.stringify({ folderId }),
251
+ });
252
+ if (res.error) {
253
+ toast.error(res.error);
254
+ } else {
255
+ toast.success(folderId ? 'Moved to folder' : 'Removed from folder');
256
+ refetch();
257
+ }
258
+ }, [refetch]);
259
+
260
+ const handleDragStart = (e: React.DragEvent, id: number) => {
261
+ e.dataTransfer.setData('text/actuate-item-id', String(id));
262
+ e.dataTransfer.effectAllowed = 'move';
263
+ };
264
+
265
+ const panelOpen = activeItem !== null;
266
+ const issues = activeItem ? [
267
+ ...(!activeItem.altTag ? ['Missing alt tag'] : []),
268
+ ...(!activeItem.title ? ['Missing title'] : []),
269
+ ...(activeItem.sizeBytes > 2000000 ? ['File size over 2 MB — consider optimizing'] : []),
270
+ ...(activeItem.usedOn?.length === 0 ? ['Not used on any page'] : []),
271
+ ] : [];
272
+
273
+ function SortHeader({ label, sortKey }: { label: string; sortKey: MediaSortKey }) {
274
+ const active = sortConfig?.key === sortKey;
275
+ return (
276
+ <button
277
+ type="button"
278
+ onClick={() => setSortConfig(toggleSort(sortConfig, sortKey))}
279
+ className="flex items-center gap-1 text-xs font-medium text-gray-700 hover:text-gray-900 transition-colors"
280
+ >
281
+ {label}
282
+ {active ? (
283
+ sortConfig!.direction === 'asc' ? <ArrowUp className="w-3 h-3" /> : <ArrowDown className="w-3 h-3" />
284
+ ) : (
285
+ <ArrowUpDown className="w-3 h-3 text-gray-400" />
286
+ )}
287
+ </button>
288
+ );
289
+ }
290
+
291
+ if (loading) {
292
+ return (
293
+ <div className="p-3 pr-6 sm:p-4 sm:pr-8 flex items-center justify-center h-64">
294
+ <Loader2 className="w-6 h-6 animate-spin text-blue-600" />
295
+ </div>
296
+ );
297
+ }
298
+
299
+ return (
300
+ <div className="p-3 pr-6 sm:p-4 sm:pr-8 h-full flex flex-col">
301
+ {error && (
302
+ <div className="mb-4 flex items-center gap-3 rounded-lg border border-red-200 bg-red-50 p-3">
303
+ <AlertTriangle className="w-5 h-5 text-red-600 shrink-0" />
304
+ <span className="text-sm text-red-800 flex-1">{error}</span>
305
+ <button onClick={refetch} className="px-3 py-1 text-sm text-red-700 border border-red-300 rounded-lg hover:bg-red-100 transition-colors">Retry</button>
306
+ </div>
307
+ )}
308
+
309
+ <div className="flex items-center justify-between mb-4">
310
+ <div className="flex items-center gap-3">
311
+ <button
312
+ type="button"
313
+ onClick={() => setSidebarOpen(prev => !prev)}
314
+ className="p-1.5 rounded-lg hover:bg-gray-100 transition-colors"
315
+ title={sidebarOpen ? 'Hide folders' : 'Show folders'}
316
+ >
317
+ <FolderInput className="w-5 h-5 text-gray-600" />
318
+ </button>
319
+ <div>
320
+ <h1 className="text-xl sm:text-2xl font-semibold text-gray-900 mb-1">Media Library</h1>
321
+ <p className="text-sm text-gray-600">{filteredAndSorted.length} files</p>
322
+ </div>
323
+ </div>
324
+ <div>
325
+ <input
326
+ ref={fileInputRef}
327
+ type="file"
328
+ multiple
329
+ accept="image/*,video/*,application/pdf,.doc,.docx,.xls,.xlsx"
330
+ className="hidden"
331
+ onChange={(e) => handleUploadFiles(e.target.files)}
332
+ />
333
+ <button
334
+ onClick={() => fileInputRef.current?.click()}
335
+ disabled={uploading}
336
+ className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm disabled:opacity-50"
337
+ >
338
+ {uploading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
339
+ {uploading ? 'Uploading...' : 'Upload Files'}
340
+ </button>
341
+ </div>
342
+ </div>
343
+
344
+ <div className="flex gap-4 flex-1 min-h-0 overflow-hidden">
345
+ {sidebarOpen && (
346
+ <div className="w-56 shrink-0 bg-white rounded-lg border border-gray-200 overflow-hidden flex flex-col">
347
+ <FolderTree
348
+ scope="media"
349
+ selected={folderSel}
350
+ onSelect={(sel) => { setFolderSel(sel); setSelectedMedia([]); }}
351
+ totalCount={allData.data?.total}
352
+ uncategorizedCount={uncatData.data?.total}
353
+ onDropItem={handleDropItem}
354
+ />
355
+ </div>
356
+ )}
357
+
358
+ <div className="flex-1 flex flex-col min-w-0">
359
+ <div className="bg-white rounded-lg border border-gray-200 mb-4">
360
+ <div className="p-3 flex items-center justify-between">
361
+ <div className="flex items-center gap-3 flex-1">
362
+ <div className="flex-1 max-w-md relative">
363
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
364
+ <input type="text" placeholder="Search media..." value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} 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" />
365
+ </div>
366
+ <select value={filterType} onChange={(e) => setFilterType(e.target.value)} className="px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
367
+ <option value="all">All Types</option>
368
+ <option value="image">Images</option>
369
+ <option value="video">Videos</option>
370
+ <option value="document">Documents</option>
371
+ </select>
372
+ </div>
373
+ <div className="flex items-center gap-1 bg-gray-100 p-1 rounded-lg">
374
+ <button onClick={() => setViewMode('grid')} className={`p-1.5 rounded transition-colors ${viewMode === 'grid' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-600 hover:text-gray-900'}`}>
375
+ <Grid3x3 className="w-4 h-4" />
376
+ </button>
377
+ <button onClick={() => setViewMode('list')} className={`p-1.5 rounded transition-colors ${viewMode === 'list' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-600 hover:text-gray-900'}`}>
378
+ <List className="w-4 h-4" />
379
+ </button>
380
+ </div>
381
+ </div>
382
+ </div>
383
+
384
+ {selectedMedia.length > 0 && (
385
+ <div className="bg-blue-50 border border-blue-200 rounded-lg p-3 mb-4">
386
+ <div className="flex items-center justify-between">
387
+ <span className="text-sm text-blue-900">{selectedMedia.length} file{selectedMedia.length !== 1 ? 's' : ''} selected</span>
388
+ <div className="flex items-center gap-2">
389
+ <button onClick={async () => { for (const id of selectedMedia) await deleteMedia(id); setSelectedMedia([]); }} className="px-3 py-1.5 text-sm bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors">Delete Selected</button>
390
+ <button onClick={() => setSelectedMedia([])} className="px-3 py-1.5 text-sm border border-gray-300 bg-white rounded-lg hover:bg-gray-50 transition-colors">Cancel</button>
391
+ </div>
392
+ </div>
393
+ </div>
394
+ )}
395
+
396
+ {filteredAndSorted.length === 0 && !loading ? (
397
+ <div className="flex flex-col items-center justify-center py-16 text-center">
398
+ <div className="w-12 h-12 rounded-full bg-gray-100 flex items-center justify-center mb-4">
399
+ <ImageIcon className="w-6 h-6 text-gray-400" />
400
+ </div>
401
+ <h3 className="text-sm font-medium text-gray-900 mb-1">
402
+ {folderSel.type === 'smart' && folderSel.smart === 'uncategorized'
403
+ ? 'No uncategorized media'
404
+ : folderSel.type === 'folder'
405
+ ? 'No media in this folder'
406
+ : 'No media yet'}
407
+ </h3>
408
+ <p className="text-sm text-gray-500">Upload your first file to get started.</p>
409
+ </div>
410
+ ) : (
411
+ <div className="flex gap-4 flex-1 overflow-hidden min-h-0">
412
+ <div className={`bg-white rounded-lg border border-gray-200 overflow-hidden transition-all duration-200 ${panelOpen ? 'flex-1 min-w-0' : 'w-full'}`}>
413
+ {viewMode === 'grid' ? (
414
+ <div className={`grid gap-2 sm:gap-3 p-2 sm:p-3 overflow-y-auto h-full ${
415
+ panelOpen
416
+ ? 'grid-cols-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4'
417
+ : 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-8'
418
+ }`}>
419
+ {filteredAndSorted.map((item) => {
420
+ const isActive = activeItem?.id === item.id;
421
+ const hasIssues = !item.altTag || !item.title || item.usedOn?.length === 0;
422
+ return (
423
+ <div
424
+ key={item.id}
425
+ className={`group relative aspect-square rounded-lg border-2 overflow-hidden cursor-pointer transition-all ${
426
+ isActive ? 'border-blue-500 ring-2 ring-blue-200' :
427
+ selectedMedia.includes(item.id) ? 'border-blue-400 ring-1 ring-blue-100' :
428
+ 'border-gray-200 hover:border-gray-300'
429
+ }`}
430
+ onClick={() => openDetail(item)}
431
+ draggable
432
+ onDragStart={(e) => handleDragStart(e, item.id)}
433
+ >
434
+ <div className="w-full h-full bg-gray-100 flex items-center justify-center">
435
+ <FileImage className="w-6 h-6 sm:w-8 sm:h-8 text-gray-400" />
436
+ </div>
437
+ <div className="absolute inset-0 bg-linear-to-t from-black/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity">
438
+ <div className="absolute bottom-0 left-0 right-0 p-2">
439
+ <p className="text-white text-xs font-medium truncate">{item.name}</p>
440
+ <p className="text-white/80 text-xs">{item.size}</p>
441
+ </div>
442
+ </div>
443
+ {hasIssues && (
444
+ <div className="absolute top-1.5 left-1.5 w-5 h-5 bg-yellow-500 rounded-full flex items-center justify-center" title="Needs attention">
445
+ <AlertTriangle className="w-3 h-3 text-white" />
446
+ </div>
447
+ )}
448
+ <div className="absolute top-1.5 right-1.5 opacity-0 group-hover:opacity-100 transition-opacity" onClick={(e) => handleCheckbox(e, item.id)}>
449
+ <div className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
450
+ selectedMedia.includes(item.id) ? 'bg-blue-600 border-blue-600' : 'bg-white/80 border-gray-400'
451
+ }`}>
452
+ {selectedMedia.includes(item.id) && (
453
+ <svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 12 12">
454
+ <path d="M10 3L4.5 8.5L2 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
455
+ </svg>
456
+ )}
457
+ </div>
458
+ </div>
459
+ </div>
460
+ );
461
+ })}
462
+ </div>
463
+ ) : (
464
+ <div className="overflow-y-auto h-full">
465
+ <table className="w-full">
466
+ <thead className="bg-gray-50 border-b border-gray-200 sticky top-0">
467
+ <tr>
468
+ <th className="w-8 px-3 py-2 text-left"><input type="checkbox" checked={selectedMedia.length === filteredAndSorted.length && filteredAndSorted.length > 0} onChange={handleSelectAll} className="rounded border-gray-300" /></th>
469
+ <th className="w-6 px-1 py-2"></th>
470
+ <th className="px-3 py-2 text-left"><SortHeader label="Name" sortKey="name" /></th>
471
+ <th className="px-3 py-2 text-left"><SortHeader label="Type" sortKey="type" /></th>
472
+ <th className="px-3 py-2 text-left"><SortHeader label="Size" sortKey="size" /></th>
473
+ <th className="px-3 py-2 text-left"><SortHeader label="Uploaded" sortKey="date" /></th>
474
+ <th className="px-3 py-2 text-left text-xs font-medium text-gray-700">Status</th>
475
+ </tr>
476
+ </thead>
477
+ <tbody className="divide-y divide-gray-200">
478
+ {filteredAndSorted.map((item) => {
479
+ const isActive = activeItem?.id === item.id;
480
+ const hasIssues = !item.altTag || !item.title || item.usedOn?.length === 0;
481
+ return (
482
+ <tr
483
+ key={item.id}
484
+ className={`transition-colors cursor-pointer ${isActive ? 'bg-blue-50' : 'hover:bg-gray-50'}`}
485
+ onClick={() => openDetail(item)}
486
+ draggable
487
+ onDragStart={(e) => handleDragStart(e, item.id)}
488
+ >
489
+ <td className="px-3 py-2" onClick={(e) => e.stopPropagation()}>
490
+ <input type="checkbox" checked={selectedMedia.includes(item.id)} onChange={() => handleCheckbox({ stopPropagation: () => {} } as React.MouseEvent, item.id)} className="rounded border-gray-300" />
491
+ </td>
492
+ <td className="px-1 py-2 cursor-grab">
493
+ <GripVertical className="w-4 h-4 text-gray-300" />
494
+ </td>
495
+ <td className="px-3 py-2">
496
+ <div className="flex items-center gap-3">
497
+ <div className="w-10 h-10 bg-gray-100 rounded flex items-center justify-center"><FileImage className="w-5 h-5 text-gray-400" /></div>
498
+ <span className="text-sm font-medium text-gray-900">{item.name}</span>
499
+ </div>
500
+ </td>
501
+ <td className="px-3 py-2 text-sm text-gray-600">{item.format ?? item.type}</td>
502
+ <td className="px-3 py-2 text-sm text-gray-600">{item.size}</td>
503
+ <td className="px-3 py-2 text-sm text-gray-600">{item.date}</td>
504
+ <td className="px-3 py-2">
505
+ {hasIssues ? (
506
+ <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
507
+ <AlertTriangle className="w-3 h-3" /> Needs attention
508
+ </span>
509
+ ) : (
510
+ <span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">Complete</span>
511
+ )}
512
+ </td>
513
+ </tr>
514
+ );
515
+ })}
516
+ </tbody>
517
+ </table>
518
+ </div>
519
+ )}
520
+ </div>
521
+
522
+ {panelOpen && activeItem && (
523
+ <div className="w-80 lg:w-96 bg-white rounded-lg border border-gray-200 overflow-y-auto shrink-0 flex flex-col">
524
+ <div className="flex items-center justify-between p-4 border-b border-gray-200 sticky top-0 bg-white z-10">
525
+ <h3 className="text-sm font-semibold text-gray-900 truncate">{activeItem.name}</h3>
526
+ <button onClick={closeDetail} className="p-1 hover:bg-gray-100 rounded transition-colors" aria-label="Close panel">
527
+ <X className="w-4 h-4 text-gray-500" />
528
+ </button>
529
+ </div>
530
+
531
+ <div className="flex-1 overflow-y-auto">
532
+ <div className="p-4 border-b border-gray-200">
533
+ <div className="aspect-video bg-gray-100 rounded-lg flex items-center justify-center">
534
+ <ImageIcon className="w-12 h-12 text-gray-300" />
535
+ </div>
536
+ </div>
537
+
538
+ {issues.length > 0 && (
539
+ <div className="mx-4 mt-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
540
+ <div className="flex items-start gap-2">
541
+ <AlertTriangle className="w-4 h-4 text-yellow-600 mt-0.5 shrink-0" />
542
+ <div>
543
+ <p className="text-xs font-semibold text-yellow-900 mb-1">{issues.length} issue{issues.length !== 1 ? 's' : ''} found</p>
544
+ <ul className="space-y-0.5">
545
+ {issues.map((issue, i) => (
546
+ <li key={i} className="text-xs text-yellow-800">• {issue}</li>
547
+ ))}
548
+ </ul>
549
+ </div>
550
+ </div>
551
+ <button
552
+ type="button"
553
+ onClick={async () => {
554
+ if (!activeItem.altTag) await handleAiGenerate('alt');
555
+ if (!activeItem.title) await handleAiGenerate('title');
556
+ if (activeItem.sizeBytes > 2000000) await handleAiGenerate('optimize');
557
+ }}
558
+ className="mt-2 w-full flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs bg-yellow-600 text-white rounded-lg hover:bg-yellow-700 transition-colors"
559
+ >
560
+ <Sparkles className="w-3.5 h-3.5" />
561
+ AI Fix All Issues
562
+ </button>
563
+ </div>
564
+ )}
565
+
566
+ <div className="p-4 border-b border-gray-200 space-y-3">
567
+ <h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wide">File Information</h4>
568
+ <div className="grid grid-cols-2 gap-3">
569
+ <div>
570
+ <div className="text-xs text-gray-500 mb-0.5">Format</div>
571
+ <div className="text-sm text-gray-900">{activeItem.format ?? 'Unknown'}</div>
572
+ </div>
573
+ <div>
574
+ <div className="text-xs text-gray-500 mb-0.5">File Size</div>
575
+ <div className="text-sm text-gray-900 flex items-center gap-1">
576
+ {activeItem.size}
577
+ {activeItem.sizeBytes > 2000000 && (
578
+ <span className="text-yellow-600 text-xs">(large)</span>
579
+ )}
580
+ </div>
581
+ </div>
582
+ <div>
583
+ <div className="text-xs text-gray-500 mb-0.5">Dimensions</div>
584
+ <div className="text-sm text-gray-900">{activeItem.dimensions ?? '—'}</div>
585
+ </div>
586
+ <div>
587
+ <div className="text-xs text-gray-500 mb-0.5">Uploaded</div>
588
+ <div className="text-sm text-gray-900">{activeItem.date}</div>
589
+ </div>
590
+ </div>
591
+
592
+ <div>
593
+ <div className="text-xs text-gray-500 mb-1">URL</div>
594
+ <div className="flex items-center gap-1">
595
+ <code className="flex-1 text-xs bg-gray-50 border border-gray-200 px-2 py-1.5 rounded text-gray-700 truncate">{activeItem.url}</code>
596
+ <button onClick={handleCopyUrl} className="p-1.5 hover:bg-gray-100 rounded transition-colors shrink-0" title="Copy URL">
597
+ <Copy className="w-3.5 h-3.5 text-gray-500" />
598
+ </button>
599
+ </div>
600
+ </div>
601
+ </div>
602
+
603
+ <div className="p-4 border-b border-gray-200 space-y-4">
604
+ <h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wide">SEO & Accessibility</h4>
605
+
606
+ <div>
607
+ <div className="flex items-center justify-between mb-1">
608
+ <label className="text-sm font-medium text-gray-700">Alt Tag</label>
609
+ <button
610
+ type="button"
611
+ onClick={() => handleAiGenerate('alt')}
612
+ disabled={aiGenerating === 'alt'}
613
+ className="flex items-center gap-1 text-xs text-indigo-600 hover:text-indigo-700 disabled:opacity-50 transition-colors"
614
+ >
615
+ <Bot className="w-3.5 h-3.5" />
616
+ {aiGenerating === 'alt' ? 'Generating...' : 'AI Generate'}
617
+ </button>
618
+ </div>
619
+ <textarea
620
+ value={editAlt}
621
+ onChange={(e) => setEditAlt(e.target.value)}
622
+ placeholder="Describe this image for accessibility..."
623
+ rows={2}
624
+ className="w-full text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
625
+ />
626
+ {!editAlt && (
627
+ <p className="text-xs text-red-500 mt-1">Required for accessibility and SEO</p>
628
+ )}
629
+ </div>
630
+
631
+ <div>
632
+ <div className="flex items-center justify-between mb-1">
633
+ <label className="text-sm font-medium text-gray-700">Title</label>
634
+ <button
635
+ type="button"
636
+ onClick={() => handleAiGenerate('title')}
637
+ disabled={aiGenerating === 'title'}
638
+ className="flex items-center gap-1 text-xs text-indigo-600 hover:text-indigo-700 disabled:opacity-50 transition-colors"
639
+ >
640
+ <Bot className="w-3.5 h-3.5" />
641
+ {aiGenerating === 'title' ? 'Generating...' : 'AI Generate'}
642
+ </button>
643
+ </div>
644
+ <input
645
+ type="text"
646
+ value={editTitle}
647
+ onChange={(e) => setEditTitle(e.target.value)}
648
+ placeholder="Image title..."
649
+ className="w-full text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
650
+ />
651
+ </div>
652
+
653
+ <div>
654
+ <label className="text-sm font-medium text-gray-700 mb-1 block">File Name</label>
655
+ <input
656
+ type="text"
657
+ value={editFilename}
658
+ onChange={(e) => setEditFilename(e.target.value)}
659
+ className="w-full text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
660
+ />
661
+ </div>
662
+ </div>
663
+
664
+ {activeItem.type === 'image' && activeItem.url && (
665
+ <div className="p-4 border-b border-gray-200">
666
+ <FocalPointPicker
667
+ imageUrl={activeItem.url}
668
+ focalX={focalX}
669
+ focalY={focalY}
670
+ onChange={(x, y) => { setFocalX(x); setFocalY(y); }}
671
+ />
672
+ </div>
673
+ )}
674
+
675
+ <div className="p-4 border-b border-gray-200">
676
+ <h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3">
677
+ Used On {activeItem.usedOn && `(${activeItem.usedOn.length})`}
678
+ </h4>
679
+ {activeItem.usedOn && activeItem.usedOn.length > 0 ? (
680
+ <div className="space-y-2">
681
+ {activeItem.usedOn.map((usage, i) => (
682
+ <button
683
+ key={i}
684
+ type="button"
685
+ onClick={() => onNavigate?.(usage.path)}
686
+ className="w-full flex items-center gap-2 p-2 rounded-lg border border-gray-200 hover:bg-gray-50 transition-colors text-left"
687
+ >
688
+ <Link2 className="w-4 h-4 text-gray-400 shrink-0" />
689
+ <span className="text-sm text-gray-900 flex-1 truncate">{usage.page}</span>
690
+ <ExternalLink className="w-3.5 h-3.5 text-gray-400 shrink-0" />
691
+ </button>
692
+ ))}
693
+ </div>
694
+ ) : (
695
+ <div className="p-3 bg-orange-50 border border-orange-200 rounded-lg">
696
+ <div className="flex items-start gap-2">
697
+ <AlertTriangle className="w-4 h-4 text-orange-600 mt-0.5 shrink-0" />
698
+ <div>
699
+ <p className="text-xs font-medium text-orange-900">Orphaned media</p>
700
+ <p className="text-xs text-orange-700 mt-0.5">This file isn&apos;t used on any page. Consider deleting it to save storage.</p>
701
+ </div>
702
+ </div>
703
+ </div>
704
+ )}
705
+ </div>
706
+
707
+ <div className="p-4 space-y-3">
708
+ <h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wide">AI Optimization</h4>
709
+ <button
710
+ type="button"
711
+ onClick={() => handleAiGenerate('optimize')}
712
+ disabled={aiGenerating === 'optimize'}
713
+ className="w-full flex items-center gap-2 p-3 rounded-lg border border-indigo-200 bg-indigo-50 hover:bg-indigo-100 transition-colors text-left disabled:opacity-50"
714
+ >
715
+ <Sparkles className={`w-5 h-5 text-indigo-600 shrink-0 ${aiGenerating === 'optimize' ? 'animate-spin' : ''}`} />
716
+ <div className="flex-1">
717
+ <div className="text-sm font-medium text-indigo-900">
718
+ {aiGenerating === 'optimize' ? 'Optimizing...' : 'Optimize Image'}
719
+ </div>
720
+ <div className="text-xs text-indigo-700 mt-0.5">
721
+ Compress and convert to modern format (WebP/AVIF)
722
+ </div>
723
+ </div>
724
+ </button>
725
+ <button
726
+ type="button"
727
+ className="w-full flex items-center gap-2 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 transition-colors text-left"
728
+ >
729
+ <Bot className="w-5 h-5 text-gray-500 shrink-0" />
730
+ <div className="flex-1">
731
+ <div className="text-sm font-medium text-gray-900">AI Content Analysis</div>
732
+ <div className="text-xs text-gray-600 mt-0.5">
733
+ Detect objects, faces, text, and suggest categories
734
+ </div>
735
+ </div>
736
+ </button>
737
+ </div>
738
+ </div>
739
+
740
+ <div className="p-4 border-t border-gray-200 bg-white sticky bottom-0 flex items-center gap-2">
741
+ <button
742
+ type="button"
743
+ onClick={handleSaveDetails}
744
+ disabled={saving}
745
+ className="flex-1 px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 flex items-center justify-center gap-2"
746
+ >
747
+ {saving && <Loader2 className="w-4 h-4 animate-spin" />}
748
+ {saving ? 'Saving...' : 'Save Changes'}
749
+ </button>
750
+ <button
751
+ type="button"
752
+ className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
753
+ title="Download"
754
+ >
755
+ <Download className="w-4 h-4 text-gray-600" />
756
+ </button>
757
+ <button
758
+ type="button"
759
+ onClick={() => deleteMedia(activeItem.id)}
760
+ className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
761
+ title="Delete"
762
+ >
763
+ <Trash2 className="w-4 h-4 text-red-600" />
764
+ </button>
765
+ </div>
766
+ </div>
767
+ )}
768
+ </div>
769
+ )}
770
+ </div>
771
+ </div>
772
+ </div>
773
+ );
774
+ }