@xleddyl/nuxt-cms 0.1.60 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/dist/module.json +1 -1
  2. package/dist/module.mjs +137 -9
  3. package/dist/runtime/app/components/cms/BlocksField.vue +86 -10
  4. package/dist/runtime/app/components/cms/BrandMark.d.vue.ts +6 -0
  5. package/dist/runtime/app/components/cms/BrandMark.vue +19 -0
  6. package/dist/runtime/app/components/cms/BrandMark.vue.d.ts +6 -0
  7. package/dist/runtime/app/components/cms/Checkbox.d.vue.ts +11 -0
  8. package/dist/runtime/app/components/cms/Checkbox.vue +21 -0
  9. package/dist/runtime/app/components/cms/Checkbox.vue.d.ts +11 -0
  10. package/dist/runtime/app/components/cms/EmptyState.vue +4 -2
  11. package/dist/runtime/app/components/cms/EntryDrawer.vue +1 -0
  12. package/dist/runtime/app/components/cms/EntryForm.d.vue.ts +1 -0
  13. package/dist/runtime/app/components/cms/EntryForm.vue +105 -30
  14. package/dist/runtime/app/components/cms/EntryForm.vue.d.ts +1 -0
  15. package/dist/runtime/app/components/cms/FieldInput.vue +20 -5
  16. package/dist/runtime/app/components/cms/FormField.d.vue.ts +5 -3
  17. package/dist/runtime/app/components/cms/FormField.vue +8 -2
  18. package/dist/runtime/app/components/cms/FormField.vue.d.ts +5 -3
  19. package/dist/runtime/app/components/cms/Icon.vue +1 -1
  20. package/dist/runtime/app/components/cms/MediaDetail.d.vue.ts +16 -0
  21. package/dist/runtime/app/components/cms/MediaDetail.vue +169 -0
  22. package/dist/runtime/app/components/cms/MediaDetail.vue.d.ts +16 -0
  23. package/dist/runtime/app/components/cms/MediaField.vue +9 -2
  24. package/dist/runtime/app/components/cms/MediaFolderPicker.d.vue.ts +2 -2
  25. package/dist/runtime/app/components/cms/MediaFolderPicker.vue +55 -54
  26. package/dist/runtime/app/components/cms/MediaFolderPicker.vue.d.ts +2 -2
  27. package/dist/runtime/app/components/cms/MediaGallery.d.vue.ts +8 -2
  28. package/dist/runtime/app/components/cms/MediaGallery.vue +927 -336
  29. package/dist/runtime/app/components/cms/MediaGallery.vue.d.ts +8 -2
  30. package/dist/runtime/app/components/cms/MediaUpload.d.vue.ts +1 -0
  31. package/dist/runtime/app/components/cms/MediaUpload.vue +76 -139
  32. package/dist/runtime/app/components/cms/MediaUpload.vue.d.ts +1 -0
  33. package/dist/runtime/app/components/cms/PageHeader.d.vue.ts +5 -3
  34. package/dist/runtime/app/components/cms/PageHeader.vue +16 -6
  35. package/dist/runtime/app/components/cms/PageHeader.vue.d.ts +5 -3
  36. package/dist/runtime/app/components/cms/PasswordInput.d.vue.ts +17 -0
  37. package/dist/runtime/app/components/cms/PasswordInput.vue +77 -0
  38. package/dist/runtime/app/components/cms/PasswordInput.vue.d.ts +17 -0
  39. package/dist/runtime/app/components/cms/SelectMenu.vue +3 -5
  40. package/dist/runtime/app/components/cms/SettingsAccount.d.vue.ts +3 -0
  41. package/dist/runtime/app/components/cms/SettingsAccount.vue +138 -0
  42. package/dist/runtime/app/components/cms/SettingsAccount.vue.d.ts +3 -0
  43. package/dist/runtime/app/components/cms/SettingsColumns.d.vue.ts +16 -0
  44. package/dist/runtime/app/components/cms/SettingsColumns.vue +102 -0
  45. package/dist/runtime/app/components/cms/SettingsColumns.vue.d.ts +16 -0
  46. package/dist/runtime/app/components/cms/SettingsEntry.d.vue.ts +6 -0
  47. package/dist/runtime/app/components/cms/SettingsEntry.vue +133 -0
  48. package/dist/runtime/app/components/cms/SettingsEntry.vue.d.ts +6 -0
  49. package/dist/runtime/app/components/cms/SettingsLayout.d.vue.ts +16 -0
  50. package/dist/runtime/app/components/cms/SettingsLayout.vue +177 -0
  51. package/dist/runtime/app/components/cms/SettingsLayout.vue.d.ts +16 -0
  52. package/dist/runtime/app/components/cms/SettingsModal.d.vue.ts +16 -0
  53. package/dist/runtime/app/components/cms/SettingsModal.vue +179 -0
  54. package/dist/runtime/app/components/cms/SettingsModal.vue.d.ts +16 -0
  55. package/dist/runtime/app/components/cms/SettingsUsers.d.vue.ts +3 -0
  56. package/dist/runtime/app/components/cms/SettingsUsers.vue +270 -0
  57. package/dist/runtime/app/components/cms/SettingsUsers.vue.d.ts +3 -0
  58. package/dist/runtime/app/components/cms/Spinner.vue +2 -2
  59. package/dist/runtime/app/components/cms/Table.d.vue.ts +7 -2
  60. package/dist/runtime/app/components/cms/Table.vue +38 -1
  61. package/dist/runtime/app/components/cms/Table.vue.d.ts +7 -2
  62. package/dist/runtime/app/components/cms/Toaster.vue +1 -1
  63. package/dist/runtime/app/components/cms/WelcomeModal.d.vue.ts +3 -0
  64. package/dist/runtime/app/components/cms/WelcomeModal.vue +69 -0
  65. package/dist/runtime/app/components/cms/WelcomeModal.vue.d.ts +3 -0
  66. package/dist/runtime/app/composables/cms-account.d.ts +11 -0
  67. package/dist/runtime/app/composables/cms-account.js +23 -0
  68. package/dist/runtime/app/composables/cms-media-library.d.ts +75 -0
  69. package/dist/runtime/app/composables/cms-media-library.js +151 -0
  70. package/dist/runtime/app/composables/cms-media-uploader.d.ts +22 -0
  71. package/dist/runtime/app/composables/cms-media-uploader.js +244 -0
  72. package/dist/runtime/app/composables/cms-settings.d.ts +13 -0
  73. package/dist/runtime/app/composables/cms-settings.js +59 -0
  74. package/dist/runtime/app/composables/cms-theme.d.ts +9 -0
  75. package/dist/runtime/app/composables/cms-theme.js +27 -0
  76. package/dist/runtime/app/layouts/cms-admin.d.vue.ts +2 -2
  77. package/dist/runtime/app/layouts/cms-admin.vue +71 -17
  78. package/dist/runtime/app/layouts/cms-admin.vue.d.ts +2 -2
  79. package/dist/runtime/app/pages/admin-collection.vue +127 -101
  80. package/dist/runtime/app/pages/admin-entry.vue +1 -0
  81. package/dist/runtime/app/pages/admin-login.vue +18 -3
  82. package/dist/runtime/app/pages/admin-media.vue +19 -3
  83. package/dist/runtime/app/utils/ui.d.ts +2 -0
  84. package/dist/runtime/app/utils/ui.js +18 -0
  85. package/dist/runtime/assets/main.css +1 -1
  86. package/dist/runtime/server/api/account-password.put.d.ts +4 -0
  87. package/dist/runtime/server/api/account-password.put.js +38 -0
  88. package/dist/runtime/server/api/account-welcome.post.d.ts +4 -0
  89. package/dist/runtime/server/api/account-welcome.post.js +14 -0
  90. package/dist/runtime/server/api/account.put.d.ts +3 -0
  91. package/dist/runtime/server/api/account.put.js +25 -0
  92. package/dist/runtime/server/api/media-delete.post.d.ts +4 -0
  93. package/dist/runtime/server/api/media-delete.post.js +23 -0
  94. package/dist/runtime/server/api/media-folder.delete.js +6 -16
  95. package/dist/runtime/server/api/media-folder.patch.d.ts +5 -0
  96. package/dist/runtime/server/api/media-folder.patch.js +68 -0
  97. package/dist/runtime/server/api/media-folder.post.js +3 -15
  98. package/dist/runtime/server/api/media-move.post.d.ts +5 -0
  99. package/dist/runtime/server/api/media-move.post.js +28 -0
  100. package/dist/runtime/server/api/media-usage.post.d.ts +5 -0
  101. package/dist/runtime/server/api/media-usage.post.js +15 -0
  102. package/dist/runtime/server/api/settings.delete.d.ts +4 -0
  103. package/dist/runtime/server/api/settings.delete.js +12 -0
  104. package/dist/runtime/server/api/settings.get.d.ts +3 -0
  105. package/dist/runtime/server/api/settings.get.js +15 -0
  106. package/dist/runtime/server/api/settings.put.d.ts +3 -0
  107. package/dist/runtime/server/api/settings.put.js +14 -0
  108. package/dist/runtime/server/api/user.delete.d.ts +4 -0
  109. package/dist/runtime/server/api/user.delete.js +12 -0
  110. package/dist/runtime/server/api/user.put.d.ts +3 -0
  111. package/dist/runtime/server/api/user.put.js +27 -0
  112. package/dist/runtime/server/api/users.get.d.ts +3 -0
  113. package/dist/runtime/server/api/users.get.js +11 -0
  114. package/dist/runtime/server/api/users.post.d.ts +3 -0
  115. package/dist/runtime/server/api/users.post.js +32 -0
  116. package/dist/runtime/server/plugins/session-validate.d.ts +2 -0
  117. package/dist/runtime/server/plugins/session-validate.js +13 -0
  118. package/dist/runtime/server/routes/auth/login.post.js +18 -8
  119. package/dist/runtime/server/utils/media-folders.d.ts +86 -0
  120. package/dist/runtime/server/utils/media-folders.js +37 -0
  121. package/dist/runtime/server/utils/media-references.d.ts +7 -1
  122. package/dist/runtime/server/utils/media-references.js +40 -0
  123. package/dist/runtime/server/utils/media-usage.d.ts +2 -0
  124. package/dist/runtime/server/utils/media-usage.js +18 -0
  125. package/dist/runtime/server/utils/password.d.ts +3 -0
  126. package/dist/runtime/server/utils/password.js +31 -0
  127. package/dist/runtime/server/utils/require-admin.d.ts +6 -2
  128. package/dist/runtime/server/utils/require-admin.js +33 -28
  129. package/dist/runtime/server/utils/same-origin.d.ts +2 -0
  130. package/dist/runtime/server/utils/same-origin.js +24 -0
  131. package/dist/runtime/server/utils/settings.d.ts +16 -0
  132. package/dist/runtime/server/utils/settings.js +31 -0
  133. package/dist/runtime/server/utils/users.d.ts +15 -0
  134. package/dist/runtime/server/utils/users.js +34 -0
  135. package/dist/runtime/shared/index.d.ts +29 -1
  136. package/dist/runtime/shared/index.js +36 -2
  137. package/dist/runtime/shared/layout.d.ts +34 -0
  138. package/dist/runtime/shared/layout.js +130 -0
  139. package/dist/runtime/shared/users.d.ts +18 -0
  140. package/dist/runtime/shared/users.js +1 -0
  141. package/dist/runtime/shared/validation.js +5 -4
  142. package/package.json +3 -3
