nuxt-media-library 0.0.63 → 0.0.65
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/runtime/components/MLMedia/Browser.d.vue.ts +98 -0
- package/dist/runtime/components/MLMedia/Browser.vue +521 -0
- package/dist/runtime/components/MLMedia/Browser.vue.d.ts +98 -0
- package/dist/runtime/components/MLMedia/Field.d.vue.ts +7 -1
- package/dist/runtime/components/MLMedia/Field.vue +2 -0
- package/dist/runtime/components/MLMedia/Field.vue.d.ts +7 -1
- package/dist/runtime/components/MLMedia/FilePicker.d.vue.ts +2 -0
- package/dist/runtime/components/MLMedia/FilePicker.vue +2 -0
- package/dist/runtime/components/MLMedia/FilePicker.vue.d.ts +2 -0
- package/dist/runtime/components/MLMedia/FilePickerPanel.d.vue.ts +7 -1
- package/dist/runtime/components/MLMedia/FilePickerPanel.vue +34 -284
- package/dist/runtime/components/MLMedia/FilePickerPanel.vue.d.ts +7 -1
- package/dist/runtime/components/MLMedia/Gallery.d.vue.ts +18 -1
- package/dist/runtime/components/MLMedia/Gallery.vue +56 -7
- package/dist/runtime/components/MLMedia/Gallery.vue.d.ts +18 -1
- package/dist/runtime/components/MLMedia/GallerySection.d.vue.ts +6 -1
- package/dist/runtime/components/MLMedia/GallerySection.vue +8 -3
- package/dist/runtime/components/MLMedia/GallerySection.vue.d.ts +6 -1
- package/dist/runtime/components/MLMedia/MediaTile.d.vue.ts +1 -1
- package/dist/runtime/components/MLMedia/MediaTile.vue.d.ts +1 -1
- package/dist/runtime/components/MLMedia/PickerCard.d.vue.ts +7 -0
- package/dist/runtime/components/MLMedia/PickerCard.vue +17 -1
- package/dist/runtime/components/MLMedia/PickerCard.vue.d.ts +7 -0
- package/dist/runtime/components/MLMedia/PickerRow.d.vue.ts +16 -1
- package/dist/runtime/components/MLMedia/PickerRow.vue +47 -30
- package/dist/runtime/components/MLMedia/PickerRow.vue.d.ts +16 -1
- package/dist/runtime/components/MLProcessing/StepLayout.d.vue.ts +1 -1
- package/dist/runtime/components/MLProcessing/StepLayout.vue.d.ts +1 -1
- package/dist/runtime/composables/useMLFilePicker.d.ts +6 -0
- package/dist/runtime/composables/useMLMediaBrowse.d.ts +25 -5
- package/dist/runtime/composables/useMLMediaBrowse.js +26 -0
- package/dist/runtime/utils/browserActions.d.ts +47 -0
- package/dist/runtime/utils/browserActions.js +36 -0
- package/dist/runtime/utils/galleryRoles.d.ts +7 -0
- package/dist/runtime/utils/galleryRoles.js +11 -0
- package/package.json +2 -1
package/dist/module.json
CHANGED
|
@@ -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>
|