@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.
- package/dist/module.json +1 -1
- package/dist/module.mjs +137 -9
- package/dist/runtime/app/components/cms/BlocksField.vue +86 -10
- package/dist/runtime/app/components/cms/BrandMark.d.vue.ts +6 -0
- package/dist/runtime/app/components/cms/BrandMark.vue +19 -0
- package/dist/runtime/app/components/cms/BrandMark.vue.d.ts +6 -0
- package/dist/runtime/app/components/cms/Checkbox.d.vue.ts +11 -0
- package/dist/runtime/app/components/cms/Checkbox.vue +21 -0
- package/dist/runtime/app/components/cms/Checkbox.vue.d.ts +11 -0
- package/dist/runtime/app/components/cms/EmptyState.vue +4 -2
- package/dist/runtime/app/components/cms/EntryDrawer.vue +1 -0
- package/dist/runtime/app/components/cms/EntryForm.d.vue.ts +1 -0
- package/dist/runtime/app/components/cms/EntryForm.vue +105 -30
- package/dist/runtime/app/components/cms/EntryForm.vue.d.ts +1 -0
- package/dist/runtime/app/components/cms/FieldInput.vue +20 -5
- package/dist/runtime/app/components/cms/FormField.d.vue.ts +5 -3
- package/dist/runtime/app/components/cms/FormField.vue +8 -2
- package/dist/runtime/app/components/cms/FormField.vue.d.ts +5 -3
- package/dist/runtime/app/components/cms/Icon.vue +1 -1
- 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 +927 -336
- package/dist/runtime/app/components/cms/MediaGallery.vue.d.ts +8 -2
- package/dist/runtime/app/components/cms/MediaUpload.d.vue.ts +1 -0
- package/dist/runtime/app/components/cms/MediaUpload.vue +76 -139
- package/dist/runtime/app/components/cms/MediaUpload.vue.d.ts +1 -0
- package/dist/runtime/app/components/cms/PageHeader.d.vue.ts +5 -3
- package/dist/runtime/app/components/cms/PageHeader.vue +16 -6
- package/dist/runtime/app/components/cms/PageHeader.vue.d.ts +5 -3
- package/dist/runtime/app/components/cms/PasswordInput.d.vue.ts +17 -0
- package/dist/runtime/app/components/cms/PasswordInput.vue +77 -0
- package/dist/runtime/app/components/cms/PasswordInput.vue.d.ts +17 -0
- package/dist/runtime/app/components/cms/SelectMenu.vue +3 -5
- package/dist/runtime/app/components/cms/SettingsAccount.d.vue.ts +3 -0
- package/dist/runtime/app/components/cms/SettingsAccount.vue +138 -0
- package/dist/runtime/app/components/cms/SettingsAccount.vue.d.ts +3 -0
- package/dist/runtime/app/components/cms/SettingsColumns.d.vue.ts +16 -0
- package/dist/runtime/app/components/cms/SettingsColumns.vue +102 -0
- package/dist/runtime/app/components/cms/SettingsColumns.vue.d.ts +16 -0
- package/dist/runtime/app/components/cms/SettingsEntry.d.vue.ts +6 -0
- package/dist/runtime/app/components/cms/SettingsEntry.vue +133 -0
- package/dist/runtime/app/components/cms/SettingsEntry.vue.d.ts +6 -0
- package/dist/runtime/app/components/cms/SettingsLayout.d.vue.ts +16 -0
- package/dist/runtime/app/components/cms/SettingsLayout.vue +177 -0
- package/dist/runtime/app/components/cms/SettingsLayout.vue.d.ts +16 -0
- package/dist/runtime/app/components/cms/SettingsModal.d.vue.ts +16 -0
- package/dist/runtime/app/components/cms/SettingsModal.vue +179 -0
- package/dist/runtime/app/components/cms/SettingsModal.vue.d.ts +16 -0
- package/dist/runtime/app/components/cms/SettingsUsers.d.vue.ts +3 -0
- package/dist/runtime/app/components/cms/SettingsUsers.vue +270 -0
- package/dist/runtime/app/components/cms/SettingsUsers.vue.d.ts +3 -0
- package/dist/runtime/app/components/cms/Spinner.vue +2 -2
- package/dist/runtime/app/components/cms/Table.d.vue.ts +7 -2
- package/dist/runtime/app/components/cms/Table.vue +38 -1
- package/dist/runtime/app/components/cms/Table.vue.d.ts +7 -2
- package/dist/runtime/app/components/cms/Toaster.vue +1 -1
- package/dist/runtime/app/components/cms/WelcomeModal.d.vue.ts +3 -0
- package/dist/runtime/app/components/cms/WelcomeModal.vue +69 -0
- package/dist/runtime/app/components/cms/WelcomeModal.vue.d.ts +3 -0
- package/dist/runtime/app/composables/cms-account.d.ts +11 -0
- package/dist/runtime/app/composables/cms-account.js +23 -0
- 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 +22 -0
- package/dist/runtime/app/composables/cms-media-uploader.js +244 -0
- package/dist/runtime/app/composables/cms-settings.d.ts +13 -0
- package/dist/runtime/app/composables/cms-settings.js +59 -0
- package/dist/runtime/app/composables/cms-theme.d.ts +9 -0
- package/dist/runtime/app/composables/cms-theme.js +27 -0
- package/dist/runtime/app/layouts/cms-admin.d.vue.ts +2 -2
- package/dist/runtime/app/layouts/cms-admin.vue +71 -17
- package/dist/runtime/app/layouts/cms-admin.vue.d.ts +2 -2
- package/dist/runtime/app/pages/admin-collection.vue +127 -101
- package/dist/runtime/app/pages/admin-entry.vue +1 -0
- package/dist/runtime/app/pages/admin-login.vue +18 -3
- package/dist/runtime/app/pages/admin-media.vue +19 -3
- package/dist/runtime/app/utils/ui.d.ts +2 -0
- package/dist/runtime/app/utils/ui.js +18 -0
- package/dist/runtime/assets/main.css +1 -1
- package/dist/runtime/server/api/account-password.put.d.ts +4 -0
- package/dist/runtime/server/api/account-password.put.js +38 -0
- package/dist/runtime/server/api/account-welcome.post.d.ts +4 -0
- package/dist/runtime/server/api/account-welcome.post.js +14 -0
- package/dist/runtime/server/api/account.put.d.ts +3 -0
- package/dist/runtime/server/api/account.put.js +25 -0
- 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/api/settings.delete.d.ts +4 -0
- package/dist/runtime/server/api/settings.delete.js +12 -0
- package/dist/runtime/server/api/settings.get.d.ts +3 -0
- package/dist/runtime/server/api/settings.get.js +15 -0
- package/dist/runtime/server/api/settings.put.d.ts +3 -0
- package/dist/runtime/server/api/settings.put.js +14 -0
- package/dist/runtime/server/api/user.delete.d.ts +4 -0
- package/dist/runtime/server/api/user.delete.js +12 -0
- package/dist/runtime/server/api/user.put.d.ts +3 -0
- package/dist/runtime/server/api/user.put.js +27 -0
- package/dist/runtime/server/api/users.get.d.ts +3 -0
- package/dist/runtime/server/api/users.get.js +11 -0
- package/dist/runtime/server/api/users.post.d.ts +3 -0
- package/dist/runtime/server/api/users.post.js +32 -0
- package/dist/runtime/server/plugins/session-validate.d.ts +2 -0
- package/dist/runtime/server/plugins/session-validate.js +13 -0
- package/dist/runtime/server/routes/auth/login.post.js +18 -8
- 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/server/utils/password.d.ts +3 -0
- package/dist/runtime/server/utils/password.js +31 -0
- package/dist/runtime/server/utils/require-admin.d.ts +6 -2
- package/dist/runtime/server/utils/require-admin.js +33 -28
- package/dist/runtime/server/utils/same-origin.d.ts +2 -0
- package/dist/runtime/server/utils/same-origin.js +24 -0
- package/dist/runtime/server/utils/settings.d.ts +16 -0
- package/dist/runtime/server/utils/settings.js +31 -0
- package/dist/runtime/server/utils/users.d.ts +15 -0
- package/dist/runtime/server/utils/users.js +34 -0
- package/dist/runtime/shared/index.d.ts +29 -1
- package/dist/runtime/shared/index.js +36 -2
- package/dist/runtime/shared/layout.d.ts +34 -0
- package/dist/runtime/shared/layout.js +130 -0
- package/dist/runtime/shared/users.d.ts +18 -0
- package/dist/runtime/shared/users.js +1 -0
- package/dist/runtime/shared/validation.js +5 -4
- 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
|
|
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,277 @@
|
|
|
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>
|
|
393
|
+
</section>
|
|
124
394
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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="
|
|
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="
|
|
448
|
+
v-model="folderName"
|
|
185
449
|
autofocus
|
|
186
|
-
placeholder="e.g.
|
|
450
|
+
placeholder="e.g. covers"
|
|
187
451
|
@keydown.enter.prevent="createFolder"
|
|
188
452
|
/>
|
|
189
453
|
<p class="cms-form-hint">
|
|
190
454
|
{{
|
|
191
|
-
|
|
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
|
|
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="
|
|
221
|
-
<CmsInput v-model="
|
|
480
|
+
<CmsFormField label="Name">
|
|
481
|
+
<CmsInput v-model="folderName" autofocus @keydown.enter.prevent="renameFolder" />
|
|
222
482
|
</CmsFormField>
|
|
223
|
-
<CmsFormField
|
|
483
|
+
<CmsFormField label="Location">
|
|
224
484
|
<CmsMediaFolderPicker
|
|
225
|
-
v-model="
|
|
226
|
-
:folders="
|
|
227
|
-
|
|
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="
|
|
527
|
+
@click="moveOpen = false"
|
|
236
528
|
/>
|
|
237
|
-
<CmsButton label="
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
}
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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 `
|
|
618
|
+
return `From the build of ${when}. Add files to ${root} and redeploy to update.`;
|
|
303
619
|
}
|
|
304
|
-
return
|
|
620
|
+
return "No media folder was found at build time. Check cms.media.publicBaseUrl.";
|
|
305
621
|
});
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
|
320
|
-
const
|
|
321
|
-
|
|
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
|
|
325
|
-
|
|
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
|
|
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)
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
673
|
+
function matchesQuery(value) {
|
|
674
|
+
return !!value && value.toLowerCase().includes(query.value);
|
|
371
675
|
}
|
|
372
|
-
const
|
|
373
|
-
|
|
374
|
-
|
|
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 (
|
|
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(
|
|
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,
|
|
700
|
+
watch([filter, folder, query, searchEverywhere, sort], () => {
|
|
388
701
|
page.value = 1;
|
|
389
702
|
});
|
|
390
|
-
watch(
|
|
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
|
-
() =>
|
|
708
|
+
() => visibleFiles.value.slice((page.value - 1) * PAGE_SIZE, page.value * PAGE_SIZE)
|
|
396
709
|
);
|
|
397
|
-
const
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
|
|
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
|
|
412
|
-
const
|
|
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
|
-
|
|
846
|
+
folderName.value = "";
|
|
415
847
|
newFolderOpen.value = true;
|
|
416
848
|
}
|
|
417
|
-
const creatingFolder = ref(false);
|
|
418
849
|
async function createFolder() {
|
|
419
|
-
const
|
|
420
|
-
if (!
|
|
421
|
-
|
|
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
|
-
|
|
427
|
-
|
|
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
|
|
437
|
-
const
|
|
438
|
-
const
|
|
439
|
-
const
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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: "
|
|
469
|
-
|
|
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
|
|
477
|
-
const
|
|
478
|
-
|
|
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
|
-
|
|
481
|
-
|
|
482
|
-
if (
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
-
|
|
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
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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>
|