nuxt-media-library 0.0.64 → 0.0.66

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/dist/module.json +1 -1
  2. package/dist/runtime/components/MLMedia/Browser.d.vue.ts +98 -0
  3. package/dist/runtime/components/MLMedia/Browser.vue +521 -0
  4. package/dist/runtime/components/MLMedia/Browser.vue.d.ts +98 -0
  5. package/dist/runtime/components/MLMedia/FilePickerPanel.d.vue.ts +1 -1
  6. package/dist/runtime/components/MLMedia/FilePickerPanel.vue +33 -287
  7. package/dist/runtime/components/MLMedia/FilePickerPanel.vue.d.ts +1 -1
  8. package/dist/runtime/components/MLMedia/GallerySection.vue +21 -6
  9. package/dist/runtime/components/MLMedia/MediaTile.d.vue.ts +7 -1
  10. package/dist/runtime/components/MLMedia/MediaTile.vue +5 -2
  11. package/dist/runtime/components/MLMedia/MediaTile.vue.d.ts +7 -1
  12. package/dist/runtime/components/MLMedia/PickerCard.d.vue.ts +7 -0
  13. package/dist/runtime/components/MLMedia/PickerCard.vue +17 -1
  14. package/dist/runtime/components/MLMedia/PickerCard.vue.d.ts +7 -0
  15. package/dist/runtime/components/MLMedia/PickerRow.d.vue.ts +16 -1
  16. package/dist/runtime/components/MLMedia/PickerRow.vue +47 -30
  17. package/dist/runtime/components/MLMedia/PickerRow.vue.d.ts +16 -1
  18. package/dist/runtime/components/MLMedia/ProgressVeil.d.vue.ts +2 -1
  19. package/dist/runtime/components/MLMedia/ProgressVeil.vue +10 -7
  20. package/dist/runtime/components/MLMedia/ProgressVeil.vue.d.ts +2 -1
  21. package/dist/runtime/components/MLProcessing/StepperItem.d.vue.ts +2 -2
  22. package/dist/runtime/components/MLProcessing/StepperItem.vue.d.ts +2 -2
  23. package/dist/runtime/composables/useMLAssetStall.d.ts +23 -0
  24. package/dist/runtime/composables/useMLAssetStall.js +59 -0
  25. package/dist/runtime/composables/useMLMediaGallery.d.ts +2 -0
  26. package/dist/runtime/composables/useMLMediaGallery.js +2 -0
  27. package/dist/runtime/utils/browserActions.d.ts +47 -0
  28. package/dist/runtime/utils/browserActions.js +36 -0
  29. package/package.json +1 -1
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "media-library",
3
3
  "configKey": "mediaLibrary",
