@nuasite/cms 0.18.1 → 0.19.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 (61) hide show
  1. package/dist/editor.js +52746 -36711
  2. package/package.json +16 -14
  3. package/src/build-processor.ts +4 -1
  4. package/src/collection-scanner.ts +425 -48
  5. package/src/dev-middleware.ts +26 -203
  6. package/src/editor/api.ts +1 -22
  7. package/src/editor/components/ai-chat.tsx +3 -3
  8. package/src/editor/components/ai-tooltip.tsx +2 -1
  9. package/src/editor/components/block-editor.tsx +13 -108
  10. package/src/editor/components/collections-browser.tsx +168 -205
  11. package/src/editor/components/component-card.tsx +49 -0
  12. package/src/editor/components/confirm-dialog.tsx +34 -47
  13. package/src/editor/components/create-page-modal.tsx +529 -101
  14. package/src/editor/components/delete-page-dialog.tsx +100 -0
  15. package/src/editor/components/fields.tsx +175 -0
  16. package/src/editor/components/frontmatter-fields.tsx +281 -70
  17. package/src/editor/components/frontmatter-sidebar.tsx +223 -0
  18. package/src/editor/components/highlight-overlay.ts +3 -2
  19. package/src/editor/components/markdown-editor-overlay.tsx +131 -85
  20. package/src/editor/components/markdown-inline-editor.tsx +74 -5
  21. package/src/editor/components/mdx-block-view.tsx +102 -0
  22. package/src/editor/components/mdx-component-picker.tsx +123 -0
  23. package/src/editor/components/mdx-props-editor.tsx +94 -0
  24. package/src/editor/components/media-library.tsx +373 -100
  25. package/src/editor/components/modal-shell.tsx +87 -0
  26. package/src/editor/components/prop-editor.tsx +52 -0
  27. package/src/editor/components/redirect-countdown.tsx +3 -1
  28. package/src/editor/components/redirects-manager.tsx +269 -0
  29. package/src/editor/components/reference-picker.tsx +203 -0
  30. package/src/editor/components/seo-editor.tsx +285 -303
  31. package/src/editor/components/toast/toast-container.tsx +2 -1
  32. package/src/editor/components/toolbar.tsx +177 -46
  33. package/src/editor/constants.ts +26 -0
  34. package/src/editor/editor.ts +112 -0
  35. package/src/editor/fetch.ts +62 -0
  36. package/src/editor/index.tsx +19 -1
  37. package/src/editor/markdown-api.ts +105 -156
  38. package/src/editor/milkdown-mdx-plugin.tsx +269 -0
  39. package/src/editor/signals.ts +206 -13
  40. package/src/editor/types.ts +52 -1
  41. package/src/handlers/api-routes.ts +251 -0
  42. package/src/handlers/component-ops.ts +2 -18
  43. package/src/handlers/markdown-ops.ts +202 -47
  44. package/src/handlers/page-ops.ts +229 -0
  45. package/src/handlers/redirect-ops.ts +163 -0
  46. package/src/handlers/source-writer.ts +157 -1
  47. package/src/html-processor.ts +14 -2
  48. package/src/index.ts +78 -14
  49. package/src/manifest-writer.ts +19 -1
  50. package/src/media/contember.ts +2 -1
  51. package/src/media/local.ts +66 -28
  52. package/src/media/project-images.ts +81 -0
  53. package/src/media/s3.ts +32 -11
  54. package/src/media/types.ts +24 -2
  55. package/src/shared.ts +27 -0
  56. package/src/source-finder/collection-finder.ts +219 -41
  57. package/src/source-finder/index.ts +7 -1
  58. package/src/source-finder/search-index.ts +178 -36
  59. package/src/source-finder/snippet-utils.ts +423 -3
  60. package/src/types.ts +111 -2
  61. package/src/utils.ts +40 -4