@@ -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,277 @@
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>
393
+ </section>
124
394
 
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>
128
-
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
+ :directories="!selectable"
435
+ @uploaded="onUploaded"
436
+ />
437
+ <p class="cms-form-hint cms-media-upload-hint">
438
+ Tip: you can also drop files or folders anywhere on the library, or on a folder.
439
+ </p>
176
440
  </template>
177
441
  </CmsModal>
178
442
 
179
- <CmsModal v-if="!readOnly" v-model:open="newFolderOpen" title="New folder" size="sm">
443
+ <CmsModal v-if="canUpload" v-model:open="newFolderOpen" title="New folder" size="sm">
180
444
  <template #body>
181
445
  <div class="cms-form">
182
446
  <CmsFormField label="Name">
183
447
  <CmsInput
184
- v-model="newFolderName"
448
+ v-model="folderName"
185
449
  autofocus
186
- placeholder="e.g. blog/covers"
450
+ placeholder="e.g. covers"
187
451
  @keydown.enter.prevent="createFolder"
188
452
  />
189
453
  <p class="cms-form-hint">
190
454
  {{
191
- newFolderSlug ? `Files uploaded here are stored under ${newFolderSlug}/` : "Letters, numbers and dashes. Use / for nesting."
455
+ newFolderPath ? `Creates ${newFolderPath}/` : "Letters, numbers and dashes."
192
456
  }}