4
- "version": "0.0.64",
4
+ "version": "0.0.66",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "3.6.1"
@@ -0,0 +1,98 @@
1
+ import type { AssetDetail } from '../../types/asset.js';
2
+ import { type MediaUploadOptions } from '../../composables/useMLMediaUpload.js';
3
+ import { type MediaEditorResult } from '../../composables/useMLMediaEditor.js';
4
+ import { type MediaBrowserAction, type MediaBrowserRole } from '../../utils/browserActions.js';
5
+ export type { MediaBrowserAction, MediaBrowserRole };
6
+ type __VLS_Props = {
7
+ /**
8
+ * List only these asset IDs (resolved through `backend`'s `resolve`
9
+ * route, searched and filtered here) instead of the library. A pool the
10
+ * host owns, e.g. a course's files. null lists the library.
11
+ */
12
+ pickFrom?: readonly string[] | null;
13
+ /** Backend whose `resolve` route reads the files. Defaults to the upload backend. */
14
+ backend?: string | null;
15
+ /** Kinds that can be selected. Others are listed but dimmed. Empty means any. */
16
+ accept?: readonly string[] | string;
17
+ /** 'none' shows no checkboxes (a click previews); 'single' replaces; 'multi' adds up to `max`. */
18
+ selection?: 'none' | 'single' | 'multi';
19
+ /** Most files that can be selected in 'multi'. */
20
+ max?: number;
21
+ /** IDs listed first (when not searching), e.g. the files a record already has. */
22
+ pinned?: readonly string[];
23
+ /** Upload session settings; shows the dropzone. false hides it. */
24
+ upload?: MediaUploadOptions | false;
25
+ /** Bytes, for uploads. */
26
+ maxSize?: number | null;
27
+ /**
28
+ * What can be done to a file, from its menu and (for several) the bar over
29
+ * the selection: 'preview', 'edit', 'remove', 'delete',
30
+ * `{ key: 'assign', roles }`, or the host's own `{ key, label, … }`.
31
+ */
32
+ actions?: readonly MediaBrowserAction[];
33
+ /** Grid rows for a fixed-height body; the list scrolls inside it. */
34
+ bodyClass?: string;
35
+ };
36
+ type __VLS_Slots = {
37
+ /** Next to the search box, e.g. "Add from library". */
38
+ 'toolbar'?: () => unknown;
39
+ /** Small labels on a card or row, e.g. "Used in 3 lessons". */
40
+ 'card-badge'?: (p: {
41
+ asset: AssetDetail;
42
+ }) => unknown;
43
+ /** Extra controls on a card or row, next to its menu. */
44
+ 'card-actions'?: (p: {
45
+ asset: AssetDetail;
46
+ }) => unknown;
47
+ /** Replaces the empty state. */
48
+ 'empty'?: (p: {
49
+ filtering: boolean;
50
+ }) => unknown;
51
+ /** Under the list, outside the scroll, e.g. the picker's Done / Cancel. */
52
+ 'footer'?: (p: {
53
+ selected: string[];
54
+ summary: string;
55
+ note: string | null;
56
+ }) => unknown;
57
+ };
58
+ type __VLS_ModelProps = {
59
+ /** Selected IDs, in the order they were chosen. Two-way and live. */
60
+ 'selected'?: string[];
61
+ };
62
+ type __VLS_PublicProps = __VLS_Props & __VLS_ModelProps;
63
+ declare const __VLS_base: import("vue").DefineComponent<__VLS_PublicProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
64
+ delete: (assetIds: string[]) => any;
65
+ remove: (assetIds: string[]) => any;
66
+ uploaded: (assetId: string, sessionId: string | null) => any;
67
+ action: (key: string, assetIds: string[], role?: string | undefined) => any;
68
+ edit: (assetId: string, result: MediaEditorResult | null) => any;
69
+ assign: (role: string, assetIds: string[]) => any;
70
+ "update:selected": (value: string[]) => any;
71
+ }, string, import("vue").PublicProps, Readonly<__VLS_PublicProps> & Readonly<{
72
+ onDelete?: ((assetIds: string[]) => any) | undefined;
73
+ onRemove?: ((assetIds: string[]) => any) | undefined;
74
+ onUploaded?: ((assetId: string, sessionId: string | null) => any) | undefined;
75
+ onAction?: ((key: string, assetIds: string[], role?: string | undefined) => any) | undefined;
76
+ onEdit?: ((assetId: string, result: MediaEditorResult | null) => any) | undefined;
77
+ onAssign?: ((role: string, assetIds: string[]) => any) | undefined;
78
+ "onUpdate:selected"?: ((value: string[]) => any) | undefined;
79
+ }>, {
80
+ upload: MediaUploadOptions | false;
81
+ actions: readonly MediaBrowserAction[];
82
+ backend: string | null;
83
+ accept: readonly string[] | string;
84
+ max: number;
85
+ maxSize: number | null;
86
+ pickFrom: readonly string[] | null;
87
+ selection: "none" | "single" | "multi";
88
+ pinned: readonly string[];
89
+ bodyClass: string;
90
+ }, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
91
+ declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
92
+ declare const _default: typeof __VLS_export;
93
+ export default _default;
94
+ type __VLS_WithSlots<T, S> = T & {
95
+ new (): {
96
+ $slots: S;
97
+ };
98
+ };
@@ -0,0 +1,521 @@
1
+ <script setup>
2
+ import { computed, ref, watch } from "vue";
3
+ import { useMLMediaAssets } from "../../composables/useMLMediaAssets";
4
+ import { useMLMediaBrowse } from "../../composables/useMLMediaBrowse";
5
+ import { useMLMediaUpload } from "../../composables/useMLMediaUpload";
6
+ import { useMLMediaEditor } from "../../composables/useMLMediaEditor";
7
+ import { assetKindMeta } from "../../utils/assetDisplay";
8
+ import { acceptForKinds, describeKinds } from "../../utils/fileAccept";
9
+ import { assetName, formatBytes } from "../../utils/assetFormat";
10
+ import { assignBlocked, confirmText, normalizeBrowserActions } from "../../utils/browserActions";
11
+ const ALL_KINDS = ["image", "video", "audio", "document", "model_3d", "archive", "font", "web_app", "image_batch", "other"];
12
+ const props = defineProps({
13
+ pickFrom: { type: [Array, null], required: false, default: null },
14
+ backend: { type: [String, null], required: false, default: void 0 },
15
+ accept: { type: [Array, String], required: false, default: "" },
16
+ selection: { type: String, required: false, default: "multi" },
17
+ max: { type: Number, required: false, default: Number.POSITIVE_INFINITY },
18
+ pinned: { type: Array, required: false, default: () => [] },
19
+ upload: { type: [Object, Boolean], required: false, default: false },
20
+ maxSize: { type: [Number, null], required: false, default: null },
21
+ actions: { type: Array, required: false, default: () => ["preview"] },
22
+ bodyClass: { type: String, required: false, default: "max-h-[min(60vh,560px)]" }
23
+ });
24
+ const selectedModel = defineModel("selected", { type: Array, ...{ default: () => [] } });
25
+ const selected = ref([...selectedModel.value]);
26
+ const sameIds = (a, b) => a.length === b.length && a.every((id, i) => id === b[i]);
27
+ watch(selectedModel, (v) => {
28
+ if (!sameIds(v, selected.value)) selected.value = [...v];
29
+ });
30
+ watch(selected, (v) => {
31
+ if (!sameIds(v, selectedModel.value)) selectedModel.value = [...v];
32
+ });
33
+ const emit = defineEmits(["uploaded", "action", "remove", "delete", "assign", "edit"]);
34
+ defineSlots();
35
+ const resolveBackend = computed(() => props.backend !== void 0 ? props.backend : props.upload ? props.upload.backend : null);
36
+ const acceptKinds = computed(() => {
37
+ const raw = typeof props.accept === "string" ? props.accept.split(",") : [...props.accept];
38
+ return raw.map((k) => k.trim().toLowerCase()).filter(Boolean);
39
+ });
40
+ const isAccepted = (kind) => !acceptKinds.value.length || acceptKinds.value.includes((kind ?? "").toLowerCase());
41
+ const notAcceptedReason = computed(() => `Only ${describeKinds(acceptKinds.value)} can be chosen here`);
42
+ const search = ref("");
43
+ const view = ref("grid");
44
+ const kindFilter = ref(acceptKinds.value.length === 1 ? [acceptKinds.value[0]] : []);
45
+ const kindOptions = computed(
46
+ () => (acceptKinds.value.length ? acceptKinds.value : ALL_KINDS).map((k) => ({ label: assetKindMeta(k).label, value: k }))
47
+ );
48
+ const filtering = computed(() => !!search.value.trim() || kindFilter.value.length > 0);
49
+ const browse = useMLMediaBrowse(
50
+ () => ({ kind: kindFilter.value[0] ?? null, query: search.value }),
51
+ props.pickFrom ? { ids: () => props.pickFrom ?? [], backend: resolveBackend } : {}
52
+ );
53
+ const selectable = computed(() => props.selection !== "none");
54
+ const single = computed(() => props.selection === "single");
55
+ const limitNote = ref(null);
56
+ function isChosen(id) {
57
+ return !!id && selected.value.includes(id);
58
+ }
59
+ function choose(id) {
60
+ limitNote.value = null;
61
+ if (single.value) {
62
+ selected.value = [id];
63
+ return true;
64
+ }
65
+ if (selected.value.length >= props.max) {
66
+ limitNote.value = `You can choose up to ${props.max}. Unselect one first.`;
67
+ return false;
68
+ }
69
+ selected.value = [...selected.value, id];
70
+ return true;
71
+ }
72
+ function toggle(id) {
73
+ if (!selectable.value) return;
74
+ if (isChosen(id)) {
75
+ limitNote.value = null;
76
+ selected.value = selected.value.filter((x) => x !== id);
77
+ } else {
78
+ choose(id);
79
+ }
80
+ }
81
+ function activate(id) {
82
+ if (selectable.value) toggle(id);
83
+ else if (hasAction("preview")) preview(id);
84
+ else if (hasAction("edit")) void edit(id);
85
+ }
86
+ const pinnedIds = computed(() => [...props.pinned]);
87
+ const pinnedLookup = useMLMediaAssets(pinnedIds, { backend: resolveBackend });
88
+ const pinnedAssets = computed(() => pinnedIds.value.map((id) => pinnedLookup.get(id)).filter((a) => !!a));
89
+ const uploader = props.upload ? useMLMediaUpload(props.upload) : null;
90
+ const rejectNote = ref(null);
91
+ function onFiles(files) {
92
+ if (!uploader) return;
93
+ rejectNote.value = null;
94
+ for (const file of files) {
95
+ const u = uploader.start(file);
96
+ void u.created.then((id) => {
97
+ if (!id) return;
98
+ u.attached = true;
99
+ emit("uploaded", id, u.sessionId);
100
+ if (selectable.value && !isChosen(id)) choose(id);
101
+ });
102
+ }
103
+ }
104
+ function onRejected(rejected) {
105
+ const first = rejected[0];
106
+ if (!first) return;
107
+ const more = rejected.length > 1 ? ` (and ${rejected.length - 1} more)` : "";
108
+ rejectNote.value = first.reason === "type" ? `${first.file.name}${more} can't be used here. Only ${describeKinds(acceptKinds.value)}.` : `${first.file.name}${more} is larger than ${props.maxSize ? formatBytes(props.maxSize) : "the limit"}.`;
109
+ }
110
+ const uploads = computed(() => uploader?.uploads.value ?? []);
111
+ const listedIds = computed(() => new Set([...browse.items.value, ...pinnedAssets.value].map((a) => a.id)));
112
+ const settled = (u) => !!u.assetId && (u.phase === "ready" || u.phase === "failed") && listedIds.value.has(u.assetId);
113
+ const visibleUploads = computed(() => uploads.value.filter((u) => u.phase !== "canceled" && !settled(u)));
114
+ const uploadIds = computed(() => new Set(visibleUploads.value.map((u) => u.assetId).filter(Boolean)));
115
+ function uploadPhase(u) {
116
+ return u.phase === "uploading" || u.phase === "processing" || u.phase === "failed" ? u.phase : null;
117
+ }
118
+ const listed = computed(() => {
119
+ const pinnedSet = new Set(filtering.value ? [] : pinnedAssets.value.map((a) => a.id));
120
+ const rest = browse.items.value.filter((a) => !pinnedSet.has(a.id) && !uploadIds.value.has(a.id));
121
+ return filtering.value ? rest : [...pinnedAssets.value, ...rest];
122
+ });
123
+ const initialLoading = computed(() => browse.loading.value && !browse.items.value.length);
124
+ const isEmpty = computed(() => !initialLoading.value && !listed.value.length && !visibleUploads.value.length);
125
+ watch(kindFilter, () => {
126
+ limitNote.value = null;
127
+ });
128
+ const blocked = (a) => selectable.value && (!isAccepted(a.kind) || a.status === "failed");
129
+ const blockedReason = (a) => a.status === "failed" ? "Processing failed for this file" : notAcceptedReason.value;
130
+ const actionList = computed(() => normalizeBrowserActions(props.actions));
131
+ const hasAction = (key) => actionList.value.some((a) => a.key === key);
132
+ const removeAction = computed(() => actionList.value.find((a) => a.key === "remove") ?? null);
133
+ const mediaEditor = useMLMediaEditor();
134
+ function preview(id) {
135
+ void mediaEditor.open(id, { readonly: true, siblings: listed.value.map((a) => a.id), backend: resolveBackend.value });
136
+ }
137
+ async function edit(id) {
138
+ const result = await mediaEditor.open(id, {
139
+ siblings: listed.value.map((a) => a.id),
140
+ removeLabel: removeAction.value?.label ?? "",
141
+ backend: resolveBackend.value
142
+ });
143
+ emit("edit", id, result);
144
+ if (result?.action === "remove" && removeAction.value) perform(removeAction.value, [result.assetId]);
145
+ }
146
+ const confirming = ref(null);
147
+ function run(action, ids, role) {
148
+ if (!ids.length) return;
149
+ if (action.confirm) confirming.value = { action, ids, role };
150
+ else perform(action, ids, role);
151
+ }
152
+ function perform(action, ids, role) {
153
+ emit("action", action.key, ids, role);
154
+ if (action.key === "preview") return preview(ids[0]);
155
+ if (action.key === "edit") return void edit(ids[0]);
156
+ if (action.key === "assign" && role) return emit("assign", role, ids);
157
+ if (action.key === "remove" || action.key === "delete") {
158
+ const gone = new Set(ids);
159
+ if (selected.value.some((id) => gone.has(id))) selected.value = selected.value.filter((id) => !gone.has(id));
160
+ if (action.key === "remove") emit("remove", ids);
161
+ else emit("delete", ids);
162
+ }
163
+ }
164
+ function runConfirmed() {
165
+ const c = confirming.value;
166
+ confirming.value = null;
167
+ if (c) perform(c.action, c.ids, c.role);
168
+ }
169
+ function roleItems(action, ids) {
170
+ return (action.roles ?? []).map((r) => {
171
+ const blockedBy = assignBlocked(r, ids.length);
172
+ return { label: blockedBy ? `${r.label} \xB7 up to ${r.max}` : r.label, disabled: !!blockedBy, onSelect: () => run(action, ids, r.key) };
173
+ });
174
+ }
175
+ function cardMenu(id, inGrid) {
176
+ const plain = [];
177
+ const danger = [];
178
+ for (const a of actionList.value) {
179
+ if (!a.single || a.key === "preview" && inGrid) continue;
180
+ if (a.roles) {
181
+ plain.push({ label: a.label, icon: a.icon, children: roleItems(a, [id]) });
182
+ continue;
183
+ }
184
+ const item = { label: a.label, icon: a.icon, onSelect: () => run(a, [id]) };
185
+ if (a.danger) danger.push({ ...item, color: "error" });
186
+ else plain.push(item);
187
+ }
188
+ return [plain, danger].filter((g) => g.length);
189
+ }
190
+ const hasMenu = (inGrid) => cardMenu("", inGrid).length > 0;
191
+ const bulkActions = computed(() => actionList.value.filter((a) => a.bulk));
192
+ const showBulk = computed(() => selectable.value && selected.value.length > 0 && bulkActions.value.length > 0);
193
+ const summary = computed(() => {
194
+ const n = selected.value.length;
195
+ if (single.value) return n ? "1 selected" : "Nothing selected";
196
+ return Number.isFinite(props.max) ? `${n} of ${props.max} selected` : `${n} selected`;
197
+ });
198
+ </script>
199
+
200
+ <template>
201
+ <div class="flex min-h-0 flex-col">
202
+ <div class="flex flex-col gap-3 border-b border-default p-4">
203
+ <div class="flex flex-wrap items-center gap-2">
204
+ <UInput
205
+ v-model="search"
206
+ icon="i-lucide-search"
207
+ placeholder="Search files"
208
+ class="min-w-0 flex-1 basis-56"
209
+ :loading="browse.loading.value && !!search"
210
+ aria-label="Search files"
211
+ />
212
+ <UFieldGroup size="md">
213
+ <UButton
214
+ icon="i-lucide-layout-grid"
215
+ color="neutral"
216
+ :variant="view === 'grid' ? 'soft' : 'outline'"
217
+ aria-label="Grid view"
218
+ :aria-pressed="view === 'grid'"
219
+ @click="view = 'grid'"
220
+ />
221
+ <UButton
222
+ icon="i-lucide-list"
223
+ color="neutral"
224
+ :variant="view === 'list' ? 'soft' : 'outline'"
225
+ aria-label="List view"
226
+ :aria-pressed="view === 'list'"
227
+ @click="view = 'list'"
228
+ />
229
+ </UFieldGroup>
230
+ <slot name="toolbar" />
231
+ </div>
232
+ <div
233
+ v-if="kindOptions.length > 1"
234
+ class="flex flex-wrap gap-1.5"
235
+ >
236
+ <MLMediaFilterPill
237
+ v-model="kindFilter"
238
+ label="File type"
239
+ :options="kindOptions"
240
+ single
241
+ />
242
+ </div>
243
+ <div
244
+ v-if="showBulk"
245
+ class="flex flex-wrap items-center gap-1.5 rounded-md bg-elevated px-2 py-1.5 text-sm"
246
+ role="toolbar"
247
+ aria-label="Actions on the selected files"
248
+ >
249
+ <span class="me-1 tabular-nums text-muted">{{ selected.length }} selected</span>
250
+ <template
251
+ v-for="a in bulkActions"
252
+ :key="a.key"
253
+ >
254
+ <UDropdownMenu
255
+ v-if="a.roles"
256
+ :items="roleItems(a, [...selected])"
257
+ >
258
+ <UButton
259
+ size="xs"
260
+ color="neutral"
261
+ variant="outline"
262
+ :icon="a.icon"
263
+ :label="a.label"
264
+ trailing-icon="i-lucide-chevron-down"
265
+ />
266
+ </UDropdownMenu>
267
+ <UButton
268
+ v-else
269
+ size="xs"
270
+ :color="a.danger ? 'error' : 'neutral'"
271
+ variant="outline"
272
+ :icon="a.icon"
273
+ :label="a.label"
274
+ @click="run(a, [...selected])"
275
+ />
276
+ </template>
277
+ <UButton
278
+ size="xs"
279
+ color="neutral"
280
+ variant="ghost"
281
+ label="Clear"
282
+ class="ms-auto"
283
+ @click="selected = []"
284
+ />
285
+ </div>
286
+ </div>
287
+
288
+ <div
289
+ class="flex min-h-0 flex-col gap-3 overflow-y-auto p-4"
290
+ :class="bodyClass"
291
+ >
292
+ <template v-if="upload">
293
+ <MLMediaDropzone
294
+ :accept="acceptForKinds(acceptKinds)"
295
+ :multiple="!single"
296
+ :max-size="maxSize"
297
+ compact
298
+ :title="single ? 'Drop a file to add it to the library' : 'Drop files to add them to the library'"
299
+ button-label="Upload from computer"
300
+ @files="onFiles"
301
+ @rejected="onRejected"
302
+ />
303
+ <p
304
+ v-if="rejectNote"
305
+ class="text-xs text-error"
306
+ >
307
+ {{ rejectNote }}
308
+ </p>
309
+ </template>
310
+
311
+ <UAlert
312
+ v-if="browse.error.value"
313
+ color="error"
314
+ variant="subtle"
315
+ icon="i-lucide-circle-alert"
316
+ :title="browse.error.value"
317
+ :actions="[{ label: 'Try again', color: 'neutral', variant: 'outline', onClick: () => browse.reload() }]"
318
+ />
319
+
320
+ <!-- Loading -->
321
+ <div
322
+ v-if="initialLoading"
323
+ class="grid grid-cols-[repeat(auto-fill,minmax(8rem,1fr))] gap-1.5"
324
+ >
325
+ <div
326
+ v-for="i in 12"
327
+ :key="i"
328
+ class="flex flex-col items-center gap-2 p-2.5"
329
+ >
330
+ <USkeleton class="aspect-square w-full max-w-28 rounded-md" />
331
+ <USkeleton class="h-3 w-3/4" />
332
+ </div>
333
+ </div>
334
+
335
+ <!-- Empty -->
336
+ <template v-else-if="isEmpty && !browse.error.value">
337
+ <slot
338
+ name="empty"
339
+ :filtering="filtering"
340
+ >
341
+ <UEmpty
342
+ variant="naked"
343
+ :icon="filtering ? 'i-lucide-search-x' : 'i-lucide-folder-open'"
344
+ :title="filtering ? 'No files match' : 'No files yet'"
345
+ :description="filtering ? 'Try another search or clear the file type.' : upload ? 'Drop files above to add the first one.' : void 0"
346
+ />
347
+ </slot>
348
+ </template>
349
+
350
+ <!-- Grid -->
351
+ <div
352
+ v-else-if="view === 'grid'"
353
+ class="grid grid-cols-[repeat(auto-fill,minmax(8rem,1fr))] gap-1.5"
354
+ >
355
+ <MLMediaPickerCard
356
+ v-for="u in visibleUploads"
357
+ :key="u.key"
358
+ :src="u.localPreview"
359
+ :kind="u.kind"
360
+ :name="u.file.name"
361
+ :meta="u.phase === 'failed' ? u.error ?? 'Failed' : void 0"
362
+ :selectable="selectable"
363
+ :selected="isChosen(u.assetId)"
364
+ :disabled="!u.assetId || selectable && !isAccepted(u.kind)"
365
+ @toggle="u.assetId && activate(u.assetId)"
366
+ >
367
+ <MLMediaProgressVeil
368
+ v-if="uploadPhase(u)"
369
+ :phase="uploadPhase(u)"
370
+ :progress="u.progress"
371
+ compact
372
+ />
373
+ </MLMediaPickerCard>
374
+ <MLMediaPickerCard
375
+ v-for="a in listed"
376
+ :key="a.id"
377
+ :asset="a"
378
+ :selectable="selectable"
379
+ :selected="isChosen(a.id)"
380
+ :disabled="blocked(a)"
381
+ :disabled-reason="blockedReason(a)"
382
+ :on-preview="hasAction('preview') ? () => preview(a.id) : void 0"
383
+ @toggle="activate(a.id)"
384
+ >
385
+ <template
386
+ v-if="$slots['card-badge']"
387
+ #badge
388
+ >
389
+ <slot
390
+ name="card-badge"
391
+ :asset="a"
392
+ />
393
+ </template>
394
+ <template
395
+ v-if="hasMenu(true) || $slots['card-actions']"
396
+ #actions
397
+ >
398
+ <slot
399
+ name="card-actions"
400
+ :asset="a"
401
+ />
402
+ <UDropdownMenu
403
+ v-if="hasMenu(true)"
404
+ :items="cardMenu(a.id, true)"
405
+ >
406
+ <UButton
407
+ size="xs"
408
+ color="neutral"
409
+ variant="soft"
410
+ icon="i-lucide-ellipsis-vertical"
411
+ :aria-label="`Actions for ${assetName(a)}`"
412
+ />
413
+ </UDropdownMenu>
414
+ </template>
415
+ </MLMediaPickerCard>
416
+ </div>
417
+
418
+ <!-- List -->
419
+ <div
420
+ v-else
421
+ class="flex flex-col divide-y divide-default overflow-hidden rounded-lg border border-default"
422
+ >
423
+ <MLMediaPickerRow
424
+ v-for="u in visibleUploads"
425
+ :key="u.key"
426
+ :src="u.localPreview"
427
+ :kind="u.kind"
428
+ :name="u.file.name"
429
+ :size="u.phase === 'uploading' ? `${Math.round(u.progress ?? 0)}%` : formatBytes(u.file.size)"
430
+ :date="u.phase === 'ready' ? 'Just now' : u.phase === 'failed' ? 'Failed' : 'Uploading'"
431
+ :selectable="selectable"
432
+ :selected="isChosen(u.assetId)"
433
+ :disabled="!u.assetId || selectable && !isAccepted(u.kind)"
434
+ @toggle="u.assetId && activate(u.assetId)"
435
+ />
436
+ <MLMediaPickerRow
437
+ v-for="a in listed"
438
+ :key="a.id"
439
+ :asset="a"
440
+ :selectable="selectable"
441
+ :selected="isChosen(a.id)"
442
+ :disabled="blocked(a)"
443
+ :disabled-reason="blockedReason(a)"
444
+ @toggle="activate(a.id)"
445
+ >
446
+ <template
447
+ v-if="$slots['card-badge']"
448
+ #badge
449
+ >
450
+ <slot
451
+ name="card-badge"
452
+ :asset="a"
453
+ />
454
+ </template>
455
+ <template
456
+ v-if="hasMenu(false) || $slots['card-actions']"
457
+ #actions
458
+ >
459
+ <slot
460
+ name="card-actions"
461
+ :asset="a"
462
+ />
463
+ <UDropdownMenu
464
+ v-if="hasMenu(false)"
465
+ :items="cardMenu(a.id, false)"
466
+ >
467
+ <UButton
468
+ size="xs"
469
+ color="neutral"
470
+ variant="ghost"
471
+ icon="i-lucide-ellipsis-vertical"
472
+ :aria-label="`Actions for ${assetName(a)}`"
473
+ />
474
+ </UDropdownMenu>
475
+ </template>
476
+ </MLMediaPickerRow>
477
+ </div>
478
+
479
+ <UButton
480
+ v-if="browse.hasMore.value && !initialLoading"
481
+ class="self-center"
482
+ color="neutral"
483
+ variant="outline"
484
+ size="sm"
485
+ label="Load more"
486
+ :loading="browse.loading.value"
487
+ @click="browse.loadMore()"
488
+ />
489
+ </div>
490
+
491
+ <slot
492
+ name="footer"
493
+ :selected="selected"
494
+ :summary="limitNote ?? summary"
495
+ :note="limitNote"
496
+ />
497
+
498
+ <UModal
499
+ :open="!!confirming"
500
+ :title="confirming ? `${confirming.action.label}?` : void 0"
501
+ :description="confirming ? confirmText(confirming.action, confirming.ids.length) : void 0"
502
+ @update:open="(open) => !open && (confirming = null)"
503
+ >
504
+ <template #footer>
505
+ <div class="flex w-full justify-end gap-2">
506
+ <UButton
507
+ color="neutral"
508
+ variant="outline"
509
+ label="Cancel"
510
+ @click="confirming = null"
511
+ />
512
+ <UButton
513
+ :color="confirming?.action.danger ? 'error' : 'primary'"
514
+ :label="confirming?.action.label"
515
+ @click="runConfirmed"
516
+ />
517
+ </div>
518
+ </template>
519
+ </UModal>
520
+ </div>
521
+ </template>