@@ -1,52 +1,101 @@
1
- import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
2
- import { fetchMediaLibrary, uploadMedia } from '../markdown-api'
3
- import {
4
- config,
5
- isMediaLibraryLoading,
6
- isMediaLibraryOpen,
7
- mediaLibraryItems,
8
- mediaLibraryState,
9
- resetMediaLibraryState,
10
- setMediaLibraryItems,
11
- setMediaLibraryLoading,
12
- showToast,
13
- } from '../signals'
14
- import type { MediaItem } from '../types'
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks'
2
+ import { Z_INDEX } from '../constants'
3
+ import { createMediaFolder, fetchMediaLibrary, fetchProjectImages, uploadMedia } from '../markdown-api'
4
+ import { config, isMediaLibraryOpen, mediaLibraryState, resetMediaLibraryState, showToast } from '../signals'
5
+ import type { MediaFolderItem, MediaItem, MediaTypeFilter } from '../types'
6
+
7
+ const VECTOR_TYPES = new Set(['image/svg+xml', 'image/x-icon'])
8
+
9
+ const TYPE_FILTERS: Array<{ value: MediaTypeFilter; label: string }> = [
10
+ { value: 'all', label: 'All' },
11
+ { value: 'photo', label: 'Photos' },
12
+ { value: 'graphic', label: 'Graphics' },
13
+ { value: 'video', label: 'Videos' },
14
+ { value: 'document', label: 'Documents' },
15
+ ]
16
+
17
+ function matchesTypeFilter(contentType: string, filter: MediaTypeFilter): boolean {
18
+ if (filter === 'all') return true
19
+ if (filter === 'photo') return contentType.startsWith('image/') && !VECTOR_TYPES.has(contentType)
20
+ if (filter === 'graphic') return VECTOR_TYPES.has(contentType)
21
+ if (filter === 'video') return contentType.startsWith('video/')
22
+ if (filter === 'document') return contentType === 'application/pdf'
23
+ return true
24
+ }
15
25
 
