@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.
- package/dist/editor.js +52746 -36711
- package/package.json +16 -14
- package/src/build-processor.ts +4 -1
- package/src/collection-scanner.ts +425 -48
- package/src/dev-middleware.ts +26 -203
- package/src/editor/api.ts +1 -22
- package/src/editor/components/ai-chat.tsx +3 -3
- package/src/editor/components/ai-tooltip.tsx +2 -1
- package/src/editor/components/block-editor.tsx +13 -108
- package/src/editor/components/collections-browser.tsx +168 -205
- package/src/editor/components/component-card.tsx +49 -0
- package/src/editor/components/confirm-dialog.tsx +34 -47
- package/src/editor/components/create-page-modal.tsx +529 -101
- package/src/editor/components/delete-page-dialog.tsx +100 -0
- package/src/editor/components/fields.tsx +175 -0
- package/src/editor/components/frontmatter-fields.tsx +281 -70
- package/src/editor/components/frontmatter-sidebar.tsx +223 -0
- package/src/editor/components/highlight-overlay.ts +3 -2
- package/src/editor/components/markdown-editor-overlay.tsx +131 -85
- package/src/editor/components/markdown-inline-editor.tsx +74 -5
- package/src/editor/components/mdx-block-view.tsx +102 -0
- package/src/editor/components/mdx-component-picker.tsx +123 -0
- package/src/editor/components/mdx-props-editor.tsx +94 -0
- package/src/editor/components/media-library.tsx +373 -100
- package/src/editor/components/modal-shell.tsx +87 -0
- package/src/editor/components/prop-editor.tsx +52 -0
- package/src/editor/components/redirect-countdown.tsx +3 -1
- package/src/editor/components/redirects-manager.tsx +269 -0
- package/src/editor/components/reference-picker.tsx +203 -0
- package/src/editor/components/seo-editor.tsx +285 -303
- package/src/editor/components/toast/toast-container.tsx +2 -1
- package/src/editor/components/toolbar.tsx +177 -46
- package/src/editor/constants.ts +26 -0
- package/src/editor/editor.ts +112 -0
- package/src/editor/fetch.ts +62 -0
- package/src/editor/index.tsx +19 -1
- package/src/editor/markdown-api.ts +105 -156
- package/src/editor/milkdown-mdx-plugin.tsx +269 -0
- package/src/editor/signals.ts +206 -13
- package/src/editor/types.ts +52 -1
- package/src/handlers/api-routes.ts +251 -0
- package/src/handlers/component-ops.ts +2 -18
- package/src/handlers/markdown-ops.ts +202 -47
- package/src/handlers/page-ops.ts +229 -0
- package/src/handlers/redirect-ops.ts +163 -0
- package/src/handlers/source-writer.ts +157 -1
- package/src/html-processor.ts +14 -2
- package/src/index.ts +78 -14
- package/src/manifest-writer.ts +19 -1
- package/src/media/contember.ts +2 -1
- package/src/media/local.ts +66 -28
- package/src/media/project-images.ts +81 -0
- package/src/media/s3.ts +32 -11
- package/src/media/types.ts +24 -2
- package/src/shared.ts +27 -0
- package/src/source-finder/collection-finder.ts +219 -41
- package/src/source-finder/index.ts +7 -1
- package/src/source-finder/search-index.ts +178 -36
- package/src/source-finder/snippet-utils.ts +423 -3
- package/src/types.ts +111 -2
- package/src/utils.ts +40 -4
|
@@ -1,52 +1,101 @@
|
|
|
1
|
-
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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 &&
|
|
31
|
-
|
|
45
|
+
if (visible && allItems.length === 0) {
|
|
46
|
+
loadFolder(currentFolder)
|
|
32
47
|
}
|
|
33
48
|
}, [visible])
|
|
34
49
|
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
39
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
88
|
-
showToast('
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
})
|
|
164
|
+
const handleDragOver = (e: DragEvent) => {
|
|
165
|
+
e.preventDefault()
|
|
166
|
+
e.stopPropagation()
|
|
167
|
+
}
|
|
115
168
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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 || '
|
|
183
|
+
showToast(result.error || 'Failed to create folder', 'error')
|
|
128
184
|
}
|
|
129
|
-
} catch
|
|
130
|
-
showToast('
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
//
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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
|
-
{/*
|
|
178
|
-
|
|
179
|
-
<
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
{/*
|
|
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
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
|
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
|
|
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
|
+
}
|