@xleddyl/nuxt-cms 0.1.59 → 0.1.61

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 (38) hide show
  1. package/dist/module.json +1 -1
  2. package/dist/module.mjs +53 -23
  3. package/dist/runtime/app/components/cms/MediaDetail.d.vue.ts +16 -0
  4. package/dist/runtime/app/components/cms/MediaDetail.vue +169 -0
  5. package/dist/runtime/app/components/cms/MediaDetail.vue.d.ts +16 -0
  6. package/dist/runtime/app/components/cms/MediaField.vue +9 -2
  7. package/dist/runtime/app/components/cms/MediaFolderPicker.d.vue.ts +2 -2
  8. package/dist/runtime/app/components/cms/MediaFolderPicker.vue +55 -54
  9. package/dist/runtime/app/components/cms/MediaFolderPicker.vue.d.ts +2 -2
  10. package/dist/runtime/app/components/cms/MediaGallery.d.vue.ts +8 -2
  11. package/dist/runtime/app/components/cms/MediaGallery.vue +913 -336
  12. package/dist/runtime/app/components/cms/MediaGallery.vue.d.ts +8 -2
  13. package/dist/runtime/app/components/cms/MediaUpload.vue +10 -103
  14. package/dist/runtime/app/composables/cms-media-library.d.ts +75 -0
  15. package/dist/runtime/app/composables/cms-media-library.js +151 -0
  16. package/dist/runtime/app/composables/cms-media-uploader.d.ts +12 -0
  17. package/dist/runtime/app/composables/cms-media-uploader.js +117 -0
  18. package/dist/runtime/app/pages/admin-media.vue +14 -2
  19. package/dist/runtime/assets/main.css +1 -1
  20. package/dist/runtime/server/api/media-delete.post.d.ts +4 -0
  21. package/dist/runtime/server/api/media-delete.post.js +23 -0
  22. package/dist/runtime/server/api/media-folder.delete.js +6 -16
  23. package/dist/runtime/server/api/media-folder.patch.d.ts +5 -0
  24. package/dist/runtime/server/api/media-folder.patch.js +68 -0
  25. package/dist/runtime/server/api/media-folder.post.js +3 -15
  26. package/dist/runtime/server/api/media-move.post.d.ts +5 -0
  27. package/dist/runtime/server/api/media-move.post.js +28 -0
  28. package/dist/runtime/server/api/media-usage.post.d.ts +5 -0
  29. package/dist/runtime/server/api/media-usage.post.js +15 -0
  30. package/dist/runtime/server/utils/media-folders.d.ts +86 -0
  31. package/dist/runtime/server/utils/media-folders.js +37 -0
  32. package/dist/runtime/server/utils/media-references.d.ts +7 -1
  33. package/dist/runtime/server/utils/media-references.js +40 -0
  34. package/dist/runtime/server/utils/media-usage.d.ts +2 -0
  35. package/dist/runtime/server/utils/media-usage.js +18 -0
  36. package/dist/runtime/shared/index.d.ts +23 -1
  37. package/dist/runtime/shared/index.js +86 -2
  38. package/package.json +1 -1
@@ -6,12 +6,170 @@
6
6
  body="Set cms.media in nuxt.config to enable uploads."
7
7
  />
8
8
 
9
- <div v-else class="cms-media-gallery">
10
- <CmsInput v-model="search" icon="magnifying-glass" placeholder="Search media…" />
9
+ <div
10
+ v-else
11
+ class="cms-media-gallery"
12
+ :class="{ 'is-compact': selectable }"
13
+ @dragenter="onGalleryDragEnter"
14
+ @dragover="onGalleryDragOver"
15
+ @dragleave="onGalleryDragLeave"
16
+ @drop="onGalleryDrop"
17
+ >
18
+ <div class="cms-media-toolbar">
19
+ <CmsInput
20
+ v-model="search"
21
+ icon="magnifying-glass"
22
+ :placeholder="
23
+ folder && !searchEverywhere ? `Search in ${mediaFolderName(folder)}\u2026` : 'Search media\u2026'
24
+ "
25
+ class="cms-media-search"
26
+ >
27
+ <template v-if="search" #trailing>
28
+ <button
29
+ type="button"
30
+ class="cms-media-search-clear"
31
+ aria-label="Clear search"
32
+ @click="search = ''"
33
+ >
34
+ <CmsIcon name="x-mark" class="size-4" />
35
+ </button>
36
+ </template>
37
+ </CmsInput>
38
+ <div class="cms-media-toolbar-actions">
39
+ <CmsDropdownMenu :items="sortItems" :content="{ align: 'end' }">
40
+ <CmsButton
41
+ :label="sortLabels[activeSort]"
42
+ icon="arrows-up-down"
43
+ variant="ghost"
44
+ color="neutral"
45
+ size="sm"
46
+ />
47
+ </CmsDropdownMenu>
48
+ <div class="cms-media-view-toggle" role="group" aria-label="View">
49
+ <CmsButton
50
+ icon="squares-2x2"
51
+ size="sm"
52
+ :variant="view === 'grid' ? 'soft' : 'ghost'"
53
+ color="neutral"
54
+ aria-label="Grid view"
55
+ @click="view = 'grid'"
56
+ />
57
+ <CmsButton
58
+ icon="list-bullet"
59
+ size="sm"
60
+ :variant="view === 'list' ? 'soft' : 'ghost'"
61
+ color="neutral"
62
+ aria-label="List view"
63
+ @click="view = 'list'"
64
+ />
65
+ </div>
66
+ <template v-if="canUpload">
67
+ <CmsButton
68
+ label="New folder"
69
+ icon="folder-plus"
70
+ variant="soft"
71
+ size="sm"
72
+ :disabled="!canNest"
73
+ @click="openNewFolder"
74
+ />
75
+ <CmsButton
76
+ label="Upload"
77
+ icon="cloud-arrow-up"
78
+ size="sm"
79
+ @click="uploadOpen = true"
80
+ />
81
+ </template>
82
+ </div>
83
+ </div>
11
84
 
12
85
  <p v-if="sourceHint" class="cms-media-source">{{ sourceHint }}</p>
13
86
 
