@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.
- package/dist/module.json +1 -1
- package/dist/module.mjs +53 -23
- package/dist/runtime/app/components/cms/MediaDetail.d.vue.ts +16 -0
- package/dist/runtime/app/components/cms/MediaDetail.vue +169 -0
- package/dist/runtime/app/components/cms/MediaDetail.vue.d.ts +16 -0
- package/dist/runtime/app/components/cms/MediaField.vue +9 -2
- package/dist/runtime/app/components/cms/MediaFolderPicker.d.vue.ts +2 -2
- package/dist/runtime/app/components/cms/MediaFolderPicker.vue +55 -54
- package/dist/runtime/app/components/cms/MediaFolderPicker.vue.d.ts +2 -2
- package/dist/runtime/app/components/cms/MediaGallery.d.vue.ts +8 -2
- package/dist/runtime/app/components/cms/MediaGallery.vue +913 -336
- package/dist/runtime/app/components/cms/MediaGallery.vue.d.ts +8 -2
- package/dist/runtime/app/components/cms/MediaUpload.vue +10 -103
- package/dist/runtime/app/composables/cms-media-library.d.ts +75 -0
- package/dist/runtime/app/composables/cms-media-library.js +151 -0
- package/dist/runtime/app/composables/cms-media-uploader.d.ts +12 -0
- package/dist/runtime/app/composables/cms-media-uploader.js +117 -0
- package/dist/runtime/app/pages/admin-media.vue +14 -2
- package/dist/runtime/assets/main.css +1 -1
- package/dist/runtime/server/api/media-delete.post.d.ts +4 -0
- package/dist/runtime/server/api/media-delete.post.js +23 -0
- package/dist/runtime/server/api/media-folder.delete.js +6 -16
- package/dist/runtime/server/api/media-folder.patch.d.ts +5 -0
- package/dist/runtime/server/api/media-folder.patch.js +68 -0
- package/dist/runtime/server/api/media-folder.post.js +3 -15
- package/dist/runtime/server/api/media-move.post.d.ts +5 -0
- package/dist/runtime/server/api/media-move.post.js +28 -0
- package/dist/runtime/server/api/media-usage.post.d.ts +5 -0
- package/dist/runtime/server/api/media-usage.post.js +15 -0
- package/dist/runtime/server/utils/media-folders.d.ts +86 -0
- package/dist/runtime/server/utils/media-folders.js +37 -0
- package/dist/runtime/server/utils/media-references.d.ts +7 -1
- package/dist/runtime/server/utils/media-references.js +40 -0
- package/dist/runtime/server/utils/media-usage.d.ts +2 -0
- package/dist/runtime/server/utils/media-usage.js +18 -0
- package/dist/runtime/shared/index.d.ts +23 -1
- package/dist/runtime/shared/index.js +86 -2
- 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
|
|
10
|
-
|
|
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
|
|
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
|
-
<
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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-
|
|
38
|
-
:
|
|
39
|
-
@click="
|
|
210
|
+
class="cms-card cms-media-tile is-selectable"
|
|
211
|
+
:title="mediaFilename(item.key)"
|
|
212
|
+
@click="emitSelect(item)"
|
|
40
213
|
>
|
|
41
|
-
<
|
|
42
|
-
|
|
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-
|
|
238
|
+
v-for="child in visibleFolders"
|
|
239
|
+
:key="child"
|
|
46
240
|
type="button"
|
|
47
|
-
class="cms-
|
|
48
|
-
:
|
|
49
|
-
|
|
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
|
|
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
|
-
</
|
|
54
|
-
</
|
|
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
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
:
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
</
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
</
|
|
418
|
+
</template>
|
|
150
419
|
|
|
151
|
-
<
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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="
|
|
427
|
+
<CmsModal v-if="canUpload" v-model:open="uploadOpen" title="Upload media">
|
|
159
428
|
<template #body>
|
|
160
|
-
<
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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="
|
|
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="
|
|
447
|
+
v-model="folderName"
|
|
185
448
|
autofocus
|
|
186
|
-
placeholder="e.g.
|
|
449
|
+
placeholder="e.g. covers"
|
|
187
450
|
@keydown.enter.prevent="createFolder"
|
|
188
451
|
/>
|
|
189
452
|
<p class="cms-form-hint">
|
|
190
453
|
{{
|
|
191
|
-
|
|
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
|
|
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="
|
|
221
|
-
<CmsInput v-model="
|
|
479
|
+
<CmsFormField label="Name">
|
|
480
|
+
<CmsInput v-model="folderName" autofocus @keydown.enter.prevent="renameFolder" />
|
|
222
481
|
</CmsFormField>
|
|
223
|
-
<CmsFormField
|
|
482
|
+
<CmsFormField label="Location">
|
|
224
483
|
<CmsMediaFolderPicker
|
|
225
|
-
v-model="
|
|
226
|
-
:folders="
|
|
227
|
-
|
|
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="
|
|
526
|
+
@click="moveOpen = false"
|
|
236
527
|
/>
|
|
237
|
-
<CmsButton label="
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
273
|
-
const
|
|
274
|
-
const
|
|
275
|
-
const
|
|
276
|
-
const
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
const
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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 `
|
|
613
|
+
return `From the build of ${when}. Add files to ${root} and redeploy to update.`;
|
|
303
614
|
}
|
|
304
|
-
return
|
|
615
|
+
return "No media folder was found at build time. Check cms.media.publicBaseUrl.";
|
|
305
616
|
});
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
|
320
|
-
const
|
|
321
|
-
|
|
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
|
|
325
|
-
|
|
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
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
353
|
-
|
|
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
|
|
373
|
-
|
|
374
|
-
|
|
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 (
|
|
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(
|
|
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,
|
|
695
|
+
watch([filter, folder, query, searchEverywhere, sort], () => {
|
|
388
696
|
page.value = 1;
|
|
389
697
|
});
|
|
390
|
-
watch(
|
|
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
|
-
() =>
|
|
703
|
+
() => visibleFiles.value.slice((page.value - 1) * PAGE_SIZE, page.value * PAGE_SIZE)
|
|
396
704
|
);
|
|
397
|
-
const
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
|
|
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
|
|
412
|
-
const
|
|
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
|
-
|
|
841
|
+
folderName.value = "";
|
|
415
842
|
newFolderOpen.value = true;
|
|
416
843
|
}
|
|
417
|
-
const creatingFolder = ref(false);
|
|
418
844
|
async function createFolder() {
|
|
419
|
-
const
|
|
420
|
-
if (!
|
|
421
|
-
|
|
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
|
-
|
|
427
|
-
|
|
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
|
|
437
|
-
const
|
|
438
|
-
const
|
|
439
|
-
const
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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: "
|
|
469
|
-
|
|
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
|
|
477
|
-
const
|
|
478
|
-
|
|
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
|
-
|
|
481
|
-
|
|
482
|
-
if (
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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
|
-
|
|
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
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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>
|