193
457
  </p>
194
458
  </CmsFormField>
@@ -199,312 +463,639 @@
199
463
  color="neutral"
200
464
  @click="newFolderOpen = false"
201
465
  />
202
- <CmsButton label="Create" :disabled="!newFolderSlug" @click="createFolder" />
466
+ <CmsButton
467
+ label="Create"
468
+ :loading="busy"
469
+ :disabled="!newFolderPath"
470
+ @click="createFolder"
471
+ />
203
472
  </div>
204
473
  </div>
205
474
  </template>
206
475
  </CmsModal>
207
476
 
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
- >
477
+ <CmsModal v-if="canManage" v-model:open="renameOpen" title="Rename folder" size="sm">
218
478
  <template #body>
219
479
  <div class="cms-form">
220
- <CmsFormField label="Alt text">
221
- <CmsInput v-model="editAlt" />
480
+ <CmsFormField label="Name">
481
+ <CmsInput v-model="folderName" autofocus @keydown.enter.prevent="renameFolder" />
222
482
  </CmsFormField>
223
- <CmsFormField v-if="canUpload" label="Folder">
483
+ <CmsFormField label="Location">
224
484
  <CmsMediaFolderPicker
225
- v-model="editFolder"
226
- :folders="folderNames"
227
- @create="onFolderCreate"
485
+ v-model="renameParent"
486
+ :folders="parentCandidates"
487
+ :exclude="folder"
228
488
  />
229
489
  </CmsFormField>
490
+ <p class="cms-form-hint">
491
+ {{
492
+ renamePath ? `Moves ${folder}/ to ${renamePath}/` : "Letters, numbers and dashes."
493
+ }}
494
+ </p>
495
+ <div class="cms-actions is-end">
496
+ <CmsButton
497
+ label="Cancel"
498
+ variant="ghost"
499
+ color="neutral"
500
+ @click="renameOpen = false"
501
+ />
502
+ <CmsButton
503
+ label="Save"
504
+ :loading="busy"
505
+ :disabled="!renamePath || renamePath === folder"
506
+ @click="renameFolder"
507
+ />
508
+ </div>
509
+ </div>
510
+ </template>
511
+ </CmsModal>
512
+
513
+ <CmsModal
514
+ v-if="canManage"
515
+ v-model:open="moveOpen"
516
+ :title="`Move ${selection.length} file${selection.length > 1 ? 's' : ''}`"
517
+ size="sm"
518
+ >
519
+ <template #body>
520
+ <div class="cms-form">
521
+ <CmsMediaFolderPicker v-model="moveTarget" :folders="library.folders.value" />
230
522
  <div class="cms-actions is-end">
231
523
  <CmsButton
232
524
  label="Cancel"
233
525
  variant="ghost"
234
526
  color="neutral"
235
- @click="editing = null"
527
+ @click="moveOpen = false"
236
528
  />
237
- <CmsButton label="Save" :loading="editSaving" @click="saveEdit" />
529
+ <CmsButton label="Move here" :loading="busy" @click="moveSelection" />
238
530
  </div>
239
531
  </div>
240
532
  </template>
241
533
  </CmsModal>
534
+
535
+ <CmsMediaDetail
536
+ v-if="!selectable"
537
+ :item="detail"
538
+ :library="library"
539
+ :read-only="readOnly"
540
+ @close="detailKey = null"
541
+ @delete="(item2) => removeItems([item2])"
542
+ />
242
543
  </div>
243
544
  </template>
244
545
 
245
546
  <script setup>
246
547
  import { computed, onMounted, ref, watch } from "#imports";