14
- <div v-if="showTypeFilters || folderChips.length || !readOnly" class="cms-media-filters">
87
+ <div class="cms-media-nav">
88
+ <nav class="cms-media-crumbs" aria-label="Folder">
89
+ <template v-for="(crumb, index) in crumbs" :key="crumb.folder ?? ''">
90
+ <CmsIcon v-if="index" name="chevron-right" class="cms-breadcrumb-separator size-3" />
91
+ <button
92
+ type="button"
93
+ class="cms-media-crumb"
94
+ :class="{
95
+ 'is-current': crumb.folder === folder,
96
+ 'is-drop': dropTarget === targetId(crumb.folder)
97
+ }"
98
+ :aria-current="crumb.folder === folder ? 'page' : void 0"
99
+ @click="openFolder(crumb.folder)"
100
+ @dragover="onTargetDragOver($event, crumb.folder)"
101
+ @dragleave="onTargetDragLeave(crumb.folder)"
102
+ @drop="onTargetDrop($event, crumb.folder)"
103
+ >
104
+ <CmsIcon v-if="!crumb.folder" name="home" class="size-3.5" />
105
+ {{ crumb.label }}
106
+ </button>
107
+ </template>
108
+ </nav>
109
+ <div v-if="folder && canManage" class="cms-media-folder-actions">
110
+ <CmsButton
111
+ label="Rename"
112
+ icon="pencil-square"
113
+ variant="ghost"
114
+ color="neutral"
115
+ size="xs"
116
+ @click="openRename"
117
+ />
118
+ <CmsButton
119
+ label="Delete folder"
120
+ icon="trash"
121
+ variant="ghost"
122
+ color="error"
123
+ size="xs"
124
+ @click="removeFolder(folder)"
125
+ />
126
+ </div>
127
+ </div>
128
+
129
+ <div v-if="uploader.uploading.value" class="cms-media-progress">
130
+ <CmsIcon name="arrow-path" class="size-4 animate-spin" />
131
+ Uploading {{ Math.min(uploader.done.value + 1, uploader.total.value) }} of
132
+ {{ uploader.total.value }} to {{ folder ?? "Library root" }}…
133
+ </div>
134
+
135
+ <div v-if="selection.length" class="cms-media-selection">
136
+ <span class="cms-media-selection-count">{{ selection.length }} selected</span>
137
+ <CmsButton
138
+ v-if="selection.length < visibleFiles.length"
139
+ :label="`Select all ${visibleFiles.length}`"
140
+ variant="ghost"
141
+ color="neutral"
142
+ size="xs"
143
+ @click="selectAll"
144
+ />
145
+ <div class="cms-media-selection-actions">
146
+ <CmsButton
147
+ label="Move to…"
148
+ icon="folder-arrow-down"
149
+ variant="soft"
150
+ size="xs"
151
+ @click="openMove"
152
+ />
153
+ <CmsButton
154
+ label="Delete"
155
+ icon="trash"
156
+ variant="soft"
157
+ color="error"
158
+ size="xs"
159
+ @click="removeItems(selectedItems)"
160
+ />
161
+ <CmsButton
162
+ icon="x-mark"
163
+ variant="ghost"
164
+ color="neutral"
165
+ size="xs"
166
+ aria-label="Clear selection"
167
+ @click="clearSelection"
168
+ />
169
+ </div>
170
+ </div>
171
+
172
+ <div v-else-if="showTypeFilters || query && folder" class="cms-media-filters">
15
173
  <template v-if="showTypeFilters">
16
174
  <button
17
175
  v-for="f in filters"
@@ -24,171 +182,276 @@
24
182
  {{ filterLabels[f] }}
25
183
  </button>
26
184
  </template>
27
- <template v-if="folderChips.length">
28
- <span v-if="showTypeFilters" class="cms-media-filter-separator" />
29
- <span
30
- v-for="chip in folderChips"
31
- :key="chip.name"
32
- class="cms-pill-group"
33
- :class="{ 'is-active': folder === chip.name }"
34
- >
185
+ <button
186
+ v-if="query && folder"
187
+ type="button"
188
+ class="cms-pill cms-media-scope"
189
+ :class="{ 'is-active': searchEverywhere }"
190
+ @click="searchEverywhere = !searchEverywhere"
191
+ >
192
+ <CmsIcon name="magnifying-glass" class="size-3" />Search all folders
193
+ </button>
194
+ </div>
195
+
196
+ <CmsSpinner v-if="library.loading.value" />
197
+
198
+ <CmsEmptyState v-else-if="loadError" icon="exclamation-triangle" title="Could not load media">
199
+ <CmsButton label="Retry" icon="arrow-path" variant="soft" @click="library.reload()" />
200
+ </CmsEmptyState>
201
+
202
+ <template v-else>
203
+ <section v-if="recent.length" class="cms-media-section">
204
+ <h3 class="cms-media-section-title"><CmsIcon name="clock" class="size-3.5" />Recent</h3>
205
+ <div class="cms-media-grid is-compact is-recent">
35
206
  <button
207
+ v-for="item in recent"
208
+ :key="item.key"
36
209
  type="button"
37
- class="cms-pill"
38
- :class="{ 'is-active': folder === chip.name, 'is-empty': chip.empty }"
39
- @click="folder = folder === chip.name ? null : chip.name"
210
+ class="cms-card cms-media-tile is-selectable"
211
+ :title="mediaFilename(item.key)"
212
+ @click="emitSelect(item)"
40
213
  >
41
- <CmsIcon name="folder" class="size-3" />{{ chip.name }}
42
- <span v-if="chip.count" class="cms-pill-count">{{ chip.count }}</span>
214
+ <div class="cms-media-preview">
215
+ <img
216
+ v-if="item.type === 'image' && item.url"
217
+ :src="item.url"
218
+ alt=""
219
+ loading="lazy"
220
+ />
221
+ <video
222
+ v-else-if="item.type === 'video' && item.url"
223
+ :src="item.url"
224
+ preload="metadata"
225
+ muted
226
+ playsinline
227
+ />
228
+ <CmsIcon v-else :name="mediaIconFor(item.type)" class="size-6" />
229
+ </div>
43
230
  </button>
231
+ </div>
232
+ </section>
233
+
234
+ <section v-if="visibleFolders.length" class="cms-media-section">
235
+ <h3 v-if="query" class="cms-media-section-title">Folders</h3>
236
+ <div class="cms-media-folders">
44
237
  <button
45
- v-if="!readOnly"
238
+ v-for="child in visibleFolders"
239
+ :key="child"
46
240
  type="button"
47
- class="cms-pill-discard"
48
- :aria-label="`Delete folder ${chip.name}`"
49
- @click="deleteFolder(chip.name)"
241
+ class="cms-media-folder-tile"
242
+ :class="{ 'is-drop': dropTarget === targetId(child) }"
243
+ :draggable="canManage"
244
+ :title="child"
245
+ @click="openFolder(child)"
246
+ @dragstart="onFolderDragStart($event, child)"
247
+ @dragend="onDragEnd"
248
+ @dragover="onTargetDragOver($event, child)"
249
+ @dragleave="onTargetDragLeave(child)"
250
+ @drop="onTargetDrop($event, child)"
50
251
  >
51
- <CmsIcon name="x-mark" class="size-3" />
252
+ <CmsIcon
253
+ :name="dropTarget === targetId(child) ? 'folder-open' : 'folder'"
254
+ class="cms-media-folder-tile-icon size-6"
255
+ />
256
+ <span class="cms-media-folder-tile-text">
257
+ <span class="cms-media-folder-tile-name">
258
+ {{ query ? child : mediaFolderName(child) }}
259
+ </span>
260
+ <span class="cms-media-folder-tile-meta">{{ folderMeta(child) }}</span>
261
+ </span>
52
262
  </button>
53
- </span>
54
- </template>
55
- <div v-if="!readOnly" class="cms-media-filters-actions">
56
- <CmsButton
57
- label="New folder"
58
- icon="folder-plus"
59
- variant="soft"
60
- size="sm"
61
- @click="openNewFolder"
62
- />
63
- <CmsButton icon="plus" size="sm" aria-label="Upload media" @click="uploadOpen = true" />
64
- </div>
65
- </div>
263
+ </div>
264
+ </section>
66
265
 
67
- <CmsSpinner v-if="loading" />
266
+ <section v-if="visibleFiles.length" class="cms-media-section">
267
+ <h3
268
+ v-if="query || visibleFolders.length || recent.length"
269
+ class="cms-media-section-title"
270
+ >
271
+ {{
272
+ query ? `${visibleFiles.length} file${visibleFiles.length > 1 ? "s" : ""}` : "Files"
273
+ }}
274
+ </h3>
68
275
 
