nuxt-media-library 0.0.44 → 0.0.49
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/README.md +71 -0
- package/dist/module.json +1 -1
- package/dist/module.mjs +4 -1
- package/dist/runtime/components/MLAssetManager/Processing.vue +3 -1
- package/dist/runtime/components/MLMedia/Dropzone.d.vue.ts +60 -0
- package/dist/runtime/components/MLMedia/Dropzone.vue +137 -0
- package/dist/runtime/components/MLMedia/Dropzone.vue.d.ts +60 -0
- package/dist/runtime/components/MLMedia/Editor.d.vue.ts +23 -0
- package/dist/runtime/components/MLMedia/Editor.vue +34 -0
- package/dist/runtime/components/MLMedia/Editor.vue.d.ts +23 -0
- package/dist/runtime/components/MLMedia/EditorPanel.d.vue.ts +38 -0
- package/dist/runtime/components/MLMedia/EditorPanel.vue +220 -0
- package/dist/runtime/components/MLMedia/EditorPanel.vue.d.ts +38 -0
- package/dist/runtime/components/MLMedia/Field.d.vue.ts +65 -0
- package/dist/runtime/components/MLMedia/Field.vue +282 -0
- package/dist/runtime/components/MLMedia/Field.vue.d.ts +65 -0
- package/dist/runtime/components/MLMedia/FileFacts.d.vue.ts +19 -0
- package/dist/runtime/components/MLMedia/FileFacts.vue +38 -0
- package/dist/runtime/components/MLMedia/FileFacts.vue.d.ts +19 -0
- package/dist/runtime/components/MLMedia/FilePicker.d.vue.ts +23 -0
- package/dist/runtime/components/MLMedia/FilePicker.vue +33 -0
- package/dist/runtime/components/MLMedia/FilePicker.vue.d.ts +23 -0
- package/dist/runtime/components/MLMedia/FilePickerPanel.d.vue.ts +33 -0
- package/dist/runtime/components/MLMedia/FilePickerPanel.vue +300 -0
- package/dist/runtime/components/MLMedia/FilePickerPanel.vue.d.ts +33 -0
- package/dist/runtime/components/MLMedia/FilterPill.d.vue.ts +30 -0
- package/dist/runtime/components/MLMedia/FilterPill.vue +84 -0
- package/dist/runtime/components/MLMedia/FilterPill.vue.d.ts +30 -0
- package/dist/runtime/components/MLMedia/Gallery.d.vue.ts +78 -0
- package/dist/runtime/components/MLMedia/Gallery.vue +256 -0
- package/dist/runtime/components/MLMedia/Gallery.vue.d.ts +78 -0
- package/dist/runtime/components/MLMedia/GallerySection.d.vue.ts +102 -0
- package/dist/runtime/components/MLMedia/GallerySection.vue +422 -0
- package/dist/runtime/components/MLMedia/GallerySection.vue.d.ts +102 -0
- package/dist/runtime/components/MLMedia/KindBadge.d.vue.ts +17 -0
- package/dist/runtime/components/MLMedia/KindBadge.vue +22 -0
- package/dist/runtime/components/MLMedia/KindBadge.vue.d.ts +17 -0
- package/dist/runtime/components/MLMedia/MediaTile.d.vue.ts +63 -0
- package/dist/runtime/components/MLMedia/MediaTile.vue +117 -0
- package/dist/runtime/components/MLMedia/MediaTile.vue.d.ts +63 -0
- package/dist/runtime/components/MLMedia/PickerCard.d.vue.ts +42 -0
- package/dist/runtime/components/MLMedia/PickerCard.vue +79 -0
- package/dist/runtime/components/MLMedia/PickerCard.vue.d.ts +42 -0
- package/dist/runtime/components/MLMedia/PickerRow.d.vue.ts +31 -0
- package/dist/runtime/components/MLMedia/PickerRow.vue +65 -0
- package/dist/runtime/components/MLMedia/PickerRow.vue.d.ts +31 -0
- package/dist/runtime/components/MLMedia/PreviewEye.d.vue.ts +16 -0
- package/dist/runtime/components/MLMedia/PreviewEye.vue +21 -0
- package/dist/runtime/components/MLMedia/PreviewEye.vue.d.ts +16 -0
- package/dist/runtime/components/MLMedia/ProgressVeil.d.vue.ts +25 -0
- package/dist/runtime/components/MLMedia/ProgressVeil.vue +98 -0
- package/dist/runtime/components/MLMedia/ProgressVeil.vue.d.ts +25 -0
- package/dist/runtime/components/MLMedia/StatusBadge.d.vue.ts +17 -0
- package/dist/runtime/components/MLMedia/StatusBadge.vue +31 -0
- package/dist/runtime/components/MLMedia/StatusBadge.vue.d.ts +17 -0
- package/dist/runtime/components/MLMedia/Thumbnail.d.vue.ts +36 -0
- package/dist/runtime/components/MLMedia/Thumbnail.vue +84 -0
- package/dist/runtime/components/MLMedia/Thumbnail.vue.d.ts +36 -0
- package/dist/runtime/components/MLMedia/UsageList.d.vue.ts +26 -0
- package/dist/runtime/components/MLMedia/UsageList.vue +66 -0
- package/dist/runtime/components/MLMedia/UsageList.vue.d.ts +26 -0
- 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/components/MLUploader/Item.d.vue.ts +2 -2
- package/dist/runtime/components/MLUploader/Item.vue.d.ts +2 -2
- package/dist/runtime/composables/useMLAssetPreview.js +17 -1
- package/dist/runtime/composables/useMLFilePicker.d.ts +20 -0
- package/dist/runtime/composables/useMLFilePicker.js +17 -0
- package/dist/runtime/composables/useMLMediaAssets.d.ts +19 -0
- package/dist/runtime/composables/useMLMediaAssets.js +56 -0
- package/dist/runtime/composables/useMLMediaBrowse.d.ts +147 -0
- package/dist/runtime/composables/useMLMediaBrowse.js +72 -0
- package/dist/runtime/composables/useMLMediaEditor.d.ts +22 -0
- package/dist/runtime/composables/useMLMediaEditor.js +20 -0
- package/dist/runtime/composables/useMLMediaField.d.ts +29 -0
- package/dist/runtime/composables/useMLMediaField.js +77 -0
- package/dist/runtime/composables/useMLMediaGallery.d.ts +55 -0
- package/dist/runtime/composables/useMLMediaGallery.js +134 -0
- package/dist/runtime/composables/useMLMediaGrants.d.ts +23 -0
- package/dist/runtime/composables/useMLMediaGrants.js +22 -0
- package/dist/runtime/composables/useMLMediaUpload.d.ts +134 -0
- package/dist/runtime/composables/useMLMediaUpload.js +153 -0
- package/dist/runtime/composables/useMLServiceUpload.d.ts +36 -0
- package/dist/runtime/composables/useMLServiceUpload.js +32 -0
- package/dist/runtime/index.d.ts +11 -0
- package/dist/runtime/index.js +11 -0
- package/dist/runtime/server/create-ml-proxy.d.ts +16 -0
- package/dist/runtime/server/create-ml-proxy.js +138 -40
- package/dist/runtime/server/grants.d.ts +124 -0
- package/dist/runtime/server/grants.js +161 -0
- package/dist/runtime/server/index.d.ts +2 -0
- package/dist/runtime/server/index.js +7 -0
- package/dist/runtime/server/uploads-proxy.js +5 -2
- package/dist/runtime/types/asset.d.ts +3 -1
- package/dist/runtime/utils/assetDisplay.d.ts +8 -0
- package/dist/runtime/utils/assetDisplay.js +29 -0
- package/dist/runtime/utils/assetFormat.d.ts +30 -0
- package/dist/runtime/utils/assetFormat.js +69 -0
- package/dist/runtime/utils/assetThumbnail.d.ts +11 -0
- package/dist/runtime/utils/assetThumbnail.js +14 -0
- package/dist/runtime/utils/fileAccept.d.ts +26 -0
- package/dist/runtime/utils/fileAccept.js +104 -0
- package/dist/runtime/utils/galleryDrag.d.ts +2 -0
- package/dist/runtime/utils/galleryDrag.js +1 -0
- package/dist/runtime/utils/grants/listeners.d.ts +2 -0
- package/dist/runtime/utils/grants/listeners.js +8 -0
- package/dist/runtime/utils/grants/store.d.ts +26 -0
- package/dist/runtime/utils/grants/store.js +37 -0
- package/dist/runtime/utils/uploader/serviceUpload.d.ts +84 -0
- package/dist/runtime/utils/uploader/serviceUpload.js +109 -0
- package/package.json +5 -2
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed, ref, watch } from "vue";
|
|
3
|
+
import { useMLMediaAssets } from "../../composables/useMLMediaAssets";
|
|
4
|
+
import { assetName } from "../../utils/assetFormat";
|
|
5
|
+
const props = defineProps({
|
|
6
|
+
assetId: { type: String, required: true },
|
|
7
|
+
siblings: { type: Array, required: false, default: () => [] },
|
|
8
|
+
alt: { type: Object, required: false, default: () => ({}) },
|
|
9
|
+
readonly: { type: Boolean, required: false, default: false },
|
|
10
|
+
removeLabel: { type: String, required: false, default: "" },
|
|
11
|
+
usages: { type: Object, required: false, default: void 0 }
|
|
12
|
+
});
|
|
13
|
+
const emit = defineEmits(["save", "remove", "cancel"]);
|
|
14
|
+
const list = computed(() => {
|
|
15
|
+
const s = props.siblings.filter(Boolean);
|
|
16
|
+
return s.includes(props.assetId) ? s : [props.assetId, ...s];
|
|
17
|
+
});
|
|
18
|
+
const index = ref(Math.max(0, list.value.indexOf(props.assetId)));
|
|
19
|
+
const currentId = computed(() => list.value[index.value] ?? props.assetId);
|
|
20
|
+
const library = useMLMediaAssets(list);
|
|
21
|
+
const asset = computed(() => library.get(currentId.value));
|
|
22
|
+
const lookup = computed(() => library.lookup(currentId.value));
|
|
23
|
+
const name = computed(() => asset.value ? assetName(asset.value) : "");
|
|
24
|
+
const kind = computed(() => (asset.value?.kind ?? "").toLowerCase());
|
|
25
|
+
const takesAlt = computed(() => kind.value === "image" || kind.value === "video");
|
|
26
|
+
const alts = ref({ ...props.alt });
|
|
27
|
+
const dirty = computed(() => list.value.some((id) => (alts.value[id] ?? "") !== (props.alt[id] ?? "")));
|
|
28
|
+
const altModel = computed({
|
|
29
|
+
get: () => alts.value[currentId.value] ?? "",
|
|
30
|
+
set: (v) => {
|
|
31
|
+
alts.value = { ...alts.value, [currentId.value]: v };
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
const canPrev = computed(() => index.value > 0);
|
|
35
|
+
const canNext = computed(() => index.value < list.value.length - 1);
|
|
36
|
+
const go = (step) => {
|
|
37
|
+
index.value = Math.min(list.value.length - 1, Math.max(0, index.value + step));
|
|
38
|
+
};
|
|
39
|
+
function onKey(e) {
|
|
40
|
+
const t = e.target;
|
|
41
|
+
if (t && (t.tagName === "TEXTAREA" || t.tagName === "INPUT" || t.isContentEditable)) return;
|
|
42
|
+
if (e.key === "ArrowLeft" && canPrev.value) go(-1);
|
|
43
|
+
else if (e.key === "ArrowRight" && canNext.value) go(1);
|
|
44
|
+
}
|
|
45
|
+
const confirmRemove = ref(false);
|
|
46
|
+
watch(currentId, () => {
|
|
47
|
+
confirmRemove.value = false;
|
|
48
|
+
});
|
|
49
|
+
const usagesHere = computed(() => props.usages?.[currentId.value]);
|
|
50
|
+
const originalUrl = computed(() => asset.value?.previewUrl ?? null);
|
|
51
|
+
defineExpose({ currentId });
|
|
52
|
+
</script>
|
|
53
|
+
|
|
54
|
+
<template>
|
|
55
|
+
<div
|
|
56
|
+
class="flex min-h-0 flex-col"
|
|
57
|
+
@keydown="onKey"
|
|
58
|
+
>
|
|
59
|
+
<div class="grid min-h-0 md:grid-cols-[minmax(0,1fr)_18rem]">
|
|
60
|
+
<!-- Stage -->
|
|
61
|
+
<div class="relative grid min-h-72 place-items-center bg-neutral-950 px-12 py-10 md:min-h-[26rem]">
|
|
62
|
+
<USkeleton
|
|
63
|
+
v-if="lookup === 'loading'"
|
|
64
|
+
class="aspect-[4/3] w-full max-w-lg rounded-md bg-white/10"
|
|
65
|
+
/>
|
|
66
|
+
<div
|
|
67
|
+
v-else-if="!asset"
|
|
68
|
+
class="flex flex-col items-center gap-2 text-center text-sm text-neutral-300"
|
|
69
|
+
>
|
|
70
|
+
<UIcon
|
|
71
|
+
name="i-lucide-file-x"
|
|
72
|
+
class="size-8 text-neutral-400"
|
|
73
|
+
/>
|
|
74
|
+
{{ lookup === "forbidden" ? "You don't have access to this file." : "This file could not be found." }}
|
|
75
|
+
</div>
|
|
76
|
+
<div
|
|
77
|
+
v-else
|
|
78
|
+
:key="currentId"
|
|
79
|
+
class="h-[min(52vh,26rem)] w-full max-w-2xl"
|
|
80
|
+
>
|
|
81
|
+
<MLAssetManagerAssetPreview :asset="asset" />
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
<UButton
|
|
85
|
+
v-if="list.length > 1"
|
|
86
|
+
icon="i-lucide-chevron-left"
|
|
87
|
+
color="neutral"
|
|
88
|
+
variant="soft"
|
|
89
|
+
class="absolute left-2 top-1/2 -translate-y-1/2 rounded-full"
|
|
90
|
+
aria-label="Previous file"
|
|
91
|
+
:disabled="!canPrev"
|
|
92
|
+
@click="go(-1)"
|
|
93
|
+
/>
|
|
94
|
+
<UButton
|
|
95
|
+
v-if="list.length > 1"
|
|
96
|
+
icon="i-lucide-chevron-right"
|
|
97
|
+
color="neutral"
|
|
98
|
+
variant="soft"
|
|
99
|
+
class="absolute right-2 top-1/2 -translate-y-1/2 rounded-full"
|
|
100
|
+
aria-label="Next file"
|
|
101
|
+
:disabled="!canNext"
|
|
102
|
+
@click="go(1)"
|
|
103
|
+
/>
|
|
104
|
+
<span
|
|
105
|
+
v-if="list.length > 1"
|
|
106
|
+
class="absolute bottom-2.5 left-1/2 -translate-x-1/2 text-xs text-neutral-300 tabular-nums"
|
|
107
|
+
aria-live="polite"
|
|
108
|
+
>{{ index + 1 }} of {{ list.length }}</span>
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
<!-- Side panel -->
|
|
112
|
+
<div class="flex min-w-0 flex-col gap-5 border-t border-default p-4 md:border-l md:border-t-0">
|
|
113
|
+
<div class="flex min-w-0 items-start gap-2">
|
|
114
|
+
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
|
115
|
+
<span class="truncate text-sm font-semibold">{{ name || "\u2014" }}</span>
|
|
116
|
+
<div
|
|
117
|
+
v-if="asset"
|
|
118
|
+
class="flex flex-wrap gap-1"
|
|
119
|
+
>
|
|
120
|
+
<MLMediaKindBadge :kind="asset.kind" />
|
|
121
|
+
<MLMediaStatusBadge
|
|
122
|
+
v-if="asset.status !== 'ready'"
|
|
123
|
+
:status="asset.status"
|
|
124
|
+
/>
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
<UButton
|
|
128
|
+
v-if="originalUrl"
|
|
129
|
+
:to="originalUrl"
|
|
130
|
+
target="_blank"
|
|
131
|
+
rel="noopener"
|
|
132
|
+
icon="i-lucide-external-link"
|
|
133
|
+
color="neutral"
|
|
134
|
+
variant="ghost"
|
|
135
|
+
size="sm"
|
|
136
|
+
aria-label="Open the original in a new tab"
|
|
137
|
+
/>
|
|
138
|
+
</div>
|
|
139
|
+
|
|
140
|
+
<UFormField
|
|
141
|
+
v-if="takesAlt && !readonly"
|
|
142
|
+
label="Alt text"
|
|
143
|
+
:help="kind === 'image' ? 'Describes the image for screen readers and search.' : 'Describes the video for screen readers.'"
|
|
144
|
+
>
|
|
145
|
+
<UTextarea
|
|
146
|
+
v-model="altModel"
|
|
147
|
+
:rows="3"
|
|
148
|
+
autoresize
|
|
149
|
+
class="w-full"
|
|
150
|
+
:placeholder="kind === 'image' ? 'e.g. Oak lounge chair, front view on a light background' : ''"
|
|
151
|
+
/>
|
|
152
|
+
</UFormField>
|
|
153
|
+
<p
|
|
154
|
+
v-else-if="takesAlt && altModel"
|
|
155
|
+
class="text-sm"
|
|
156
|
+
>
|
|
157
|
+
<span class="block text-[11px] font-semibold uppercase tracking-wider text-dimmed">Alt text</span>
|
|
158
|
+
{{ altModel }}
|
|
159
|
+
</p>
|
|
160
|
+
|
|
161
|
+
<MLMediaFileFacts :asset="asset" />
|
|
162
|
+
|
|
163
|
+
<MLMediaUsageList
|
|
164
|
+
v-if="usagesHere"
|
|
165
|
+
:usages="usagesHere"
|
|
166
|
+
/>
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
|
|
170
|
+
<div class="flex flex-wrap items-center gap-2 border-t border-default px-4 py-3">
|
|
171
|
+
<template v-if="removeLabel && !readonly">
|
|
172
|
+
<UButton
|
|
173
|
+
v-if="!confirmRemove"
|
|
174
|
+
color="error"
|
|
175
|
+
variant="ghost"
|
|
176
|
+
icon="i-lucide-x"
|
|
177
|
+
:label="removeLabel"
|
|
178
|
+
@click="confirmRemove = true"
|
|
179
|
+
/>
|
|
180
|
+
<template v-else>
|
|
181
|
+
<span class="text-sm text-muted">The file stays in the library.</span>
|
|
182
|
+
<UButton
|
|
183
|
+
color="error"
|
|
184
|
+
variant="soft"
|
|
185
|
+
:label="removeLabel"
|
|
186
|
+
@click="emit('remove', currentId)"
|
|
187
|
+
/>
|
|
188
|
+
<UButton
|
|
189
|
+
color="neutral"
|
|
190
|
+
variant="ghost"
|
|
191
|
+
label="Keep"
|
|
192
|
+
@click="confirmRemove = false"
|
|
193
|
+
/>
|
|
194
|
+
</template>
|
|
195
|
+
</template>
|
|
196
|
+
<span class="me-auto" />
|
|
197
|
+
<template v-if="readonly">
|
|
198
|
+
<UButton
|
|
199
|
+
color="neutral"
|
|
200
|
+
variant="outline"
|
|
201
|
+
label="Close"
|
|
202
|
+
@click="emit('cancel')"
|
|
203
|
+
/>
|
|
204
|
+
</template>
|
|
205
|
+
<template v-else>
|
|
206
|
+
<UButton
|
|
207
|
+
color="neutral"
|
|
208
|
+
variant="outline"
|
|
209
|
+
label="Cancel"
|
|
210
|
+
@click="emit('cancel')"
|
|
211
|
+
/>
|
|
212
|
+
<UButton
|
|
213
|
+
label="Save"
|
|
214
|
+
:disabled="!dirty"
|
|
215
|
+
@click="emit('save', { alt: { ...alts } })"
|
|
216
|
+
/>
|
|
217
|
+
</template>
|
|
218
|
+
</div>
|
|
219
|
+
</div>
|
|
220
|
+
</template>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { MediaUsage } from './UsageList.vue.js';
|
|
2
|
+
type __VLS_Props = {
|
|
3
|
+
/** The file to show first. */
|
|
4
|
+
assetId: string;
|
|
5
|
+
/** The record's other files, in order. Enables previous / next. */
|
|
6
|
+
siblings?: readonly string[];
|
|
7
|
+
/** Current alt text per asset ID. Alt text lives on the host record, not the file. */
|
|
8
|
+
alt?: Readonly<Record<string, string>>;
|
|
9
|
+
/** Preview only: no alt editing, no Save. Used from the picker. */
|
|
10
|
+
readonly?: boolean;
|
|
11
|
+
/** Label for the remove button, e.g. "Remove from product". Empty hides it. */
|
|
12
|
+
removeLabel?: string;
|
|
13
|
+
/** Where each file is used, per asset ID. Omit to hide the section. */
|
|
14
|
+
usages?: Readonly<Record<string, MediaUsage[]>>;
|
|
15
|
+
};
|
|
16
|
+
declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {
|
|
17
|
+
currentId: import("vue").ComputedRef<string>;
|
|
18
|
+
}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
|
|
19
|
+
remove: (assetId: string) => any;
|
|
20
|
+
cancel: () => any;
|
|
21
|
+
save: (result: {
|
|
22
|
+
alt: Record<string, string>;
|
|
23
|
+
}) => any;
|
|
24
|
+
}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
|
|
25
|
+
onRemove?: ((assetId: string) => any) | undefined;
|
|
26
|
+
onCancel?: (() => any) | undefined;
|
|
27
|
+
onSave?: ((result: {
|
|
28
|
+
alt: Record<string, string>;
|
|
29
|
+
}) => any) | undefined;
|
|
30
|
+
}>, {
|
|
31
|
+
usages: Readonly<Record<string, MediaUsage[]>>;
|
|
32
|
+
siblings: readonly string[];
|
|
33
|
+
alt: Readonly<Record<string, string>>;
|
|
34
|
+
readonly: boolean;
|
|
35
|
+
removeLabel: string;
|
|
36
|
+
}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
37
|
+
declare const _default: typeof __VLS_export;
|
|
38
|
+
export default _default;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { MediaUploadOptions } from '../../composables/useMLMediaUpload.js';
|
|
2
|
+
type __VLS_Props = {
|
|
3
|
+
/** Asset kinds the field takes: ['image'], "image,video". Empty means any file. */
|
|
4
|
+
accept?: readonly string[] | string;
|
|
5
|
+
label?: string;
|
|
6
|
+
/** Under the field. Defaults to what the field accepts and its size limit. */
|
|
7
|
+
hint?: string;
|
|
8
|
+
required?: boolean;
|
|
9
|
+
disabled?: boolean;
|
|
10
|
+
/** Bytes. */
|
|
11
|
+
maxSize?: number | null;
|
|
12
|
+
/** Shows "No alt text" on a filled image field. Alt text lives on the host record. */
|
|
13
|
+
missingAlt?: boolean;
|
|
14
|
+
/** Upload session settings for new files. */
|
|
15
|
+
visibility?: MediaUploadOptions['visibility'];
|
|
16
|
+
processingConfig?: MediaUploadOptions['processingConfig'];
|
|
17
|
+
collectionName?: string | null;
|
|
18
|
+
/** Show "Select existing", which opens the library picker. */
|
|
19
|
+
picker?: boolean;
|
|
20
|
+
/** Replaces the built-in picker, e.g. with the host's own dialog. */
|
|
21
|
+
onPick?: () => void;
|
|
22
|
+
/** Show the eye button on the thumbnail, which opens the media editor. */
|
|
23
|
+
editor?: boolean;
|
|
24
|
+
/** Label for the editor's remove button. Empty hides it. */
|
|
25
|
+
removeLabel?: string;
|
|
26
|
+
/** Replaces the built-in editor. Receives the asset ID. */
|
|
27
|
+
onOpen?: (assetId: string) => void;
|
|
28
|
+
};
|
|
29
|
+
type __VLS_ModelProps = {
|
|
30
|
+
modelValue?: string | null;
|
|
31
|
+
/** Alt text for the file, stored on the host record. Edited in the media editor. */
|
|
32
|
+
'alt'?: string | undefined;
|
|
33
|
+
};
|
|
34
|
+
type __VLS_PublicProps = __VLS_Props & __VLS_ModelProps;
|
|
35
|
+
declare const __VLS_export: import("vue").DefineComponent<__VLS_PublicProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
|
|
36
|
+
uploaded: (assetId: string) => any;
|
|
37
|
+
"update:modelValue": (value: string | null) => any;
|
|
38
|
+
"update:alt": (value: string | undefined) => any;
|
|
39
|
+
}, string, import("vue").PublicProps, Readonly<__VLS_PublicProps> & Readonly<{
|
|
40
|
+
onUploaded?: ((assetId: string) => any) | undefined;
|
|
41
|
+
"onUpdate:modelValue"?: ((value: string | null) => any) | undefined;
|
|
42
|
+
"onUpdate:alt"?: ((value: string | undefined) => any) | undefined;
|
|
43
|
+
}>, {
|
|
44
|
+
visibility: "private" | "restricted" | "public";
|
|
45
|
+
label: string;
|
|
46
|
+
accept: readonly string[] | string;
|
|
47
|
+
maxSize: number | null;
|
|
48
|
+
processingConfig: {
|
|
49
|
+
stepOptions?: Record<string, Record<string, unknown>> | null;
|
|
50
|
+
useFilenameAsAssetId?: boolean;
|
|
51
|
+
enableTranscription?: boolean;
|
|
52
|
+
};
|
|
53
|
+
collectionName: string | null;
|
|
54
|
+
disabled: boolean;
|
|
55
|
+
required: boolean;
|
|
56
|
+
removeLabel: string;
|
|
57
|
+
hint: string;
|
|
58
|
+
missingAlt: boolean;
|
|
59
|
+
picker: boolean;
|
|
60
|
+
onPick: () => void;
|
|
61
|
+
editor: boolean;
|
|
62
|
+
onOpen: (assetId: string) => void;
|
|
63
|
+
}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
64
|
+
declare const _default: typeof __VLS_export;
|
|
65
|
+
export default _default;
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed } from "vue";
|
|
3
|
+
import { useMLMediaField } from "../../composables/useMLMediaField";
|
|
4
|
+
import { useMLFilePicker } from "../../composables/useMLFilePicker";
|
|
5
|
+
import { useMLMediaEditor } from "../../composables/useMLMediaEditor";
|
|
6
|
+
import { assetDimensions, assetDurationSeconds, assetName, assetSizeBytes, formatBytes, formatDuration } from "../../utils/assetFormat";
|
|
7
|
+
const props = defineProps({
|
|
8
|
+
accept: { type: [Array, String], required: false, default: "" },
|
|
9
|
+
label: { type: String, required: false, default: void 0 },
|
|
10
|
+
hint: { type: String, required: false, default: void 0 },
|
|
11
|
+
required: { type: Boolean, required: false, default: false },
|
|
12
|
+
disabled: { type: Boolean, required: false, default: false },
|
|
13
|
+
maxSize: { type: [Number, null], required: false, default: null },
|
|
14
|
+
missingAlt: { type: Boolean, required: false, default: false },
|
|
15
|
+
visibility: { type: String, required: false, default: void 0 },
|
|
16
|
+
processingConfig: { type: Object, required: false, default: void 0 },
|
|
17
|
+
collectionName: { type: [String, null], required: false, default: null },
|
|
18
|
+
picker: { type: Boolean, required: false, default: true },
|
|
19
|
+
onPick: { type: Function, required: false, default: void 0 },
|
|
20
|
+
editor: { type: Boolean, required: false, default: true },
|
|
21
|
+
removeLabel: { type: String, required: false, default: "Remove from this field" },
|
|
22
|
+
onOpen: { type: Function, required: false, default: void 0 }
|
|
23
|
+
});
|
|
24
|
+
const model = defineModel({ type: [String, null], ...{ default: null } });
|
|
25
|
+
const alt = defineModel("alt", { type: null, ...{ default: void 0 } });
|
|
26
|
+
const emit = defineEmits(["uploaded"]);
|
|
27
|
+
const field = useMLMediaField(model, {
|
|
28
|
+
accept: () => props.accept,
|
|
29
|
+
maxSize: () => props.maxSize,
|
|
30
|
+
visibility: props.visibility,
|
|
31
|
+
processingConfig: props.processingConfig,
|
|
32
|
+
collectionName: props.collectionName,
|
|
33
|
+
onUploaded: (id) => emit("uploaded", id)
|
|
34
|
+
});
|
|
35
|
+
const { state, asset, upload, message } = field;
|
|
36
|
+
const helpText = computed(() => {
|
|
37
|
+
if (props.hint) return props.hint;
|
|
38
|
+
const parts = [field.acceptLabel.value === "any file" ? "" : `Takes ${field.acceptLabel.value}`];
|
|
39
|
+
if (props.maxSize) parts.push(`up to ${formatBytes(props.maxSize)}`);
|
|
40
|
+
return parts.filter(Boolean).join(" ").replace(/^./, (c) => c.toUpperCase());
|
|
41
|
+
});
|
|
42
|
+
const name = computed(() => upload.value?.file.name ?? assetName(asset.value));
|
|
43
|
+
const kind = computed(() => upload.value?.kind ?? asset.value?.kind ?? null);
|
|
44
|
+
const isImage = computed(() => (kind.value ?? "").toLowerCase() === "image");
|
|
45
|
+
const details = computed(() => {
|
|
46
|
+
const u = upload.value;
|
|
47
|
+
if (u && state.value === "uploading") {
|
|
48
|
+
if (u.phase === "failed") return u.error ?? "Upload failed";
|
|
49
|
+
if (u.phase === "processing") return u.progress != null ? `Processing ${Math.round(u.progress)}%` : "Processing\u2026";
|
|
50
|
+
return `Uploading ${Math.round(u.progress ?? 0)}% \xB7 ${formatBytes(u.file.size)}`;
|
|
51
|
+
}
|
|
52
|
+
const a = asset.value;
|
|
53
|
+
if (!a) return "";
|
|
54
|
+
const dims = assetDimensions(a);
|
|
55
|
+
const duration = assetDurationSeconds(a);
|
|
56
|
+
return [
|
|
57
|
+
formatBytes(assetSizeBytes(a)),
|
|
58
|
+
dims ? `${dims.width} \xD7 ${dims.height}` : "",
|
|
59
|
+
duration ? formatDuration(duration) : ""
|
|
60
|
+
].filter(Boolean).join(" \xB7 ");
|
|
61
|
+
});
|
|
62
|
+
const veilPhase = computed(() => {
|
|
63
|
+
const p = upload.value?.phase;
|
|
64
|
+
return state.value === "uploading" && (p === "uploading" || p === "processing" || p === "failed") ? p : null;
|
|
65
|
+
});
|
|
66
|
+
const filePicker = useMLFilePicker();
|
|
67
|
+
const canPick = computed(() => !!props.onPick || props.picker);
|
|
68
|
+
async function pickExisting() {
|
|
69
|
+
if (props.onPick) return props.onPick();
|
|
70
|
+
const ids = await filePicker.open({
|
|
71
|
+
accept: props.accept,
|
|
72
|
+
max: 1,
|
|
73
|
+
selected: [model.value],
|
|
74
|
+
title: props.label ? `Select ${props.label.toLowerCase()}` : "Select file",
|
|
75
|
+
maxSize: props.maxSize,
|
|
76
|
+
upload: { visibility: props.visibility, processingConfig: props.processingConfig, collectionName: props.collectionName }
|
|
77
|
+
});
|
|
78
|
+
if (ids) {
|
|
79
|
+
field.clearMessage();
|
|
80
|
+
model.value = ids[0] ?? null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const mediaEditor = useMLMediaEditor();
|
|
84
|
+
const canOpen = computed(() => state.value === "filled" && !!model.value && (!!props.onOpen || props.editor));
|
|
85
|
+
const showNoAlt = computed(() => isImage.value && (props.missingAlt || alt.value !== void 0 && !alt.value.trim()));
|
|
86
|
+
async function openEditor() {
|
|
87
|
+
const id = model.value;
|
|
88
|
+
if (!canOpen.value || !id) return;
|
|
89
|
+
if (props.onOpen) return props.onOpen(id);
|
|
90
|
+
const result = await mediaEditor.open(id, {
|
|
91
|
+
alt: alt.value ?? "",
|
|
92
|
+
readonly: props.disabled,
|
|
93
|
+
removeLabel: props.removeLabel
|
|
94
|
+
});
|
|
95
|
+
if (result?.action === "save") alt.value = result.alt[id] ?? "";
|
|
96
|
+
else if (result?.action === "remove") field.clear();
|
|
97
|
+
}
|
|
98
|
+
function changeItems(open) {
|
|
99
|
+
return [
|
|
100
|
+
{ label: "Upload new", icon: "i-lucide-upload", onSelect: open },
|
|
101
|
+
{ label: "Select existing", icon: "i-lucide-layout-grid", onSelect: () => void pickExisting() }
|
|
102
|
+
];
|
|
103
|
+
}
|
|
104
|
+
</script>
|
|
105
|
+
|
|
106
|
+
<template>
|
|
107
|
+
<UFormField
|
|
108
|
+
:label="label"
|
|
109
|
+
:required="required"
|
|
110
|
+
:help="message ? void 0 : helpText || void 0"
|
|
111
|
+
:error="message ?? void 0"
|
|
112
|
+
>
|
|
113
|
+
<!-- Empty -->
|
|
114
|
+
<MLMediaDropzone
|
|
115
|
+
v-if="state === 'empty'"
|
|
116
|
+
:accept="field.acceptAttr.value"
|
|
117
|
+
:multiple="false"
|
|
118
|
+
:max-size="maxSize"
|
|
119
|
+
:disabled="disabled"
|
|
120
|
+
@files="field.onFiles"
|
|
121
|
+
@rejected="field.onRejected"
|
|
122
|
+
>
|
|
123
|
+
<template
|
|
124
|
+
v-if="canPick"
|
|
125
|
+
#actions
|
|
126
|
+
>
|
|
127
|
+
<UButton
|
|
128
|
+
size="sm"
|
|
129
|
+
color="neutral"
|
|
130
|
+
variant="outline"
|
|
131
|
+
icon="i-lucide-layout-grid"
|
|
132
|
+
label="Select existing"
|
|
133
|
+
:disabled="disabled"
|
|
134
|
+
@click="pickExisting"
|
|
135
|
+
/>
|
|
136
|
+
</template>
|
|
137
|
+
</MLMediaDropzone>
|
|
138
|
+
|
|
139
|
+
<!-- Loading the current file -->
|
|
140
|
+
<div
|
|
141
|
+
v-else-if="state === 'loading'"
|
|
142
|
+
class="flex items-center gap-3 rounded-lg border border-default p-2"
|
|
143
|
+
>
|
|
144
|
+
<USkeleton class="aspect-[4/3] w-20 rounded-md" />
|
|
145
|
+
<div class="flex flex-1 flex-col gap-1.5">
|
|
146
|
+
<USkeleton class="h-3.5 w-2/3" />
|
|
147
|
+
<USkeleton class="h-3 w-1/3" />
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
<!-- Filled, uploading, or pointing at a file that is gone -->
|
|
152
|
+
<MLMediaDropzone
|
|
153
|
+
v-else
|
|
154
|
+
variant="overlay"
|
|
155
|
+
:accept="field.acceptAttr.value"
|
|
156
|
+
:multiple="false"
|
|
157
|
+
:max-size="maxSize"
|
|
158
|
+
:disabled="disabled || state === 'uploading'"
|
|
159
|
+
title="Drop to replace"
|
|
160
|
+
class="rounded-lg"
|
|
161
|
+
@files="field.onFiles"
|
|
162
|
+
@rejected="field.onRejected"
|
|
163
|
+
>
|
|
164
|
+
<template #default="{ open }">
|
|
165
|
+
<div
|
|
166
|
+
class="flex items-center gap-3 rounded-lg border p-2"
|
|
167
|
+
:class="state === 'missing' ? 'border-warning/50 bg-warning/5' : 'border-default bg-default'"
|
|
168
|
+
>
|
|
169
|
+
<div class="group relative shrink-0">
|
|
170
|
+
<MLMediaThumbnail
|
|
171
|
+
:asset="upload?.localPreview ? null : asset"
|
|
172
|
+
:src="upload?.localPreview ?? void 0"
|
|
173
|
+
:kind="kind"
|
|
174
|
+
size="md"
|
|
175
|
+
aspect="landscape"
|
|
176
|
+
>
|
|
177
|
+
<MLMediaProgressVeil
|
|
178
|
+
v-if="veilPhase"
|
|
179
|
+
:phase="veilPhase"
|
|
180
|
+
:progress="upload?.progress ?? null"
|
|
181
|
+
compact
|
|
182
|
+
/>
|
|
183
|
+
</MLMediaThumbnail>
|
|
184
|
+
<MLMediaPreviewEye
|
|
185
|
+
v-if="canOpen"
|
|
186
|
+
:name="name"
|
|
187
|
+
class="absolute bottom-1 right-1 z-20"
|
|
188
|
+
@click="openEditor"
|
|
189
|
+
/>
|
|
190
|
+
</div>
|
|
191
|
+
|
|
192
|
+
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
193
|
+
<span
|
|
194
|
+
v-if="state === 'missing'"
|
|
195
|
+
class="text-sm font-semibold"
|
|
196
|
+
>File unavailable</span>
|
|
197
|
+
<span
|
|
198
|
+
v-else
|
|
199
|
+
class="truncate text-sm font-semibold"
|
|
200
|
+
>{{ name }}</span>
|
|
201
|
+
<span
|
|
202
|
+
v-if="state === 'missing'"
|
|
203
|
+
class="text-xs text-muted"
|
|
204
|
+
>It was deleted, or you don't have access to it.</span>
|
|
205
|
+
<span
|
|
206
|
+
v-else
|
|
207
|
+
class="truncate text-xs tabular-nums"
|
|
208
|
+
:class="upload?.phase === 'failed' ? 'text-error' : 'text-muted'"
|
|
209
|
+
>{{ details }}</span>
|
|
210
|
+
<span
|
|
211
|
+
v-if="state === 'filled' && showNoAlt"
|
|
212
|
+
class="text-xs text-warning"
|
|
213
|
+
>No alt text</span>
|
|
214
|
+
</div>
|
|
215
|
+
|
|
216
|
+
<div
|
|
217
|
+
v-if="!disabled"
|
|
218
|
+
class="flex shrink-0 items-center gap-0.5"
|
|
219
|
+
>
|
|
220
|
+
<template v-if="state === 'uploading'">
|
|
221
|
+
<UButton
|
|
222
|
+
v-if="upload?.phase === 'failed'"
|
|
223
|
+
size="sm"
|
|
224
|
+
color="neutral"
|
|
225
|
+
variant="ghost"
|
|
226
|
+
icon="i-lucide-rotate-ccw"
|
|
227
|
+
label="Retry"
|
|
228
|
+
@click="upload?.retry()"
|
|
229
|
+
/>
|
|
230
|
+
<UButton
|
|
231
|
+
v-if="upload?.phase === 'failed'"
|
|
232
|
+
size="sm"
|
|
233
|
+
color="neutral"
|
|
234
|
+
variant="ghost"
|
|
235
|
+
icon="i-lucide-x"
|
|
236
|
+
aria-label="Remove"
|
|
237
|
+
@click="field.clear()"
|
|
238
|
+
/>
|
|
239
|
+
<UButton
|
|
240
|
+
v-else
|
|
241
|
+
size="sm"
|
|
242
|
+
color="neutral"
|
|
243
|
+
variant="ghost"
|
|
244
|
+
label="Cancel"
|
|
245
|
+
@click="upload?.cancel()"
|
|
246
|
+
/>
|
|
247
|
+
</template>
|
|
248
|
+
<template v-else>
|
|
249
|
+
<UDropdownMenu
|
|
250
|
+
v-if="canPick"
|
|
251
|
+
:items="changeItems(open)"
|
|
252
|
+
:content="{ align: 'end' }"
|
|
253
|
+
>
|
|
254
|
+
<UButton
|
|
255
|
+
size="sm"
|
|
256
|
+
variant="ghost"
|
|
257
|
+
label="Change"
|
|
258
|
+
trailing-icon="i-lucide-chevron-down"
|
|
259
|
+
/>
|
|
260
|
+
</UDropdownMenu>
|
|
261
|
+
<UButton
|
|
262
|
+
v-else
|
|
263
|
+
size="sm"
|
|
264
|
+
variant="ghost"
|
|
265
|
+
label="Change"
|
|
266
|
+
@click="open"
|
|
267
|
+
/>
|
|
268
|
+
<UButton
|
|
269
|
+
size="sm"
|
|
270
|
+
color="neutral"
|
|
271
|
+
variant="ghost"
|
|
272
|
+
icon="i-lucide-x"
|
|
273
|
+
:aria-label="`Remove ${name || 'file'}`"
|
|
274
|
+
@click="field.clear()"
|
|
275
|
+
/>
|
|
276
|
+
</template>
|
|
277
|
+
</div>
|
|
278
|
+
</div>
|
|
279
|
+
</template>
|
|
280
|
+
</MLMediaDropzone>
|
|
281
|
+
</UFormField>
|
|
282
|
+
</template>
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { MediaUploadOptions } from '../../composables/useMLMediaUpload.js';
|
|
2
|
+
type __VLS_Props = {
|
|
3
|
+
/** Asset kinds the field takes: ['image'], "image,video". Empty means any file. */
|
|
4
|
+
accept?: readonly string[] | string;
|
|
5
|
+
label?: string;
|
|
6
|
+
/** Under the field. Defaults to what the field accepts and its size limit. */
|
|
7
|
+
hint?: string;
|
|
8
|
+
required?: boolean;
|
|
9
|
+
disabled?: boolean;
|
|
10
|
+
/** Bytes. */
|
|
11
|
+
maxSize?: number | null;
|
|
12
|
+
/** Shows "No alt text" on a filled image field. Alt text lives on the host record. */
|
|
13
|
+
missingAlt?: boolean;
|
|
14
|
+
/** Upload session settings for new files. */
|
|
15
|
+
visibility?: MediaUploadOptions['visibility'];
|
|
16
|
+
processingConfig?: MediaUploadOptions['processingConfig'];
|
|
17
|
+
collectionName?: string | null;
|
|
18
|
+
/** Show "Select existing", which opens the library picker. */
|
|
19
|
+
picker?: boolean;
|
|
20
|
+
/** Replaces the built-in picker, e.g. with the host's own dialog. */
|
|
21
|
+
onPick?: () => void;
|
|
22
|
+
/** Show the eye button on the thumbnail, which opens the media editor. */
|
|
23
|
+
editor?: boolean;
|
|
24
|
+
/** Label for the editor's remove button. Empty hides it. */
|
|
25
|
+
removeLabel?: string;
|
|
26
|
+
/** Replaces the built-in editor. Receives the asset ID. */
|
|
27
|
+
onOpen?: (assetId: string) => void;
|
|
28
|
+
};
|
|
29
|
+
type __VLS_ModelProps = {
|
|
30
|
+
modelValue?: string | null;
|
|
31
|
+
/** Alt text for the file, stored on the host record. Edited in the media editor. */
|
|
32
|
+
'alt'?: string | undefined;
|
|
33
|
+
};
|
|
34
|
+
type __VLS_PublicProps = __VLS_Props & __VLS_ModelProps;
|
|
35
|
+
declare const __VLS_export: import("vue").DefineComponent<__VLS_PublicProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
|
|
36
|
+
uploaded: (assetId: string) => any;
|
|
37
|
+
"update:modelValue": (value: string | null) => any;
|
|
38
|
+
"update:alt": (value: string | undefined) => any;
|
|
39
|
+
}, string, import("vue").PublicProps, Readonly<__VLS_PublicProps> & Readonly<{
|
|
40
|
+
onUploaded?: ((assetId: string) => any) | undefined;
|
|
41
|
+
"onUpdate:modelValue"?: ((value: string | null) => any) | undefined;
|
|
42
|
+
"onUpdate:alt"?: ((value: string | undefined) => any) | undefined;
|
|
43
|
+
}>, {
|
|
44
|
+
visibility: "private" | "restricted" | "public";
|
|
45
|
+
label: string;
|
|
46
|
+
accept: readonly string[] | string;
|
|
47
|
+
maxSize: number | null;
|
|
48
|
+
processingConfig: {
|
|
49
|
+
stepOptions?: Record<string, Record<string, unknown>> | null;
|
|
50
|
+
useFilenameAsAssetId?: boolean;
|
|
51
|
+
enableTranscription?: boolean;
|
|
52
|
+
};
|
|
53
|
+
collectionName: string | null;
|
|
54
|
+
disabled: boolean;
|
|
55
|
+
required: boolean;
|
|
56
|
+
removeLabel: string;
|
|
57
|
+
hint: string;
|
|
58
|
+
missingAlt: boolean;
|
|
59
|
+
picker: boolean;
|
|
60
|
+
onPick: () => void;
|
|
61
|
+
editor: boolean;
|
|
62
|
+
onOpen: (assetId: string) => void;
|
|
63
|
+
}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
64
|
+
declare const _default: typeof __VLS_export;
|
|
65
|
+
export default _default;
|