16
26
  export function MediaLibrary() {
17
27
  const visible = isMediaLibraryOpen.value
18
- const items = mediaLibraryItems.value
19
- const isLoading = isMediaLibraryLoading.value
20
28
  const insertCallback = mediaLibraryState.value.insertCallback
21
29
 
22
30
  const [uploadProgress, setUploadProgress] = useState<number | null>(null)
23
31
  const [searchQuery, setSearchQuery] = useState('')
32
+ const [allItems, setAllItems] = useState<MediaItem[]>([])
33
+ const [folders, setFolders] = useState<MediaFolderItem[]>([])
34
+ const [currentFolder, setCurrentFolder] = useState('')
35
+ const [typeFilter, setTypeFilter] = useState<MediaTypeFilter>('all')
36
+ const [isLoading, setIsLoading] = useState(false)
37
+ const [showNewFolderInput, setShowNewFolderInput] = useState(false)
38
+ const [newFolderName, setNewFolderName] = useState('')
24
39
  const fileInputRef = useRef<HTMLInputElement>(null)
25
40
  const containerRef = useRef<HTMLDivElement>(null)
41
+ const newFolderInputRef = useRef<HTMLInputElement>(null)
26
42
 
27
- // Load media items on open
28
43
  // biome-ignore lint/correctness/useExhaustiveDependencies: know what i am doing
29
44
  useEffect(() => {
30
- if (visible && items.length === 0) {
31
- loadMediaItems()
45
+ if (visible && allItems.length === 0) {
46
+ loadFolder(currentFolder)
32
47
  }
33
48
  }, [visible])
34
49
 
35
- const loadMediaItems = async () => {
36
- setMediaLibraryLoading(true)
50
+ useEffect(() => {
51
+ if (showNewFolderInput && newFolderInputRef.current) {
52
+ newFolderInputRef.current.focus()
53
+ }
54
+ }, [showNewFolderInput])
55
+
56
+ const loadFolder = async (folder: string) => {
57
+ setIsLoading(true)
37
58
  try {
38
- const result = await fetchMediaLibrary(config.value)
39
- setMediaLibraryItems(result.items)
59
+ const isRoot = !folder
60
+ const [uploads, project] = await Promise.all([
61
+ fetchMediaLibrary(config.value, { folder: folder || undefined }).catch(() => ({ items: [], folders: [] })),
62
+ isRoot
63
+ ? fetchProjectImages(config.value).catch(() => ({ items: [] }))
64
+ : Promise.resolve({ items: [] }),
65
+ ])
66
+
67
+ setFolders((uploads as any).folders ?? [])
68
+
69
+ const seen = new Set<string>()
70
+ const combined: MediaItem[] = []
71
+ for (const item of [...uploads.items, ...project.items]) {
72
+ if (!seen.has(item.url)) {
73
+ seen.add(item.url)
74
+ combined.push(item)
75
+ }
76
+ }
77
+ setAllItems(combined)
40
78
  } catch (error) {
41
79
  showToast('Failed to load media library', 'error')
42
80
  } finally {
43
- setMediaLibraryLoading(false)
81
+ setIsLoading(false)
44
82
  }
45
83
  }
46
84
 
85
+ const navigateToFolder = useCallback((folder: string) => {
86
+ setCurrentFolder(folder)
87
+ setSearchQuery('')
88
+ loadFolder(folder)
89
+ }, [])
90
+
47
91
  const handleClose = useCallback(() => {
48
92
  resetMediaLibraryState()
49
93
  setSearchQuery('')
94
+ setCurrentFolder('')
95
+ setTypeFilter('all')
96
+ setFolders([])
97
+ setShowNewFolderInput(false)
98
+ setNewFolderName('')
50
99
  }, [])
51
100
 
52
101
  const handleSelectImage = useCallback(
@@ -64,28 +113,24 @@ export function MediaLibrary() {
64
113
  fileInputRef.current?.click()
65
114
  }, [])
66
115
 
67
- const handleFileChange = async (e: Event) => {
68
- const target = e.target as HTMLInputElement
69
- const file = target.files?.[0]
70
- if (!file) return
71
-
116
+ const handleUploadFile = async (file: File) => {
72
117
  setUploadProgress(0)
73
118
  try {
74
119
  const result = await uploadMedia(config.value, file, (percent) => {
75
120
  setUploadProgress(percent)
76
- })
121
+ }, { folder: currentFolder || undefined })
77
122
 
78
123
  if (result.success && result.url) {
79
- // Add the new item to the list
80
124
  const newItem: MediaItem = {
81
125
  id: result.id || crypto.randomUUID(),
82
126
  url: result.url,
83
127
  filename: result.filename || file.name,
84
128
  annotation: result.annotation,
85
129
  contentType: file.type,
130
+ folder: currentFolder || undefined,
86
131
  }
87
- setMediaLibraryItems([newItem, ...items])
88
- showToast('Image uploaded successfully', 'success')
132
+ setAllItems((prev) => [newItem, ...prev])
133
+ showToast('File uploaded successfully', 'success')
89
134
  } else {
90
135
  showToast(result.error || 'Upload failed', 'error')
91
136
  }
@@ -93,10 +138,17 @@ export function MediaLibrary() {
93
138
  showToast('Upload failed', 'error')
94
139
  } finally {
95
140
  setUploadProgress(null)
96
- target.value = ''
97
141
  }
98
142
  }
99
143
 
144
+ const handleFileChange = async (e: Event) => {
145
+ const target = e.target as HTMLInputElement
146
+ const file = target.files?.[0]
147
+ if (!file) return
148
+ await handleUploadFile(file)
149
+ target.value = ''
150
+ }
151
+
100
152
  const handleDrop = async (e: DragEvent) => {
101
153
  e.preventDefault()
102
154
  e.stopPropagation()
@@ -106,48 +158,66 @@ export function MediaLibrary() {
106
158
  showToast('Please drop an image file', 'error')
107
159
  return
108
160
  }
161
+ await handleUploadFile(file)
162
+ }
109
163
 
110
- setUploadProgress(0)
111
- try {
112
- const result = await uploadMedia(config.value, file, (percent) => {
113
- setUploadProgress(percent)
114
- })
164
+ const handleDragOver = (e: DragEvent) => {
165
+ e.preventDefault()
166
+ e.stopPropagation()
167
+ }
115
168
 
116
- if (result.success && result.url) {
117
- const newItem: MediaItem = {
118
- id: result.id || crypto.randomUUID(),
119
- url: result.url,
120
- filename: result.filename || file.name,
121
- annotation: result.annotation,
122
- contentType: file.type,
123
- }
124
- setMediaLibraryItems([newItem, ...items])
125
- showToast('Image uploaded successfully', 'success')
169
+ const handleCreateFolder = async () => {
170
+ const name = newFolderName.trim()
171
+ if (!name) return
172
+ if (/[/\\:*?"<>|]/.test(name)) {
173
+ showToast('Invalid folder name', 'error')
174
+ return
175
+ }
176
+ const folderPath = currentFolder ? `${currentFolder}/${name}` : name
177
+ try {
178
+ const result = await createMediaFolder(config.value, folderPath)
179
+ if (result.success) {
180
+ setFolders((prev) => [...prev, { name, path: folderPath }].sort((a, b) => a.name.localeCompare(b.name)))
181
+ showToast('Folder created', 'success')
126
182
  } else {
127
- showToast(result.error || 'Upload failed', 'error')
183
+ showToast(result.error || 'Failed to create folder', 'error')
128
184
  }
129
- } catch (error) {
130
- showToast('Upload failed', 'error')
131
- } finally {
132
- setUploadProgress(null)
185
+ } catch {
186
+ showToast('Failed to create folder', 'error')
133
187
  }
188
+ setNewFolderName('')
189
+ setShowNewFolderInput(false)
134
190
  }
135
191
 
136
- const handleDragOver = (e: DragEvent) => {
137
- e.preventDefault()
138
- e.stopPropagation()
139
- }
192
+ // Client-side filtering: both search query AND type filter
193
+ const filteredItems = useMemo(() => {
194
+ let items = allItems
195
+ if (typeFilter !== 'all') {
196
+ items = items.filter((item) => matchesTypeFilter(item.contentType, typeFilter))
197
+ }
198
+ if (searchQuery) {
199
+ const q = searchQuery.toLowerCase()
200
+ items = items.filter((item) => item.filename.toLowerCase().includes(q))
201
+ }
202
+ return items
203
+ }, [searchQuery, typeFilter, allItems])
140
204
 
141
- // Filter items by search query
142
- const filteredItems = searchQuery
143
- ? items.filter((item) => item.filename.toLowerCase().includes(searchQuery.toLowerCase()))
144
- : items
205
+ // Build breadcrumb segments
206
+ const breadcrumbs = useMemo(() => {
207
+ if (!currentFolder) return []
208
+ const parts = currentFolder.split('/')
209
+ return parts.map((name, i) => ({
210
+ name,
211
+ path: parts.slice(0, i + 1).join('/'),
212
+ }))
213
+ }, [currentFolder])
145
214
 
146
215
  if (!visible) return null
147
216
 
148
217
  return (
149
218
  <div
150
- class="fixed inset-0 z-2147483647 flex items-center justify-center bg-black/60 backdrop-blur-sm"
219
+ style={{ zIndex: Z_INDEX.MODAL }}
220
+ class="fixed inset-0 flex items-center justify-center bg-black/60 backdrop-blur-sm"
151
221
  onClick={handleClose}
152
222
  data-cms-ui
153
223
  >
@@ -174,34 +244,158 @@ export function MediaLibrary() {
174
244
  </button>
175
245
  </div>
176
246
 
177
- {/* Toolbar */}
178
- <div class="flex items-center gap-3 p-4 border-b border-white/10">
179
- <input
180
- type="text"
181
- placeholder="Search images..."
182
- value={searchQuery}
183
- onInput={(e) => setSearchQuery((e.target as HTMLInputElement).value)}
184
- class="flex-1 px-4 py-2.5 bg-white/10 border border-white/20 rounded-cms-md text-sm text-white placeholder:text-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/10"
185
- data-cms-ui
186
- />
187
- <button
188
- type="button"
189
- onClick={handleUploadClick}
190
- class="px-5 py-2.5 bg-cms-primary text-cms-primary-text rounded-cms-pill text-sm font-medium hover:bg-cms-primary-hover transition-colors"
191
- data-cms-ui
192
- >
193
- Upload
194
- </button>
195
- <input
196
- ref={fileInputRef}
197
- type="file"
198
- accept="image/*"
199
- class="hidden"
200
- onChange={handleFileChange}
201
- data-cms-ui
202
- />
247
+ {/* Breadcrumbs */}
248
+ {currentFolder && (
249
+ <div class="flex items-center gap-1.5 px-4 pt-3 text-sm" data-cms-ui>
250
+ <button
251
+ type="button"
252
+ onClick={() => navigateToFolder('')}
253
+ class="text-white/60 hover:text-white transition-colors"
254
+ data-cms-ui
255
+ >
256
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
257
+ <path
258
+ stroke-linecap="round"
259
+ stroke-linejoin="round"
260
+ stroke-width="2"
261
+ d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0h4"
262
+ />
263
+ </svg>
264
+ </button>
265
+ {breadcrumbs.map((crumb, i) => (
266
+ <span key={crumb.path} class="flex items-center gap-1.5">
267
+ <span class="text-white/30">/</span>
268
+ {i === breadcrumbs.length - 1
269
+ ? <span class="text-white font-medium">{crumb.name}</span>
270
+ : (
271
+ <button
272
+ type="button"
273
+ onClick={() => navigateToFolder(crumb.path)}
274
+ class="text-white/60 hover:text-white transition-colors"
275
+ data-cms-ui
276
+ >
277
+ {crumb.name}
278
+ </button>
279
+ )}
280
+ </span>
281
+ ))}
282
+ </div>
283
+ )}
284
+
285
+ {/* Search + Type Filters + Actions */}
286
+ <div class="flex flex-col gap-3 p-4 border-b border-white/10">
287
+ <div class="flex items-center gap-3">
288
+ <input
289
+ type="text"
290
+ placeholder="Search files..."
291
+ value={searchQuery}
292
+ onInput={(e) => setSearchQuery((e.target as HTMLInputElement).value)}
293
+ class="flex-1 px-4 py-2.5 bg-white/10 border border-white/20 rounded-cms-md text-sm text-white placeholder:text-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/10"
294
+ data-cms-ui
295
+ />
296
+ <button
297
+ type="button"
298
+ onClick={() => setShowNewFolderInput((v) => !v)}
299
+ class="px-3 py-2.5 bg-white/10 text-white/70 rounded-cms-md text-sm hover:bg-white/15 hover:text-white transition-colors border border-white/20"
300
+ title="New folder"
301
+ data-cms-ui
302
+ >
303
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
304
+ <path
305
+ stroke-linecap="round"
306
+ stroke-linejoin="round"
307
+ stroke-width="2"
308
+ d="M9 13h6m-3-3v6m-9 1V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"
309
+ />
310
+ </svg>
311
+ </button>
312
+ <button
313
+ type="button"
314
+ onClick={handleUploadClick}
315
+ class="px-5 py-2.5 bg-cms-primary text-cms-primary-text rounded-cms-pill text-sm font-medium hover:bg-cms-primary-hover transition-colors"
316
+ data-cms-ui
317
+ >
318
+ Upload
319
+ </button>
320
+ <input
321
+ ref={fileInputRef}
322
+ type="file"
323
+ accept="image/*,video/mp4,video/webm,application/pdf"
324
+ class="hidden"
325
+ onChange={handleFileChange}
326
+ data-cms-ui
327
+ />
328
+ </div>
329
+
330
+ {/* Type filter tabs */}
331
+ <div class="flex items-center gap-1" data-cms-ui>
332
+ {TYPE_FILTERS.map((filter) => (
333
+ <button
334
+ key={filter.value}
335
+ type="button"
336
+ onClick={() => setTypeFilter(filter.value)}
337
+ class={`px-3 py-1.5 text-xs font-medium rounded-cms-pill transition-colors ${
338
+ typeFilter === filter.value
339
+ ? 'bg-cms-primary text-cms-primary-text'
340
+ : 'text-white/50 hover:text-white hover:bg-white/10'
341
+ }`}
342
+ data-cms-ui
343
+ >
344
+ {filter.label}
345
+ </button>
346
+ ))}
347
+ </div>
203
348
  </div>
204
349
 
350
+ {/* New folder input */}
351
+ {showNewFolderInput && (
352
+ <div class="flex items-center gap-2 px-4 py-3 bg-white/5 border-b border-white/10" data-cms-ui>
353
+ <svg class="w-4 h-4 text-white/40 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
354
+ <path
355
+ stroke-linecap="round"
356
+ stroke-linejoin="round"
357
+ stroke-width="2"
358
+ d="M9 13h6m-3-3v6m-9 1V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"
359
+ />
360
+ </svg>
361
+ <input
362
+ ref={newFolderInputRef}
363
+ type="text"
364
+ placeholder="Folder name..."
365
+ value={newFolderName}
366
+ onInput={(e) => setNewFolderName((e.target as HTMLInputElement).value)}
367
+ onKeyDown={(e) => {
368
+ if (e.key === 'Enter') handleCreateFolder()
369
+ if (e.key === 'Escape') {
370
+ setShowNewFolderInput(false)
371
+ setNewFolderName('')
372
+ }
373
+ }}
374
+ class="flex-1 px-3 py-1.5 bg-white/10 border border-white/20 rounded-cms-md text-sm text-white placeholder:text-white/40 focus:outline-none focus:border-white/40"
375
+ data-cms-ui
376
+ />
377
+ <button
378
+ type="button"
379
+ onClick={handleCreateFolder}
380
+ class="px-3 py-1.5 bg-cms-primary text-cms-primary-text rounded-cms-md text-xs font-medium hover:bg-cms-primary-hover transition-colors"
381
+ data-cms-ui
382
+ >
383
+ Create
384
+ </button>
385
+ <button
386
+ type="button"
387
+ onClick={() => {
388
+ setShowNewFolderInput(false)
389
+ setNewFolderName('')
390
+ }}
391
+ class="px-2 py-1.5 text-white/40 hover:text-white text-xs transition-colors"
392
+ data-cms-ui
393
+ >
394
+ Cancel
395
+ </button>
396
+ </div>
397
+ )}
398
+
205
399
  {/* Upload progress */}
206
400
  {uploadProgress !== null && (
207
401
  <div class="px-4 py-3 bg-white/5 border-b border-white/10">
@@ -217,7 +411,7 @@ export function MediaLibrary() {
217
411
  </div>
218
412
  )}
219
413
 
220
- {/* Grid */}
414
+ {/* Content grid */}
221
415
  <div class="flex-1 overflow-auto p-4">
222
416
  {isLoading
223
417
  ? (
@@ -225,7 +419,7 @@ export function MediaLibrary() {
225
419
  <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-cms-primary" />
226
420
  </div>
227
421
  )
228
- : filteredItems.length === 0
422
+ : folders.length === 0 && filteredItems.length === 0
229
423
  ? (
230
424
  <div class="flex flex-col items-center justify-center h-48 text-white/50">
231
425
  <svg class="w-12 h-12 mb-3 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -237,12 +431,42 @@ export function MediaLibrary() {
237
431
  />
238
432
  </svg>
239
433
  <p class="text-sm">
240
- {searchQuery ? 'No images found' : 'No images yet. Upload one to get started.'}
434
+ {searchQuery || typeFilter !== 'all' ? 'No matching files found' : 'No files yet. Upload one to get started.'}
241
435
  </p>
242
436
  </div>
243
437
  )
244
438
  : (
245
439
  <div class="grid grid-cols-4 gap-3">
440
+ {/* Folders first (hidden when filtering by type or searching) */}
441
+ {!searchQuery && typeFilter === 'all' && folders.map((folder) => (
442
+ <div key={folder.path} class="group relative aspect-square" data-cms-ui>
443
+ <button
444
+ type="button"
445
+ onClick={() => navigateToFolder(folder.path)}
446
+ class="w-full h-full rounded-cms-md overflow-hidden border-2 border-white/10 hover:border-white/30 focus:outline-none focus:border-white/30 transition-all bg-white/5 hover:bg-white/10 flex flex-col items-center justify-center gap-2"
447
+ data-cms-ui
448
+ >
449
+ <svg
450
+ class="w-10 h-10 text-white/40 group-hover:text-white/60 transition-colors"
451
+ fill="none"
452
+ stroke="currentColor"
453
+ viewBox="0 0 24 24"
454
+ >
455
+ <path
456
+ stroke-linecap="round"
457
+ stroke-linejoin="round"
458
+ stroke-width="1.5"
459
+ d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"
460
+ />
461
+ </svg>
462
+ <p class="text-xs text-white/60 group-hover:text-white/80 truncate max-w-full px-2 transition-colors">
463
+ {folder.name}
464
+ </p>
465
+ </button>
466
+ </div>
467
+ ))}
468
+
469
+ {/* Files */}
246
470
  {filteredItems.map((item) => (
247
471
  <div key={item.id} class="group relative aspect-square" data-cms-ui>
248
472
  <button
@@ -251,11 +475,19 @@ export function MediaLibrary() {
251
475
  class="w-full h-full rounded-cms-md overflow-hidden border-2 border-white/10 hover:border-cms-primary focus:outline-none focus:border-cms-primary transition-all"
252
476
  data-cms-ui
253
477
  >
254
- <img
255
- src={item.url}
256
- alt={item.annotation || item.filename}
257
- class="w-full h-full object-cover"
258
- />
478
+ {item.contentType.startsWith('image/')
479
+ ? (
480
+ <img
481
+ src={item.url}
482
+ alt={item.annotation || item.filename}
483
+ class="w-full h-full object-cover"
484
+ />
485
+ )
486
+ : (
487
+ <div class="w-full h-full flex flex-col items-center justify-center bg-white/5 gap-2">
488
+ <FileTypeIcon contentType={item.contentType} />
489
+ </div>
490
+ )}
259
491
  <div class="absolute inset-0 bg-black/0 group-hover:bg-black/30 transition-colors pointer-events-none" />
260
492
  <div class="absolute bottom-0 left-0 right-0 p-2 bg-linear-to-t from-black/70 to-transparent opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none">
261
493
  <p class="text-xs text-white truncate">{item.filename}</p>
@@ -272,7 +504,12 @@ export function MediaLibrary() {
272
504
  data-cms-ui
273
505
  >
274
506
  <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
275
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
507
+ <path
508
+ stroke-linecap="round"
509
+ stroke-linejoin="round"
510
+ stroke-width="2"
511
+ d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
512
+ />
276
513
  </svg>
277
514
  </button>
278
515
  <div class="absolute right-0 top-full mt-1 w-48 p-2 bg-black/90 text-white text-xs rounded-md opacity-0 invisible group-hover/tooltip:opacity-100 group-hover/tooltip:visible transition-all z-10 pointer-events-none">
@@ -287,11 +524,47 @@ export function MediaLibrary() {
287
524
  )}
288
525
  </div>
289
526
 
290
- {/* Footer with drop hint */}
291
527
  <div class="px-4 py-4 border-t border-white/10 bg-white/5 text-center text-sm text-white/50 rounded-b-cms-xl">
292
- Drag and drop images here to upload
528
+ Drag and drop files here to upload{currentFolder ? ` to ${currentFolder}` : ''}
293
529
  </div>
294
530
  </div>
295
531
  </div>
296
532
  )
297
533
  }
534
+
535
+ function FileTypeIcon({ contentType }: { contentType: string }) {
536
+ if (contentType.startsWith('video/')) {
537
+ return (
538
+ <svg class="w-10 h-10 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
539
+ <path
540
+ stroke-linecap="round"
541
+ stroke-linejoin="round"
542
+ stroke-width="1.5"
543
+ d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"
544
+ />
545
+ </svg>
546
+ )
547
+ }
548
+ if (contentType === 'application/pdf') {
549
+ return (
550
+ <svg class="w-10 h-10 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
551
+ <path
552
+ stroke-linecap="round"
553
+ stroke-linejoin="round"
554
+ stroke-width="1.5"
555
+ d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"
556
+ />
557
+ </svg>
558
+ )
559
+ }
560
+ return (
561
+ <svg class="w-10 h-10 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
562
+ <path
563
+ stroke-linecap="round"
564
+ stroke-linejoin="round"
565
+ stroke-width="1.5"
566
+ d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
567
+ />
568
+ </svg>
569
+ )
570
+ }