69
- <div v-else-if="visible.length" class="cms-media-grid" :class="{ 'is-compact': selectable }">
70
- <component
71
- :is="selectable ? 'button' : 'div'"
72
- v-for="item in paged"
73
- :key="item.key"
74
- :type="selectable ? 'button' : void 0"
75
- :role="!selectable && canEditAlt ? 'button' : void 0"
76
- :tabindex="!selectable && canEditAlt ? 0 : void 0"
77
- class="cms-card cms-media-tile"
78
- :class="{ 'is-selectable': selectable || canEditAlt }"
79
- @click="onTileClick(item)"
80
- @keydown.enter="!selectable && canEditAlt && openEdit(item)"
81
- >
82
- <div class="cms-media-preview">
83
- <img
84
- v-if="item.type === 'image' && item.url"
85
- :src="item.url"
86
- alt=""
87
- loading="lazy"
88
- />
89
- <video
90
- v-else-if="item.type === 'video' && item.url"
91
- :src="item.url"
92
- preload="metadata"
93
- muted
94
- playsinline
95
- />
96
- <CmsIcon v-else :name="mediaIconFor(item.type)" class="size-8" />
97
- <div v-if="!selectable && !readOnly" class="cms-media-actions">
98
- <CmsButton
99
- icon="trash"
100
- size="xs"
101
- color="error"
102
- variant="solid"
103
- aria-label="Delete"
104
- @click.stop="remove(item)"
105
- />
106
- </div>
107
- <div class="cms-media-info">
108
- {{ extension(item.key) }} · {{ formatSize(item.size)
109
- }}<template v-if="item.width && item.height">
110
- · {{ item.width }}×{{ item.height }}
111
- </template>
276
+ <div
277
+ v-if="view === 'grid'"
278
+ class="cms-media-grid"
279
+ :class="{ 'is-compact': selectable }"
280
+ >
281
+ <div
282
+ v-for="item in paged"
283
+ :key="item.key"
284
+ role="button"
285
+ tabindex="0"
286
+ class="cms-card cms-media-tile is-selectable"
287
+ :class="{
288
+ 'is-checked': isSelected(item.key),
289
+ 'is-dragging': draggingKeys.includes(item.key)
290
+ }"
291
+ :draggable="canManage"
292
+ :aria-pressed="canManage ? isSelected(item.key) : void 0"
293
+ @click="onItemClick(item, $event)"
294
+ @keydown.enter.prevent="onItemClick(item, $event)"
295
+ @keydown.space.prevent="
296
+ canManage ? toggleSelect(item.key, $event) : onItemClick(item, $event)
297
+ "
298
+ @dragstart="onItemDragStart($event, item)"
299
+ @dragend="onDragEnd"
300
+ >
301
+ <div class="cms-media-preview">
302
+ <img
303
+ v-if="item.type === 'image' && item.url"
304
+ :src="item.url"
305
+ alt=""
306
+ loading="lazy"
307
+ draggable="false"
308
+ />
309
+ <video
310
+ v-else-if="item.type === 'video' && item.url"
311
+ :src="item.url"
312
+ preload="metadata"
313
+ muted
314
+ playsinline
315
+ />
316
+ <CmsIcon v-else :name="mediaIconFor(item.type)" class="size-8" />
317
+ <button
318
+ v-if="canManage"
319
+ type="button"
320
+ class="cms-media-check"
321
+ :class="{ 'is-on': isSelected(item.key), 'is-visible': selection.length }"
322
+ :aria-label="isSelected(item.key) ? 'Deselect' : 'Select'"
323
+ tabindex="-1"
324
+ @click.stop="toggleSelect(item.key, $event)"
325
+ >
326
+ <CmsIcon name="check" class="size-3.5" />
327
+ </button>
328
+ <div class="cms-media-info">
329
+ {{ tileInfo(item) }}
330
+ </div>
331
+ </div>
332
+ <div class="cms-media-meta">
333
+ <div class="cms-media-name" :title="item.key">
334
+ {{ mediaFilename(item.key) }}
335
+ </div>
336
+ <div v-if="query && item.folder" class="cms-media-folder">
337
+ <CmsIcon name="folder" class="size-3" />{{ item.folder }}
338
+ </div>
339
+ </div>
112
340
  </div>
113
341
  </div>
114
- <div class="cms-media-meta">
115
- <div class="cms-media-name" :title="item.key">
116
- {{ mediaFilename(item.key) }}
117
- </div>
118
- <div v-if="item.folder" class="cms-media-folder">
119
- <CmsIcon name="folder" class="size-3" />{{ item.folder }}
342
+
343
+ <div v-else class="cms-card cms-media-list">
344
+ <div
345
+ v-for="item in paged"
346
+ :key="item.key"
347
+ role="button"
348
+ tabindex="0"
349
+ class="cms-media-row"
350
+ :class="{
351
+ 'is-checked': isSelected(item.key),
352
+ 'is-dragging': draggingKeys.includes(item.key)
353
+ }"
354
+ :draggable="canManage"
355
+ @click="onItemClick(item, $event)"
356
+ @keydown.enter.prevent="onItemClick(item, $event)"
357
+ @keydown.space.prevent="
358
+ canManage ? toggleSelect(item.key, $event) : onItemClick(item, $event)
359
+ "
360
+ @dragstart="onItemDragStart($event, item)"
361
+ @dragend="onDragEnd"
362
+ >
363
+ <button
364
+ v-if="canManage"
365
+ type="button"
366
+ class="cms-media-check is-visible"
367
+ :class="{ 'is-on': isSelected(item.key) }"
368
+ :aria-label="isSelected(item.key) ? 'Deselect' : 'Select'"
369
+ tabindex="-1"
370
+ @click.stop="toggleSelect(item.key, $event)"
371
+ >
372
+ <CmsIcon name="check" class="size-3.5" />
373
+ </button>
374
+ <CmsMediaThumb :value="item.key" />
375
+ <span class="cms-media-row-name" :title="item.key">
376
+ {{ mediaFilename(item.key) }}
377
+ <span v-if="query && item.folder" class="cms-media-folder">
378
+ <CmsIcon name="folder" class="size-3" />{{ item.folder }}
379
+ </span>
380
+ </span>
381
+ <span class="cms-media-row-cell is-mono">{{ extension(item.key) }}</span>
382
+ <span class="cms-media-row-cell">{{
383
+ item.size != null ? formatFileSize(item.size) : ""
384
+ }}</span>
385
+ <span class="cms-media-row-cell is-wide">
386
+ {{ item.width && item.height ? `${item.width}\xD7${item.height}` : "" }}
387
+ </span>
388
+ <span class="cms-media-row-cell is-wide">
389
+ {{ item.createdAt ? new Date(item.createdAt).toLocaleDateString() : "" }}
390
+ </span>
120
391
  </div>
121
392
  </div>
122
- </component>
123
- </div>
124
-
125
- <CmsEmptyState v-else-if="loadError" icon="exclamation-triangle" title="Could not load media">
126
- <CmsButton label="Retry" icon="arrow-path" variant="soft" @click="reload" />
127
- </CmsEmptyState>
393
+ </section>
128
394
 
129
- <CmsEmptyState
130
- v-else-if="items.length"
131
- icon="magnifying-glass"
132
- title="No matching media"
133
- body="Try a different search or filter."
134
- />
395
+ <CmsEmptyState v-if="!visibleFiles.length && !visibleFolders.length" v-bind="emptyState">
396
+ <CmsButton
397
+ v-if="query"
398
+ label="Clear search"
399
+ icon="x-mark"
400
+ variant="soft"
401
+ @click="search = ''"
402
+ />
403
+ <CmsButton
404
+ v-else-if="canUpload && !hasTypedFiles"
405
+ label="Upload"
406
+ icon="cloud-arrow-up"
407
+ variant="soft"
408
+ @click="uploadOpen = true"
409
+ />
410
+ </CmsEmptyState>
135
411
 
136
- <CmsEmptyState
137
- v-else
138
- icon="photo"
139
- :title="readOnly ? 'No media' : 'No media yet'"
140
- :body="readOnly ? 'No media registered.' : 'Files you upload will show up here.'"
141
- >
142
- <CmsButton
143
- v-if="!readOnly"
144
- label="Upload"
145
- icon="plus"
146
- variant="soft"
147
- @click="uploadOpen = true"
412
+ <CmsPagination
413
+ v-if="visibleFiles.length > PAGE_SIZE"
414
+ v-model:page="page"
415
+ :total="visibleFiles.length"
416
+ :items-per-page="PAGE_SIZE"
148
417
  />