247
548
  import {
549
+ isWithinMediaFolder,
550
+ MEDIA_FOLDER_MAX_DEPTH,
248
551
  MEDIA_TYPES,
552
+ formatFileSize,
249
553
  mediaFilename,
554
+ mediaFolderAncestors,
555
+ mediaFolderDepth,
556
+ mediaFolderName,
557
+ mediaFolderParent,
250
558
  mediaIconFor,
559
+ mediaTypeAccept,
251
560
  mediaTypeFilter,
252
- normalizeMediaFolder
561
+ rebaseMediaFolder,
562
+ slugify
253
563
  } from "#nuxt-cms";
254
564
  import { useCmsConfirm } from "../../composables/cms-confirm";
565
+ import { useCmsMediaLibrary, usageSummary } from "../../composables/cms-media-library";
566
+ import {
567
+ uploadTreeFromDrop,
568
+ uploadTreeFromFiles,
569
+ useCmsMediaUploader
570
+ } from "../../composables/cms-media-uploader";
255
571
  import { useCmsRuntime } from "../../composables/cms-runtime";
256
572
  import { useCmsToast } from "../../composables/cms-toast";
257
- import { errorMessage } from "../../utils/ui";
573
+ const MEDIA_DRAG = "application/x-cms-media";
574
+ const FOLDER_DRAG = "application/x-cms-folder";
575
+ const VIEW_STORAGE_KEY = "cms-media-view";
576
+ const PAGE_SIZE = 24;
577
+ const RECENT_COUNT = 6;
258
578
  const filterLabels = {
259
579
  all: "All",
260
580
  image: "Images",
261
581
  video: "Videos",
262
582
  file: "Files"
263
583
  };
584
+ const sortLabels = {
585
+ newest: "Newest",
586
+ oldest: "Oldest",
587
+ name: "Name",
588
+ size: "Largest"
589
+ };
264
590
  const props = defineProps({
265
591
  selectable: { type: Boolean, required: false },
266
592
  mediaType: { type: null, required: false },
267
593
  accept: { type: Array, required: false }
268
594
  });
269
595
  const emit = defineEmits(["select"]);
596
+ const folder = defineModel("folder", { type: [String, null], ...{ default: null } });
270
597
  const toast = useCmsToast();
598
+ const confirmAction = useCmsConfirm();
271
599
  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
- }
600
+ const library = useCmsMediaLibrary();
601
+ const uploader = useCmsMediaUploader();
602
+ const readOnly = computed(() => runtime.mediaStorage === "local");
603
+ const canUpload = computed(() => !readOnly.value);
604
+ const canManage = computed(() => !readOnly.value && !props.selectable);
605
+ const canNest = computed(() => mediaFolderDepth(folder.value) < MEDIA_FOLDER_MAX_DEPTH);
606
+ onMounted(() => library.reload());
607
+ const notConfigured = computed(() => library.errorCode.value === 501);
608
+ const loadError = computed(
609
+ () => library.errorCode.value !== null && library.errorCode.value !== 501
610
+ );
296
611
  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}`;
612
+ const source = library.source.value;
613
+ if (!readOnly.value || !source) return null;
614
+ const { kind, root, builtAt } = source;
615
+ if (kind === "filesystem") return `Read live from ${root}`;
300
616
  if (kind === "manifest") {
301
617
  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`;
618
+ return `From the build of ${when}. Add files to ${root} and redeploy to update.`;
303
619
  }
304
- return `no media folder was found at build time \u2014 check cms.media.publicBaseUrl`;
620
+ return "No media folder was found at build time. Check cms.media.publicBaseUrl.";
305
621
  });
306
- onMounted(reload);
307
- const notConfigured = computed(() => errorCode.value === 501);
308
- const loadError = computed(() => errorCode.value !== null && errorCode.value !== 501);
622
+ watch(
623
+ () => [library.loading.value, library.folders.value],
624
+ ([loading, folders]) => {
625
+ if (loading || !folder.value || folders.includes(folder.value)) return;
626
+ folder.value = mediaFolderAncestors(folder.value).reverse().find((ancestor) => folders.includes(ancestor)) ?? null;
627
+ }
628
+ );
309
629
  const allowedTypes = computed(() => mediaTypeFilter(props.mediaType));
630
+ const acceptAttr = computed(
631
+ () => (props.accept?.length ? props.accept : mediaTypeAccept(props.mediaType))?.join(",")
632
+ );
310
633
  const filters = computed(() => [
311
634
  "all",
312
635
  ...allowedTypes.value ?? MEDIA_TYPES
313
636
  ]);
314
637
  const filter = ref("all");
315
- const showTypeFilters = computed(() => filters.value.length > 2 && items.value.length > 0);
316
638
  watch(filters, (list) => {
317
639
  if (!list.includes(filter.value)) filter.value = "all";
318
640
  });
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();
641
+ const typedItems = computed(() => {
642
+ const allowed = allowedTypes.value;
643
+ return allowed ? library.items.value.filter((item) => allowed.includes(item.type)) : library.items.value;
323
644
  });