149
- </CmsEmptyState>
418
+ </template>
150
419
 
151
- <CmsPagination
152
- v-if="!loading && visible.length"
153
- v-model:page="page"
154
- :total="visible.length"
155
- :items-per-page="PAGE_SIZE"
156
- />
420
+ <div v-if="fileDragActive" class="cms-media-drop-overlay">
421
+ <CmsIcon name="cloud-arrow-up" class="size-8" />
422
+ <span
423
+ >Drop to upload to <strong>{{ dropTargetLabel }}</strong></span
424
+ >
425
+ </div>
157
426
 
158
- <CmsModal v-if="!readOnly" v-model:open="uploadOpen" title="Upload media">
427
+ <CmsModal v-if="canUpload" v-model:open="uploadOpen" title="Upload media">
159
428
  <template #body>
160
- <div class="cms-form">
161
- <CmsFormField label="Destination folder">
162
- <CmsMediaFolderPicker
163
- v-model="uploadFolder"
164
- :folders="folderNames"
165
- @create="onFolderCreate"
166
- />
167
- </CmsFormField>
168
- <CmsMediaUpload
169
- :multiple="!selectable"
170
- :media-type="mediaType"
171
- :accept="accept"
172
- :folder="uploadFolder"
173
- @uploaded="onUploaded"
174
- />
175
- </div>
429
+ <CmsMediaUpload
430
+ :multiple="!selectable"
431
+ :media-type="mediaType"
432
+ :accept="accept"
433
+ :folder="folder"
434
+ @uploaded="onUploaded"
435
+ />
436
+ <p class="cms-form-hint cms-media-upload-hint">
437
+ Tip: you can also drop files anywhere on the library, or on a folder.
438
+ </p>
176
439
  </template>
177
440
  </CmsModal>
178
441
 
179
- <CmsModal v-if="!readOnly" v-model:open="newFolderOpen" title="New folder" size="sm">
442
+ <CmsModal v-if="canUpload" v-model:open="newFolderOpen" title="New folder" size="sm">
180
443
  <template #body>
181
444
  <div class="cms-form">
182
445
  <CmsFormField label="Name">
183
446
  <CmsInput
184
- v-model="newFolderName"
447
+ v-model="folderName"
185
448
  autofocus
186
- placeholder="e.g. blog/covers"
449
+ placeholder="e.g. covers"
187
450
  @keydown.enter.prevent="createFolder"
188
451
  />
189
452
  <p class="cms-form-hint">
190
453
  {{
191
- newFolderSlug ? `Files uploaded here are stored under ${newFolderSlug}/` : "Letters, numbers and dashes. Use / for nesting."
454
+ newFolderPath ? `Creates ${newFolderPath}/` : "Letters, numbers and dashes."
192
455
  }}
193
456
  </p>
194
457
  </CmsFormField>
@@ -199,312 +462,626 @@
199
462
  color="neutral"
200
463
  @click="newFolderOpen = false"
201
464
  />
202
- <CmsButton label="Create" :disabled="!newFolderSlug" @click="createFolder" />
465
+ <CmsButton
466
+ label="Create"
467
+ :loading="busy"
468
+ :disabled="!newFolderPath"
469
+ @click="createFolder"
470
+ />
203
471
  </div>
204
472
  </div>
205
473
  </template>
206
474
  </CmsModal>
207
475
 
208
- <CmsModal
209
- v-if="canEditAlt"
210
- :open="!!editing"
211
- :title="editing ? mediaFilename(editing.key) : ''"
212
- @update:open="
213
- (open) => {
214
- if (!open) editing = null;
215
- }
216
- "
217
- >
476
+ <CmsModal v-if="canManage" v-model:open="renameOpen" title="Rename folder" size="sm">
218
477
  <template #body>
219
478
  <div class="cms-form">
220
- <CmsFormField label="Alt text">
221
- <CmsInput v-model="editAlt" />
479
+ <CmsFormField label="Name">
480
+ <CmsInput v-model="folderName" autofocus @keydown.enter.prevent="renameFolder" />
222
481
  </CmsFormField>
223
- <CmsFormField v-if="canUpload" label="Folder">
482
+ <CmsFormField label="Location">
224
483
  <CmsMediaFolderPicker
225
- v-model="editFolder"
226
- :folders="folderNames"
227
- @create="onFolderCreate"
484
+ v-model="renameParent"
485
+ :folders="parentCandidates"
486
+ :exclude="folder"
228
487
  />
229
488
  </CmsFormField>
489
+ <p class="cms-form-hint">
490
+ {{
491
+ renamePath ? `Moves ${folder}/ to ${renamePath}/` : "Letters, numbers and dashes."
492
+ }}
493
+ </p>
494
+ <div class="cms-actions is-end">
495
+ <CmsButton
496
+ label="Cancel"
497
+ variant="ghost"
498
+ color="neutral"
499
+ @click="renameOpen = false"
500
+ />
501
+ <CmsButton
502
+ label="Save"
503
+ :loading="busy"
504
+ :disabled="!renamePath || renamePath === folder"
505
+ @click="renameFolder"
506
+ />
507
+ </div>
508
+ </div>
509
+ </template>
510
+ </CmsModal>
511
+
512
+ <CmsModal
513
+ v-if="canManage"
514
+ v-model:open="moveOpen"
515
+ :title="`Move ${selection.length} file${selection.length > 1 ? 's' : ''}`"
516
+ size="sm"
517
+ >
518
+ <template #body>
519
+ <div class="cms-form">
520
+ <CmsMediaFolderPicker v-model="moveTarget" :folders="library.folders.value" />
230
521
  <div class="cms-actions is-end">
231
522
  <CmsButton
232
523
  label="Cancel"
233
524
  variant="ghost"
234
525
  color="neutral"
235
- @click="editing = null"
526
+ @click="moveOpen = false"
236
527
  />
237
- <CmsButton label="Save" :loading="editSaving" @click="saveEdit" />
528
+ <CmsButton label="Move here" :loading="busy" @click="moveSelection" />
238
529
  </div>
239
530
  </div>
240
531
  </template>
241
532
  </CmsModal>
533
+
534
+ <CmsMediaDetail
535
+ v-if="!selectable"
536
+ :item="detail"
537
+ :library="library"
538
+ :read-only="readOnly"
539
+ @close="detailKey = null"
540
+ @delete="(item2) => removeItems([item2])"
541
+ />
242
542
  </div>
243
543
  </template>
244
544
 
245
545
  <script setup>
246
546
  import { computed, onMounted, ref, watch } from "#imports";
247
547
  import {
548
+ isWithinMediaFolder,
549
+ MEDIA_FOLDER_MAX_DEPTH,
248
550
  MEDIA_TYPES,
551
+ formatFileSize,
249
552
  mediaFilename,
553
+ mediaFolderAncestors,
554
+ mediaFolderDepth,
555
+ mediaFolderName,
556
+ mediaFolderParent,
250
557
  mediaIconFor,
558
+ mediaTypeAccept,
251
559
  mediaTypeFilter,
252
- normalizeMediaFolder
560
+ rebaseMediaFolder,
561
+ slugify
253
562
  } from "#nuxt-cms";
254
563
  import { useCmsConfirm } from "../../composables/cms-confirm";
564
+ import { useCmsMediaLibrary, usageSummary } from "../../composables/cms-media-library";
565
+ import { useCmsMediaUploader } from "../../composables/cms-media-uploader";
255
566
  import { useCmsRuntime } from "../../composables/cms-runtime";
256
567
  import { useCmsToast } from "../../composables/cms-toast";
257
- import { errorMessage } from "../../utils/ui";
568
+ const MEDIA_DRAG = "application/x-cms-media";
569
+ const FOLDER_DRAG = "application/x-cms-folder";
570
+ const VIEW_STORAGE_KEY = "cms-media-view";
571
+ const PAGE_SIZE = 24;
572
+ const RECENT_COUNT = 6;
258
573
  const filterLabels = {
259
574
  all: "All",
260
575
  image: "Images",
261
576
  video: "Videos",
262
577
  file: "Files"
263
578
  };
579
+ const sortLabels = {
580
+ newest: "Newest",
581
+ oldest: "Oldest",
582
+ name: "Name",
583
+ size: "Largest"
584
+ };
264
585
  const props = defineProps({
265
586
  selectable: { type: Boolean, required: false },
266
587
  mediaType: { type: null, required: false },
267
588
  accept: { type: Array, required: false }
268
589
  });
269
590
  const emit = defineEmits(["select"]);
591
+ const folder = defineModel("folder", { type: [String, null], ...{ default: null } });
270
592
  const toast = useCmsToast();
593
+ const confirmAction = useCmsConfirm();
271
594
  const runtime = useCmsRuntime();
272
- const isLocal = computed(() => runtime.mediaStorage === "local");
273
- const canUpload = computed(() => !isLocal.value);
274
- const canEditAlt = computed(() => true);
275
- const readOnly = computed(() => !canUpload.value);
276
- const endpoint = "/api/cms/admin/media";
277
- const items = ref([]);
278
- const serverFolders = ref([]);
279
- const source = ref(null);
280
- const loading = ref(true);
281
- const errorCode = ref(null);
282
- async function reload() {
283
- loading.value = true;
284
- errorCode.value = null;
285
- try {
286
- const result = await $fetch(endpoint);
287
- items.value = result.items;
288
- serverFolders.value = result.folders ?? [];
289
- source.value = result.source;
290
- } catch (err) {
291
- errorCode.value = err.statusCode ?? 500;
292
- } finally {
293
- loading.value = false;
294
- }
295
- }
595
+ const library = useCmsMediaLibrary();
596
+ const uploader = useCmsMediaUploader();
597
+ const readOnly = computed(() => runtime.mediaStorage === "local");
598
+ const canUpload = computed(() => !readOnly.value);
599
+ const canManage = computed(() => !readOnly.value && !props.selectable);
600
+ const canNest = computed(() => mediaFolderDepth(folder.value) < MEDIA_FOLDER_MAX_DEPTH);
601
+ onMounted(() => library.reload());
602
+ const notConfigured = computed(() => library.errorCode.value === 501);
603
+ const loadError = computed(
604
+ () => library.errorCode.value !== null && library.errorCode.value !== 501
605
+ );
296
606
  const sourceHint = computed(() => {
297
- if (!isLocal.value || !source.value) return null;
298
- const { kind, root, builtAt } = source.value;
299
- if (kind === "filesystem") return `read live from ${root}`;
607
+ const source = library.source.value;
608
+ if (!readOnly.value || !source) return null;
609
+ const { kind, root, builtAt } = source;
610
+ if (kind === "filesystem") return `Read live from ${root}`;
300
611
  if (kind === "manifest") {
301
612
  const when = builtAt ? new Date(builtAt).toLocaleString() : "the last build";
302
- return `from the build of ${when} \u2014 add files to ${root} and redeploy to update`;
613
+ return `From the build of ${when}. Add files to ${root} and redeploy to update.`;
303
614
  }
304
- return `no media folder was found at build time \u2014 check cms.media.publicBaseUrl`;
615
+ return "No media folder was found at build time. Check cms.media.publicBaseUrl.";
305
616
  });
306
- onMounted(reload);
307
- const notConfigured = computed(() => errorCode.value === 501);
308
- const loadError = computed(() => errorCode.value !== null && errorCode.value !== 501);
617
+ watch(
618
+ () => [library.loading.value, library.folders.value],
619
+ ([loading, folders]) => {
620
+ if (loading || !folder.value || folders.includes(folder.value)) return;
621
+ folder.value = mediaFolderAncestors(folder.value).reverse().find((ancestor) => folders.includes(ancestor)) ?? null;
622
+ }
623
+ );
309
624
  const allowedTypes = computed(() => mediaTypeFilter(props.mediaType));
625
+ const acceptAttr = computed(
626
+ () => (props.accept?.length ? props.accept : mediaTypeAccept(props.mediaType))?.join(",")
627
+ );
310
628
  const filters = computed(() => [
311
629
  "all",
312
630
  ...allowedTypes.value ?? MEDIA_TYPES
313
631
  ]);
314
632
  const filter = ref("all");
315
- const showTypeFilters = computed(() => filters.value.length > 2 && items.value.length > 0);
316
633
  watch(filters, (list) => {
317
634
  if (!list.includes(filter.value)) filter.value = "all";
318
635
  });