324
- const folderNames = computed(
325
- () => [.../* @__PURE__ */ new Set([...serverFolders.value, ...usedFolders.value])].sort()
645
+ const hasTypedFiles = computed(() => typedItems.value.length > 0);
646
+ const showTypeFilters = computed(() => filters.value.length > 2 && hasTypedFiles.value);
647
+ const search = ref("");
648
+ const searchEverywhere = ref(false);
649
+ const query = computed(() => search.value.trim().toLowerCase());
650
+ const scope = computed(() => searchEverywhere.value ? null : folder.value);
651
+ const hasDates = computed(() => library.items.value.some((item) => item.createdAt));
652
+ const sort = ref("newest");
653
+ const activeSort = computed(
654
+ () => !hasDates.value && (sort.value === "newest" || sort.value === "oldest") ? "name" : sort.value
326
655
  );
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)
656
+ const sortItems = computed(
657
+ () => Object.keys(sortLabels).filter((key) => hasDates.value || key !== "newest" && key !== "oldest").map((key) => ({
658
+ label: sortLabels[key],
659
+ type: "checkbox",
660
+ checked: activeSort.value === key,
661
+ onUpdateChecked: () => {
662
+ sort.value = key;
663
+ }
340
664
  }))
341
665
  );
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
- );
666
+ function compareItems(a, b) {
667
+ const byName = mediaFilename(a.key).localeCompare(mediaFilename(b.key));
668
+ if (activeSort.value === "name") return byName;
669
+ if (activeSort.value === "size") return (b.size ?? 0) - (a.size ?? 0) || byName;
670
+ const delta = (Date.parse(b.createdAt ?? "") || 0) - (Date.parse(a.createdAt ?? "") || 0);
671
+ return (activeSort.value === "newest" ? delta : -delta) || byName;
351
672
  }
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
- }
673
+ function matchesQuery(value) {
674
+ return !!value && value.toLowerCase().includes(query.value);
371
675
  }
372
- const visible = computed(() => {
373
- const allowed = allowedTypes.value;
374
- let list = allowed ? items.value.filter((item) => allowed.includes(item.type)) : items.value;
676
+ const visibleFolders = computed(() => {
677
+ if (!query.value) return library.childFolders(folder.value);
678
+ return library.folders.value.filter(
679
+ (candidate) => candidate !== scope.value && isWithinMediaFolder(candidate, scope.value) && matchesQuery(mediaFolderName(candidate))
680
+ );
681
+ });
682
+ const visibleFiles = computed(() => {
683
+ let list = typedItems.value;
375
684
  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) {
685
+ if (query.value) {
379
686
  list = list.filter(
380
- (item) => [item.key, item.alt, item.folder].some((value) => value?.toLowerCase().includes(query))
687
+ (item) => isWithinMediaFolder(item.folder, scope.value) && [mediaFilename(item.key), item.alt, item.folder].some(matchesQuery)
381
688
  );
689
+ } else {
690
+ list = list.filter((item) => (item.folder ?? null) === folder.value);
382
691
  }
383
- return list;
692
+ return [...list].sort(compareItems);
693
+ });
694
+ const recent = computed(() => {
695
+ if (!props.selectable || query.value || folder.value || page.value > 1) return [];
696
+ if (typedItems.value.length <= RECENT_COUNT) return [];
697
+ return typedItems.value.filter((item) => item.createdAt).sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt)).slice(0, RECENT_COUNT);
384
698
  });
385
- const PAGE_SIZE = 24;
386
699
  const page = ref(1);