319
- const search = ref("");
320
- const usedFolders = computed(() => {
321
- const set = new Set(items.value.map((item) => item.folder).filter((f) => !!f));
322
- return [...set].sort();
636
+ const typedItems = computed(() => {
637
+ const allowed = allowedTypes.value;
638
+ return allowed ? library.items.value.filter((item) => allowed.includes(item.type)) : library.items.value;
323
639
  });
324
- const folderNames = computed(
325
- () => [.../* @__PURE__ */ new Set([...serverFolders.value, ...usedFolders.value])].sort()
640
+ const hasTypedFiles = computed(() => typedItems.value.length > 0);
641
+ const showTypeFilters = computed(() => filters.value.length > 2 && hasTypedFiles.value);
642
+ const search = ref("");
643
+ const searchEverywhere = ref(false);
644
+ const query = computed(() => search.value.trim().toLowerCase());
645
+ const scope = computed(() => searchEverywhere.value ? null : folder.value);
646
+ const hasDates = computed(() => library.items.value.some((item) => item.createdAt));
647
+ const sort = ref("newest");
648
+ const activeSort = computed(
649
+ () => !hasDates.value && (sort.value === "newest" || sort.value === "oldest") ? "name" : sort.value
326
650
  );
327
- const folderCounts = computed(() => {
328
- const counts = /* @__PURE__ */ new Map();
329
- for (const item of items.value) {
330
- if (!item.folder) continue;
331
- counts.set(item.folder, (counts.get(item.folder) ?? 0) + 1);
332
- }
333
- return counts;
334
- });
335
- const folderChips = computed(
336
- () => folderNames.value.map((name) => ({
337
- name,
338
- count: folderCounts.value.get(name) ?? 0,
339
- empty: !folderCounts.value.get(name)
651
+ const sortItems = computed(
652
+ () => Object.keys(sortLabels).filter((key) => hasDates.value || key !== "newest" && key !== "oldest").map((key) => ({
653
+ label: sortLabels[key],
654
+ type: "checkbox",
655
+ checked: activeSort.value === key,
656
+ onUpdateChecked: () => {
657
+ sort.value = key;
658
+ }
340
659
  }))
341
660
  );
342
- const folder = ref(null);
343
- function onFolderCreate(name) {
344
- registerFolder(name).catch(
345
- (err) => toast.add({
346
- title: "Could not create the folder",
347
- description: errorMessage(err),
348
- color: "error"
349
- })
350
- );
661
+ function compareItems(a, b) {
662
+ const byName = mediaFilename(a.key).localeCompare(mediaFilename(b.key));
663
+ if (activeSort.value === "name") return byName;
664
+ if (activeSort.value === "size") return (b.size ?? 0) - (a.size ?? 0) || byName;
665
+ const delta = (Date.parse(b.createdAt ?? "") || 0) - (Date.parse(a.createdAt ?? "") || 0);
666
+ return (activeSort.value === "newest" ? delta : -delta) || byName;
351
667
  }
352
- async function registerFolder(name) {
353
- if (folderNames.value.includes(name)) return;
354
- await $fetch(`${endpoint}/folders`, { method: "POST", body: { name } });
355
- await reload();
356
- }
357
- async function deleteFolder(name) {
358
- const count = folderCounts.value.get(name) ?? 0;
359
- const question = count ? `Delete the folder "${name}" and its ${count} file${count > 1 ? "s" : ""}?` : `Delete the empty folder "${name}"?`;
360
- if (!await confirmAction(question)) return;
361
- try {
362
- await $fetch(`${endpoint}/folders`, {
363
- method: "DELETE",
364
- query: { name, recursive: count ? "true" : "false" }
365
- });
366
- if (folder.value === name) folder.value = null;
367
- await reload();
368
- } catch (error) {
369
- toast.add({ title: "Delete failed", description: errorMessage(error), color: "error" });
370
- }
668
+ function matchesQuery(value) {
669
+ return !!value && value.toLowerCase().includes(query.value);
371
670
  }
372
- const visible = computed(() => {
373
- const allowed = allowedTypes.value;
374
- let list = allowed ? items.value.filter((item) => allowed.includes(item.type)) : items.value;
671
+ const visibleFolders = computed(() => {
672
+ if (!query.value) return library.childFolders(folder.value);
673
+ return library.folders.value.filter(
674
+ (candidate) => candidate !== scope.value && isWithinMediaFolder(candidate, scope.value) && matchesQuery(mediaFolderName(candidate))
675
+ );
676
+ });
677
+ const visibleFiles = computed(() => {
678
+ let list = typedItems.value;
375
679
  if (filter.value !== "all") list = list.filter((item) => item.type === filter.value);
376
- if (folder.value) list = list.filter((item) => item.folder === folder.value);
377
- const query = search.value.trim().toLowerCase();
378
- if (query) {
680
+ if (query.value) {
379
681
  list = list.filter(
380
- (item) => [item.key, item.alt, item.folder].some((value) => value?.toLowerCase().includes(query))
682
+ (item) => isWithinMediaFolder(item.folder, scope.value) && [mediaFilename(item.key), item.alt, item.folder].some(matchesQuery)
381
683
  );
684
+ } else {
685
+ list = list.filter((item) => (item.folder ?? null) === folder.value);
382
686
  }
383
- return list;
687
+ return [...list].sort(compareItems);
688
+ });
689
+ const recent = computed(() => {
690
+ if (!props.selectable || query.value || folder.value || page.value > 1) return [];
691
+ if (typedItems.value.length <= RECENT_COUNT) return [];
692
+ return typedItems.value.filter((item) => item.createdAt).sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt)).slice(0, RECENT_COUNT);
384
693
  });
385
- const PAGE_SIZE = 24;
386
694
  const page = ref(1);
387
- watch([filter, folder, search], () => {
695
+ watch([filter, folder, query, searchEverywhere, sort], () => {
388
696
  page.value = 1;
389
697
  });
390
- watch(visible, (list) => {
698
+ watch(visibleFiles, (list) => {
391
699
  const pageCount = Math.max(1, Math.ceil(list.length / PAGE_SIZE));
392
700
  if (page.value > pageCount) page.value = pageCount;
393
701
  });
394
702
  const paged = computed(
395
- () => visible.value.slice((page.value - 1) * PAGE_SIZE, page.value * PAGE_SIZE)
703
+ () => visibleFiles.value.slice((page.value - 1) * PAGE_SIZE, page.value * PAGE_SIZE)
396
704
  );
397
- const uploadOpen = ref(false);
398
- const uploadFolder = ref(null);
399
- watch(uploadOpen, (open) => {
400
- if (open) uploadFolder.value = folder.value;
705
+ const emptyState = computed(() => {
706
+ if (query.value) {
707
+ return {
708
+ icon: "magnifying-glass",
709
+ title: "No matching media",
710
+ body: searchEverywhere.value || !folder.value ? "Try a different search or filter." : "Nothing here matches. Try searching all folders."
711
+ };
712
+ }
713
+ if (!library.items.value.length && !library.folders.value.length) {
714
+ return {
715
+ icon: "photo",
716
+ title: readOnly.value ? "No media" : "No media yet",
717
+ body: readOnly.value ? "No media registered." : "Drop files here or use Upload to add some."
718
+ };
719
+ }
720
+ if (folder.value) {
721
+ return {
722
+ icon: "folder-open",
723
+ title: "This folder is empty",
724
+ body: canUpload.value ? "Drop files here or use Upload to add some." : void 0
725
+ };
726
+ }
727
+ return {
728
+ icon: "photo",
729
+ title: hasTypedFiles.value ? "No files in the library root" : "No matching media",
730
+ body: hasTypedFiles.value ? "Open a folder to see its files." : "No files of this type yet."
731
+ };
401
732
  });
402
- function onUploaded(uploaded) {
403
- uploadOpen.value = false;
404
- if (props.selectable && uploaded[0]) {
405
- emit("select", uploaded[0]);
733
+ const view = ref("grid");
734
+ onMounted(() => {
735
+ try {
736
+ if (localStorage.getItem(VIEW_STORAGE_KEY) === "list") view.value = "list";
737
+ } catch {
738
+ }
739
+ });
740
+ watch(view, (value) => {
741
+ try {
742
+ localStorage.setItem(VIEW_STORAGE_KEY, value);
743
+ } catch {
744
+ }
745
+ });
746
+ const crumbs = computed(() => [
747
+ { label: "Library", folder: null },
748
+ ...folder.value ? mediaFolderAncestors(folder.value).map((path) => ({
749
+ label: mediaFolderName(path),
750
+ folder: path
751
+ })) : []
752
+ ]);
753
+ function openFolder(target) {
754
+ folder.value = target;
755
+ search.value = "";
756
+ searchEverywhere.value = false;
757
+ }
758
+ function folderMeta(path) {
759
+ const stats = library.stats.value.get(path);
760
+ if (!stats) return "Empty";
761
+ const parts = [];
762
+ if (stats.folders) parts.push(`${stats.folders} folder${stats.folders > 1 ? "s" : ""}`);
763
+ if (stats.files) parts.push(`${stats.files} file${stats.files > 1 ? "s" : ""}`);
764
+ return parts.join(" \xB7 ") || "Empty";
765
+ }
766
+ const selection = ref([]);
767
+ const lastSelected = ref(null);
768
+ const selectedItems = computed(() => {
769
+ const keys = new Set(selection.value);
770
+ return library.items.value.filter((item) => keys.has(item.key));
771
+ });
772
+ watch(folder, () => clearSelection());
773
+ watch(
774
+ () => library.items.value,
775
+ (items) => {
776
+ const keys = new Set(items.map((item) => item.key));
777
+ selection.value = selection.value.filter((key) => keys.has(key));
778
+ }
779
+ );
780
+ function isSelected(key) {
781
+ return selection.value.includes(key);
782
+ }
783
+ function toggleSelect(key, event) {
784
+ const shift = event?.shiftKey;
785
+ if (shift && lastSelected.value) {
786
+ const keys = visibleFiles.value.map((item) => item.key);
787
+ const from = keys.indexOf(lastSelected.value);
788
+ const to = keys.indexOf(key);
789
+ if (from !== -1 && to !== -1) {
790
+ const range = keys.slice(Math.min(from, to), Math.max(from, to) + 1);
791
+ selection.value = [.../* @__PURE__ */ new Set([...selection.value, ...range])];
792
+ lastSelected.value = key;
793
+ return;
794
+ }
795
+ }
796
+ selection.value = isSelected(key) ? selection.value.filter((entry) => entry !== key) : [...selection.value, key];
797
+ lastSelected.value = key;
798
+ }
799
+ function selectAll() {
800
+ selection.value = visibleFiles.value.map((item) => item.key);
801
+ }
802
+ function clearSelection() {
803
+ selection.value = [];
804
+ lastSelected.value = null;
805
+ }
806
+ const detailKey = ref(null);
807
+ const detail = computed(
808
+ () => library.items.value.find((item) => item.key === detailKey.value) ?? null
809
+ );
810
+ function emitSelect(item) {
811
+ emit("select", { key: item.key, url: item.url });
812
+ }
813
+ function onItemClick(item, event) {
814
+ if (props.selectable) {
815
+ emitSelect(item);
816
+ return;
817
+ }
818
+ if (canManage.value && (selection.value.length || event.metaKey || event.ctrlKey || event.shiftKey)) {
819
+ toggleSelect(item.key, event);
406
820
  return;
407
821
  }
408
- void reload();
822
+ detailKey.value = item.key;
823
+ }
824
+ const busy = ref(false);
825
+ async function run(action) {
826
+ if (busy.value) return false;
827
+ busy.value = true;
828
+ try {
829
+ return await action();
830
+ } finally {
831
+ busy.value = false;
832
+ }
409
833
  }
410
834
  const newFolderOpen = ref(false);
411
- const newFolderName = ref("");
412
- const newFolderSlug = computed(() => normalizeMediaFolder(newFolderName.value));
835
+ const folderName = ref("");
836
+ const folderSlug = computed(() => slugify(folderName.value));
837
+ const newFolderPath = computed(
838
+ () => folderSlug.value ? folder.value ? `${folder.value}/${folderSlug.value}` : folderSlug.value : null
839
+ );
413
840
  function openNewFolder() {
414
- newFolderName.value = "";
841
+ folderName.value = "";
415
842
  newFolderOpen.value = true;
416
843
  }
417
- const creatingFolder = ref(false);
418
844
  async function createFolder() {
419
- const name = newFolderSlug.value;
420
- if (!name || creatingFolder.value) return;
421
- creatingFolder.value = true;
422
- try {
423
- await registerFolder(name);
424
- folder.value = name;
845
+ const path = newFolderPath.value;
846
+ if (!path) return;
847
+ if (library.folders.value.includes(path)) {
425
848
  newFolderOpen.value = false;
426
- } catch (err) {
427
- toast.add({
428
- title: "Could not create the folder",
429
- description: errorMessage(err),
430
- color: "error"
431
- });
432
- } finally {
433
- creatingFolder.value = false;
849
+ openFolder(path);
850
+ return;
434
851
  }
852
+ const ok = await run(() => library.createFolder(path));
853
+ if (!ok) return;
854
+ newFolderOpen.value = false;
855
+ openFolder(path);
435
856
  }
436
- const editing = ref(null);
437
- const editAlt = ref("");
438
- const editFolder = ref(null);
439
- const editSaving = ref(false);
440
- function onTileClick(item) {
441
- if (props.selectable) {
442
- emit("select", { key: item.key, url: item.url });
857
+ const renameOpen = ref(false);
858
+ const renameParent = ref(null);
859
+ const parentCandidates = computed(() => {
860
+ const own = folder.value;
861
+ if (!own) return [];
862
+ const subtreeDepth = Math.max(
863
+ ...library.folders.value.filter((candidate) => isWithinMediaFolder(candidate, own)).map((candidate) => mediaFolderDepth(candidate) - mediaFolderDepth(own) + 1)
864
+ );
865
+ return library.folders.value.filter(
866
+ (candidate) => mediaFolderDepth(candidate) + subtreeDepth <= MEDIA_FOLDER_MAX_DEPTH
867
+ );
868
+ });
869
+ const renamePath = computed(() => {
870
+ if (!folderSlug.value) return null;
871
+ return renameParent.value ? `${renameParent.value}/${folderSlug.value}` : folderSlug.value;
872
+ });
873
+ function openRename() {
874
+ if (!folder.value) return;
875
+ folderName.value = mediaFolderName(folder.value);
876
+ renameParent.value = mediaFolderParent(folder.value);
877
+ renameOpen.value = true;
878
+ }
879
+ async function renameFolder() {
880
+ const from = folder.value;
881
+ const to = renamePath.value;
882
+ if (!from || !to || from === to) return;
883
+ const ok = await run(() => library.moveFolder(from, to));
884
+ if (!ok) return;
885
+ renameOpen.value = false;
886
+ openFolder(to);
887
+ }
888
+ async function removeFolder(path) {
889
+ const files = library.itemsWithin(path);
890
+ const subfolders = library.folders.value.filter(
891
+ (candidate) => candidate !== path && isWithinMediaFolder(candidate, path)
892
+ ).length;
893
+ const contents = [];
894
+ if (subfolders) contents.push(`${subfolders} subfolder${subfolders > 1 ? "s" : ""}`);
895
+ if (files.length) contents.push(`${files.length} file${files.length > 1 ? "s" : ""}`);
896
+ const keys = files.map((item) => item.key);
897
+ const summary = usageSummary(await library.usage(keys), keys);
898
+ const message = contents.length ? `Delete "${mediaFolderName(path)}" with ${contents.join(" and ")}? ${summary}` : `Delete the empty folder "${mediaFolderName(path)}"?`;
899
+ if (!await confirmAction(message.trim(), { title: "Delete folder", confirmLabel: "Delete" }))
443
900
  return;
901
+ const ok = await run(() => library.deleteFolder(path));
902
+ if (ok && folder.value && isWithinMediaFolder(folder.value, path)) {
903
+ openFolder(mediaFolderParent(path));
444
904
  }
445
- if (canEditAlt.value) openEdit(item);
446
905
  }
447
- function openEdit(item) {
448
- editing.value = item;
449
- editAlt.value = item.alt ?? "";
450
- editFolder.value = item.folder ?? null;
906
+ async function removeItems(items) {
907
+ if (!items.length) return;
908
+ const keys = items.map((item) => item.key);
909
+ const summary = usageSummary(await library.usage(keys), keys);
910
+ const question = keys.length === 1 ? `Delete "${mediaFilename(keys[0])}"?` : `Delete ${keys.length} files?`;
911
+ if (!await confirmAction(`${question} ${summary}`.trim(), {
912
+ title: "Delete",
913
+ confirmLabel: "Delete"
914
+ }))
915
+ return;
916
+ const ok = await run(() => library.deleteItems(keys));
917
+ if (!ok) return;
918
+ if (detailKey.value && keys.includes(detailKey.value)) detailKey.value = null;
919
+ clearSelection();
920
+ }
921
+ const moveOpen = ref(false);
922
+ const moveTarget = ref(null);
923
+ function openMove() {
924
+ moveTarget.value = folder.value;
925
+ moveOpen.value = true;
451
926
  }
452
- async function saveEdit() {
453
- if (!editing.value) return;
454
- editSaving.value = true;
455
- try {
456
- await $fetch(endpoint, {
457
- method: "PUT",
458
- body: {
459
- key: editing.value.key,
460
- alt: editAlt.value.trim() || null,
461
- ...canUpload.value ? { folder: editFolder.value } : {}
462
- }
463
- });
464
- editing.value = null;
465
- await reload();
466
- } catch (err) {
927
+ async function moveKeys(keys, target) {
928
+ const moving = keys.filter(
929
+ (key) => (library.items.value.find((item) => item.key === key)?.folder ?? null) !== target
930
+ );
931
+ if (!moving.length) return true;
932
+ const ok = await run(() => library.moveItems(moving, target));
933
+ if (ok) {
467
934
  toast.add({
468
- title: "Save failed",
469
- description: errorMessage(err),
470
- color: "error"
935
+ title: `Moved ${moving.length} file${moving.length > 1 ? "s" : ""} to ${target ?? "Library root"}`,
936
+ color: "success"
471
937
  });
472
- } finally {
473
- editSaving.value = false;
474
938
  }
939
+ return ok;
475
940
  }
476
- function extension(key) {
477
- const dot = key.lastIndexOf(".");
478
- return dot === -1 ? "file" : key.slice(dot + 1).toLowerCase();
941
+ async function moveSelection() {
942
+ const ok = await moveKeys(selection.value, moveTarget.value);
943
+ if (!ok) return;
944
+ moveOpen.value = false;
945
+ clearSelection();
479
946
  }
480
- function formatSize(bytes) {
481
- if (bytes == null) return "\u2014";
482
- if (bytes < 1024) return `${bytes} B`;
483
- let value = bytes;
484
- for (const unit of ["KB", "MB", "GB"]) {
485
- value /= 1024;
486
- if (value < 1024) return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${unit}`;
947
+ const uploadOpen = ref(false);
948
+ async function afterUpload(uploaded) {
949
+ if (!uploaded.length) return;
950
+ if (props.selectable && uploaded[0]) {
951
+ emitSelect(uploaded[0]);
952
+ return;
487
953
  }
488
- return `${Math.round(value / 1024)} TB`;
954
+ await library.reload({ quiet: true });
955
+ toast.add({
956
+ title: `Uploaded ${uploaded.length} file${uploaded.length > 1 ? "s" : ""}`,
957
+ color: "success"
958
+ });
489
959
  }
490
- const confirmAction = useCmsConfirm();
491
- const removing = ref(false);
492
- async function remove(item) {
493
- if (removing.value) return;
494
- if (!await confirmAction("Delete this file? Entries referencing it will keep a broken link."))
960
+ function onUploaded(uploaded) {
961
+ uploadOpen.value = false;
962
+ void afterUpload(uploaded);
963
+ }
964
+ async function uploadFiles(files, target) {
965
+ const uploaded = await uploader.upload(files, {
966
+ folder: target,
967
+ accept: acceptAttr.value,
968
+ multiple: !props.selectable
969
+ });
970
+ await afterUpload(uploaded);
971
+ }
972
+ const draggingKeys = ref([]);
973
+ const draggingFolder = ref(null);
974
+ const dropTarget = ref(null);
975
+ const fileDragDepth = ref(0);
976
+ const fileDragActive = computed(() => fileDragDepth.value > 0 && canUpload.value);
977
+ const ROOT_TARGET = ":root";
978
+ function targetId(target) {
979
+ return target ?? ROOT_TARGET;
980
+ }
981
+ const dropTargetLabel = computed(() => {
982
+ if (!dropTarget.value) return folder.value ?? "Library root";
983
+ return dropTarget.value === ROOT_TARGET ? "Library root" : dropTarget.value;
984
+ });
985
+ function hasFiles(event) {
986
+ return !!event.dataTransfer?.types.includes("Files");
987
+ }
988
+ function onItemDragStart(event, item) {
989
+ if (!canManage.value || !event.dataTransfer) return;
990
+ const keys = isSelected(item.key) ? [...selection.value] : [item.key];
991
+ draggingKeys.value = keys;
992
+ event.dataTransfer.effectAllowed = "move";
993
+ event.dataTransfer.setData(MEDIA_DRAG, JSON.stringify(keys));
994
+ }
995
+ function onFolderDragStart(event, path) {
996
+ if (!canManage.value || !event.dataTransfer) return;
997
+ draggingFolder.value = path;
998
+ event.dataTransfer.effectAllowed = "move";
999
+ event.dataTransfer.setData(FOLDER_DRAG, path);
1000
+ }
1001
+ function onDragEnd() {
1002
+ draggingKeys.value = [];
1003
+ draggingFolder.value = null;
1004
+ dropTarget.value = null;
1005
+ }
1006
+ function folderMoveTarget(path, target) {
1007
+ const name = mediaFolderName(path);
1008
+ return target ? `${target}/${name}` : name;
1009
+ }
1010
+ function acceptsDrop(event, target) {
1011
+ if (draggingKeys.value.length) return true;
1012
+ if (draggingFolder.value) {
1013
+ const from = draggingFolder.value;
1014
+ if (target && isWithinMediaFolder(target, from)) return false;
1015
+ if (mediaFolderParent(from) === target) return false;
1016
+ const depth = Math.max(
1017
+ ...library.folders.value.filter((candidate) => isWithinMediaFolder(candidate, from)).map((candidate) => mediaFolderDepth(candidate) - mediaFolderDepth(from) + 1)
1018
+ );
1019
+ return mediaFolderDepth(target) + depth <= MEDIA_FOLDER_MAX_DEPTH;
1020
+ }
1021
+ return hasFiles(event) && canUpload.value;
1022
+ }
1023
+ function onTargetDragOver(event, target) {
1024
+ if (!acceptsDrop(event, target)) return;
1025
+ event.preventDefault();
1026
+ event.stopPropagation();
1027
+ if (event.dataTransfer) event.dataTransfer.dropEffect = hasFiles(event) ? "copy" : "move";
1028
+ dropTarget.value = targetId(target);
1029
+ }
1030
+ function onTargetDragLeave(target) {
1031
+ if (dropTarget.value === targetId(target)) dropTarget.value = null;
1032
+ }
1033
+ async function onTargetDrop(event, target) {
1034
+ if (!acceptsDrop(event, target)) return;
1035
+ event.preventDefault();
1036
+ event.stopPropagation();
1037
+ const keys = [...draggingKeys.value];
1038
+ const path = draggingFolder.value;
1039
+ const files = event.dataTransfer?.files;
1040
+ onDragEnd();
1041
+ fileDragDepth.value = 0;
1042
+ if (keys.length) {
1043
+ if (await moveKeys(keys, target)) clearSelection();
495
1044
  return;
496
- removing.value = true;
497
- try {
498
- await $fetch(endpoint, { method: "DELETE", query: { key: item.key } });
499
- await reload();
500
- } catch (err) {
501
- toast.add({
502
- title: "Delete failed",
503
- description: errorMessage(err),
504
- color: "error"
505
- });
506
- } finally {
507
- removing.value = false;
508
1045
  }
1046
+ if (path) {
1047
+ const to = folderMoveTarget(path, target);
1048
+ const ok = await run(() => library.moveFolder(path, to));
1049
+ if (ok && folder.value && isWithinMediaFolder(folder.value, path)) {
1050
+ folder.value = rebaseMediaFolder(folder.value, path, to);
1051
+ }
1052
+ return;
1053
+ }
1054
+ if (files?.length) await uploadFiles(files, target);
1055
+ }
1056
+ function onGalleryDragEnter(event) {
1057
+ if (!hasFiles(event) || !canUpload.value) return;
1058
+ fileDragDepth.value++;
1059
+ }
1060
+ function onGalleryDragOver(event) {
1061
+ if (!hasFiles(event) || !canUpload.value) return;
1062
+ event.preventDefault();
1063
+ if (event.dataTransfer) event.dataTransfer.dropEffect = "copy";
1064
+ }
1065
+ function onGalleryDragLeave(event) {
1066
+ if (!hasFiles(event) || !canUpload.value) return;
1067
+ fileDragDepth.value = Math.max(0, fileDragDepth.value - 1);
1068
+ }
1069
+ async function onGalleryDrop(event) {
1070
+ if (!hasFiles(event) || !canUpload.value) return;
1071
+ event.preventDefault();
1072
+ fileDragDepth.value = 0;
1073
+ dropTarget.value = null;
1074
+ const files = event.dataTransfer?.files;
1075
+ if (files?.length) await uploadFiles(files, folder.value);
1076
+ }
1077
+ function tileInfo(item) {
1078
+ const parts = [extension(item.key)];
1079
+ if (item.size != null) parts.push(formatFileSize(item.size));
1080
+ if (item.width && item.height) parts.push(`${item.width}\xD7${item.height}`);
1081
+ return parts.join(" \xB7 ");
1082
+ }
1083
+ function extension(key) {
1084
+ const dot = key.lastIndexOf(".");
1085
+ return dot === -1 ? "file" : key.slice(dot + 1).toLowerCase();
509
1086
  }
510
1087
  </script>