387
- watch([filter, folder, search], () => {
700
+ watch([filter, folder, query, searchEverywhere, sort], () => {
388
701
  page.value = 1;
389
702
  });
390
- watch(visible, (list) => {
703
+ watch(visibleFiles, (list) => {
391
704
  const pageCount = Math.max(1, Math.ceil(list.length / PAGE_SIZE));
392
705
  if (page.value > pageCount) page.value = pageCount;
393
706
  });
394
707
  const paged = computed(
395
- () => visible.value.slice((page.value - 1) * PAGE_SIZE, page.value * PAGE_SIZE)
708
+ () => visibleFiles.value.slice((page.value - 1) * PAGE_SIZE, page.value * PAGE_SIZE)
396
709
  );
397
- const uploadOpen = ref(false);
398
- const uploadFolder = ref(null);
399
- watch(uploadOpen, (open) => {
400
- if (open) uploadFolder.value = folder.value;
710
+ const emptyState = computed(() => {
711
+ if (query.value) {
712
+ return {
713
+ icon: "magnifying-glass",
714
+ title: "No matching media",
715
+ body: searchEverywhere.value || !folder.value ? "Try a different search or filter." : "Nothing here matches. Try searching all folders."
716
+ };
717
+ }
718
+ if (!library.items.value.length && !library.folders.value.length) {
719
+ return {
720
+ icon: "photo",
721
+ title: readOnly.value ? "No media" : "No media yet",
722
+ body: readOnly.value ? "No media registered." : "Drop files here or use Upload to add some."
723
+ };
724
+ }
725
+ if (folder.value) {
726
+ return {
727
+ icon: "folder-open",
728
+ title: "This folder is empty",
729
+ body: canUpload.value ? "Drop files here or use Upload to add some." : void 0
730
+ };
731
+ }
732
+ return {
733
+ icon: "photo",
734
+ title: hasTypedFiles.value ? "No files in the library root" : "No matching media",
735
+ body: hasTypedFiles.value ? "Open a folder to see its files." : "No files of this type yet."
736
+ };
401
737
  });
402
- function onUploaded(uploaded) {
403
- uploadOpen.value = false;
404
- if (props.selectable && uploaded[0]) {
405
- emit("select", uploaded[0]);
738
+ const view = ref("grid");
739
+ onMounted(() => {
740
+ try {
741
+ if (localStorage.getItem(VIEW_STORAGE_KEY) === "list") view.value = "list";
742
+ } catch {
743
+ }
744
+ });
745
+ watch(view, (value) => {
746
+ try {
747
+ localStorage.setItem(VIEW_STORAGE_KEY, value);
748
+ } catch {
749
+ }
750
+ });
751
+ const crumbs = computed(() => [
752
+ { label: "Library", folder: null },
753
+ ...folder.value ? mediaFolderAncestors(folder.value).map((path) => ({
754
+ label: mediaFolderName(path),
755
+ folder: path
756
+ })) : []
757
+ ]);
758
+ function openFolder(target) {
759
+ folder.value = target;
760
+ search.value = "";
761
+ searchEverywhere.value = false;
762
+ }
763
+ function folderMeta(path) {
764
+ const stats = library.stats.value.get(path);
765
+ if (!stats) return "Empty";
766
+ const parts = [];
767
+ if (stats.folders) parts.push(`${stats.folders} folder${stats.folders > 1 ? "s" : ""}`);
768
+ if (stats.files) parts.push(`${stats.files} file${stats.files > 1 ? "s" : ""}`);
769
+ return parts.join(" \xB7 ") || "Empty";
770
+ }
771
+ const selection = ref([]);
772
+ const lastSelected = ref(null);
773
+ const selectedItems = computed(() => {
774
+ const keys = new Set(selection.value);
775
+ return library.items.value.filter((item) => keys.has(item.key));
776
+ });
777
+ watch(folder, () => clearSelection());
778
+ watch(
779
+ () => library.items.value,
780
+ (items) => {
781
+ const keys = new Set(items.map((item) => item.key));
782
+ selection.value = selection.value.filter((key) => keys.has(key));
783
+ }
784
+ );
785
+ function isSelected(key) {
786
+ return selection.value.includes(key);
787
+ }
788
+ function toggleSelect(key, event) {
789
+ const shift = event?.shiftKey;
790
+ if (shift && lastSelected.value) {
791
+ const keys = visibleFiles.value.map((item) => item.key);
792
+ const from = keys.indexOf(lastSelected.value);
793
+ const to = keys.indexOf(key);
794
+ if (from !== -1 && to !== -1) {
795
+ const range = keys.slice(Math.min(from, to), Math.max(from, to) + 1);
796
+ selection.value = [.../* @__PURE__ */ new Set([...selection.value, ...range])];
797
+ lastSelected.value = key;
798
+ return;
799
+ }
800
+ }
801
+ selection.value = isSelected(key) ? selection.value.filter((entry) => entry !== key) : [...selection.value, key];
802
+ lastSelected.value = key;
803
+ }
804
+ function selectAll() {
805
+ selection.value = visibleFiles.value.map((item) => item.key);
806
+ }
807
+ function clearSelection() {
808
+ selection.value = [];
809
+ lastSelected.value = null;
810
+ }
811
+ const detailKey = ref(null);
812
+ const detail = computed(
813
+ () => library.items.value.find((item) => item.key === detailKey.value) ?? null
814
+ );
815
+ function emitSelect(item) {
816
+ emit("select", { key: item.key, url: item.url });
817
+ }
818
+ function onItemClick(item, event) {
819
+ if (props.selectable) {
820
+ emitSelect(item);
406
821
  return;
407
822
  }
408
- void reload();
823
+ if (canManage.value && (selection.value.length || event.metaKey || event.ctrlKey || event.shiftKey)) {
824
+ toggleSelect(item.key, event);
825
+ return;
826
+ }
827
+ detailKey.value = item.key;
828
+ }
829
+ const busy = ref(false);
830
+ async function run(action) {
831
+ if (busy.value) return false;
832
+ busy.value = true;
833
+ try {
834
+ return await action();
835
+ } finally {
836
+ busy.value = false;
837
+ }
409
838
  }
410
839
  const newFolderOpen = ref(false);
411
- const newFolderName = ref("");
412
- const newFolderSlug = computed(() => normalizeMediaFolder(newFolderName.value));
840
+ const folderName = ref("");
841
+ const folderSlug = computed(() => slugify(folderName.value));
842
+ const newFolderPath = computed(
843
+ () => folderSlug.value ? folder.value ? `${folder.value}/${folderSlug.value}` : folderSlug.value : null
844
+ );
413
845
  function openNewFolder() {
414
- newFolderName.value = "";
846
+ folderName.value = "";
415
847
  newFolderOpen.value = true;
416
848
  }
417
- const creatingFolder = ref(false);
418
849
  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;
850
+ const path = newFolderPath.value;
851
+ if (!path) return;
852
+ if (library.folders.value.includes(path)) {
425
853
  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;
854
+ openFolder(path);
855
+ return;
434
856
  }
857
+ const ok = await run(() => library.createFolder(path));
858
+ if (!ok) return;
859
+ newFolderOpen.value = false;
860
+ openFolder(path);
435
861
  }
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 });
862
+ const renameOpen = ref(false);
863
+ const renameParent = ref(null);
864
+ const parentCandidates = computed(() => {
865
+ const own = folder.value;
866
+ if (!own) return [];
867
+ const subtreeDepth = Math.max(
868
+ ...library.folders.value.filter((candidate) => isWithinMediaFolder(candidate, own)).map((candidate) => mediaFolderDepth(candidate) - mediaFolderDepth(own) + 1)
869
+ );
870
+ return library.folders.value.filter(
871
+ (candidate) => mediaFolderDepth(candidate) + subtreeDepth <= MEDIA_FOLDER_MAX_DEPTH
872
+ );
873
+ });
874
+ const renamePath = computed(() => {
875
+ if (!folderSlug.value) return null;
876
+ return renameParent.value ? `${renameParent.value}/${folderSlug.value}` : folderSlug.value;
877
+ });
878
+ function openRename() {
879
+ if (!folder.value) return;
880
+ folderName.value = mediaFolderName(folder.value);
881
+ renameParent.value = mediaFolderParent(folder.value);
882
+ renameOpen.value = true;
883
+ }
884
+ async function renameFolder() {
885
+ const from = folder.value;
886
+ const to = renamePath.value;
887
+ if (!from || !to || from === to) return;
888
+ const ok = await run(() => library.moveFolder(from, to));
889
+ if (!ok) return;
890
+ renameOpen.value = false;
891
+ openFolder(to);
892
+ }
893
+ async function removeFolder(path) {
894
+ const files = library.itemsWithin(path);
895
+ const subfolders = library.folders.value.filter(
896
+ (candidate) => candidate !== path && isWithinMediaFolder(candidate, path)
897
+ ).length;
898
+ const contents = [];
899
+ if (subfolders) contents.push(`${subfolders} subfolder${subfolders > 1 ? "s" : ""}`);
900
+ if (files.length) contents.push(`${files.length} file${files.length > 1 ? "s" : ""}`);
901
+ const keys = files.map((item) => item.key);
902
+ const summary = usageSummary(await library.usage(keys), keys);
903
+ const message = contents.length ? `Delete "${mediaFolderName(path)}" with ${contents.join(" and ")}? ${summary}` : `Delete the empty folder "${mediaFolderName(path)}"?`;
904
+ if (!await confirmAction(message.trim(), { title: "Delete folder", confirmLabel: "Delete" }))
443
905
  return;
906
+ const ok = await run(() => library.deleteFolder(path));
907
+ if (ok && folder.value && isWithinMediaFolder(folder.value, path)) {
908
+ openFolder(mediaFolderParent(path));
444
909
  }
445
- if (canEditAlt.value) openEdit(item);
446
910
  }
447
- function openEdit(item) {
448
- editing.value = item;
449
- editAlt.value = item.alt ?? "";
450
- editFolder.value = item.folder ?? null;
911
+ async function removeItems(items) {
912
+ if (!items.length) return;
913
+ const keys = items.map((item) => item.key);
914
+ const summary = usageSummary(await library.usage(keys), keys);
915
+ const question = keys.length === 1 ? `Delete "${mediaFilename(keys[0])}"?` : `Delete ${keys.length} files?`;
916
+ if (!await confirmAction(`${question} ${summary}`.trim(), {
917
+ title: "Delete",
918
+ confirmLabel: "Delete"
919
+ }))
920
+ return;
921
+ const ok = await run(() => library.deleteItems(keys));
922
+ if (!ok) return;
923
+ if (detailKey.value && keys.includes(detailKey.value)) detailKey.value = null;
924
+ clearSelection();
451
925
  }
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) {
926
+ const moveOpen = ref(false);
927
+ const moveTarget = ref(null);
928
+ function openMove() {
929
+ moveTarget.value = folder.value;
930
+ moveOpen.value = true;
931
+ }
932
+ async function moveKeys(keys, target) {
933
+ const moving = keys.filter(
934
+ (key) => (library.items.value.find((item) => item.key === key)?.folder ?? null) !== target
935
+ );
936
+ if (!moving.length) return true;
937
+ const ok = await run(() => library.moveItems(moving, target));
938
+ if (ok) {
467
939
  toast.add({
468
- title: "Save failed",
469
- description: errorMessage(err),
470
- color: "error"
940
+ title: `Moved ${moving.length} file${moving.length > 1 ? "s" : ""} to ${target ?? "Library root"}`,
941
+ color: "success"
471
942
  });
472
- } finally {
473
- editSaving.value = false;
474
943
  }
944
+ return ok;
475
945
  }
476
- function extension(key) {
477
- const dot = key.lastIndexOf(".");
478
- return dot === -1 ? "file" : key.slice(dot + 1).toLowerCase();
946
+ async function moveSelection() {
947
+ const ok = await moveKeys(selection.value, moveTarget.value);
948
+ if (!ok) return;
949
+ moveOpen.value = false;
950
+ clearSelection();
479
951
  }
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}`;
952
+ const uploadOpen = ref(false);
953
+ async function afterUpload(uploaded) {
954
+ if (!uploaded.length) {
955
+ if (!props.selectable) await library.reload({ quiet: true });
956
+ return;
957
+ }
958
+ if (props.selectable && uploaded[0]) {
959
+ emitSelect(uploaded[0]);
960
+ return;
487
961
  }
488
- return `${Math.round(value / 1024)} TB`;
962
+ await library.reload({ quiet: true });
963
+ toast.add({
964
+ title: `Uploaded ${uploaded.length} file${uploaded.length > 1 ? "s" : ""}`,
965
+ color: "success"
966
+ });
489
967
  }
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."))
968
+ function onUploaded(uploaded) {
969
+ uploadOpen.value = false;
970
+ void afterUpload(uploaded);
971
+ }
972
+ function droppedTree(event) {
973
+ const data = event.dataTransfer;
974
+ if (!data?.files.length) return null;
975
+ return props.selectable ? Promise.resolve(uploadTreeFromFiles(data.files)) : uploadTreeFromDrop(data);
976
+ }
977
+ async function uploadTree(pending, target) {
978
+ const tree = await pending;
979
+ const uploaded = await uploader.upload(tree, {
980
+ folder: target,
981
+ accept: acceptAttr.value,
982
+ multiple: !props.selectable
983
+ });
984
+ if (uploaded.length || tree.directories.length) await afterUpload(uploaded);
985
+ }
986
+ const draggingKeys = ref([]);
987
+ const draggingFolder = ref(null);
988
+ const dropTarget = ref(null);
989
+ const fileDragDepth = ref(0);
990
+ const fileDragActive = computed(() => fileDragDepth.value > 0 && canUpload.value);
991
+ const ROOT_TARGET = ":root";
992
+ function targetId(target) {
993
+ return target ?? ROOT_TARGET;
994
+ }
995
+ const dropTargetLabel = computed(() => {
996
+ if (!dropTarget.value) return folder.value ?? "Library root";
997
+ return dropTarget.value === ROOT_TARGET ? "Library root" : dropTarget.value;
998
+ });
999
+ function hasFiles(event) {
1000
+ return !!event.dataTransfer?.types.includes("Files");
1001
+ }
1002
+ function onItemDragStart(event, item) {
1003
+ if (!canManage.value || !event.dataTransfer) return;
1004
+ const keys = isSelected(item.key) ? [...selection.value] : [item.key];
1005
+ draggingKeys.value = keys;
1006
+ event.dataTransfer.effectAllowed = "move";
1007
+ event.dataTransfer.setData(MEDIA_DRAG, JSON.stringify(keys));
1008
+ }
1009
+ function onFolderDragStart(event, path) {
1010
+ if (!canManage.value || !event.dataTransfer) return;
1011
+ draggingFolder.value = path;
1012
+ event.dataTransfer.effectAllowed = "move";
1013
+ event.dataTransfer.setData(FOLDER_DRAG, path);
1014
+ }
1015
+ function onDragEnd() {
1016
+ draggingKeys.value = [];
1017
+ draggingFolder.value = null;
1018
+ dropTarget.value = null;
1019
+ }
1020
+ function folderMoveTarget(path, target) {
1021
+ const name = mediaFolderName(path);
1022
+ return target ? `${target}/${name}` : name;
1023
+ }
1024
+ function acceptsDrop(event, target) {
1025
+ if (draggingKeys.value.length) return true;
1026
+ if (draggingFolder.value) {
1027
+ const from = draggingFolder.value;
1028
+ if (target && isWithinMediaFolder(target, from)) return false;
1029
+ if (mediaFolderParent(from) === target) return false;
1030
+ const depth = Math.max(
1031
+ ...library.folders.value.filter((candidate) => isWithinMediaFolder(candidate, from)).map((candidate) => mediaFolderDepth(candidate) - mediaFolderDepth(from) + 1)
1032
+ );
1033
+ return mediaFolderDepth(target) + depth <= MEDIA_FOLDER_MAX_DEPTH;
1034
+ }
1035
+ return hasFiles(event) && canUpload.value;
1036
+ }
1037
+ function onTargetDragOver(event, target) {
1038
+ if (!acceptsDrop(event, target)) return;
1039
+ event.preventDefault();
1040
+ event.stopPropagation();
1041
+ if (event.dataTransfer) event.dataTransfer.dropEffect = hasFiles(event) ? "copy" : "move";
1042
+ dropTarget.value = targetId(target);
1043
+ }
1044
+ function onTargetDragLeave(target) {
1045
+ if (dropTarget.value === targetId(target)) dropTarget.value = null;
1046
+ }
1047
+ async function onTargetDrop(event, target) {
1048
+ if (!acceptsDrop(event, target)) return;
1049
+ event.preventDefault();
1050
+ event.stopPropagation();
1051
+ const keys = [...draggingKeys.value];
1052
+ const path = draggingFolder.value;
1053
+ const tree = hasFiles(event) ? droppedTree(event) : null;
1054
+ onDragEnd();
1055
+ fileDragDepth.value = 0;
1056
+ if (keys.length) {
1057
+ if (await moveKeys(keys, target)) clearSelection();
495
1058
  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
1059
  }
1060
+ if (path) {
1061
+ const to = folderMoveTarget(path, target);
1062
+ const ok = await run(() => library.moveFolder(path, to));
1063
+ if (ok && folder.value && isWithinMediaFolder(folder.value, path)) {
1064
+ folder.value = rebaseMediaFolder(folder.value, path, to);
1065
+ }
1066
+ return;
1067
+ }
1068
+ if (tree) await uploadTree(tree, target);
1069
+ }
1070
+ function onGalleryDragEnter(event) {
1071
+ if (!hasFiles(event) || !canUpload.value) return;
1072
+ fileDragDepth.value++;
1073
+ }
1074
+ function onGalleryDragOver(event) {
1075
+ if (!hasFiles(event) || !canUpload.value) return;
1076
+ event.preventDefault();
1077
+ if (event.dataTransfer) event.dataTransfer.dropEffect = "copy";
1078
+ }
1079
+ function onGalleryDragLeave(event) {
1080
+ if (!hasFiles(event) || !canUpload.value) return;
1081
+ fileDragDepth.value = Math.max(0, fileDragDepth.value - 1);
1082
+ }
1083
+ async function onGalleryDrop(event) {
1084
+ if (!hasFiles(event) || !canUpload.value) return;
1085
+ event.preventDefault();
1086
+ fileDragDepth.value = 0;
1087
+ dropTarget.value = null;
1088
+ const tree = droppedTree(event);
1089
+ if (tree) await uploadTree(tree, folder.value);
1090
+ }
1091
+ function tileInfo(item) {
1092
+ const parts = [extension(item.key)];
1093
+ if (item.size != null) parts.push(formatFileSize(item.size));
1094
+ if (item.width && item.height) parts.push(`${item.width}\xD7${item.height}`);
1095
+ return parts.join(" \xB7 ");
1096
+ }
1097
+ function extension(key) {
1098
+ const dot = key.lastIndexOf(".");
1099
+ return dot === -1 ? "file" : key.slice(dot + 1).toLowerCase();
509
1100
  }
510
1101
  </script>