Skip to content

Commit 2e79b10

Browse files
committed
feat: add is array support to upload plugin
1 parent d0dfb0c commit 2e79b10

File tree

3 files changed

+157
-77
lines changed

3 files changed

+157
-77
lines changed

custom/preview.vue

Lines changed: 61 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,36 @@
11
<template>
22
<div>
3-
<template v-if="url">
4-
<img
5-
v-if="contentType && contentType.startsWith('image')"
6-
:src="url"
7-
class="rounded-md"
8-
:style="[maxWidth, minWidth]"
9-
ref="img"
10-
@click.stop="zoom.open()"
11-
/>
12-
<video
13-
v-else-if="contentType && contentType.startsWith('video')"
14-
:src="url"
15-
class="rounded-md"
16-
controls
17-
@click.stop >
18-
</video>
19-
20-
<a v-else :href="url" target="_blank"
21-
class="flex gap-1 items-center py-1 px-3 me-2 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded border border-gray-300 hover:bg-gray-100 hover:text-lightPrimary focus:z-10 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 dark:bg-darkListTable dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700 rounded-default"
22-
>
23-
<!-- download file icon -->
24-
<svg class="w-4 h-4 me-2" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
25-
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
26-
</svg>
27-
{{ $t('Download file') }}
28-
</a>
3+
<template v-if="urls.length">
4+
<div class="flex flex-wrap gap-2 items-start">
5+
<template v-for="(u, i) in urls" :key="`${u}-${i}`">
6+
<img
7+
v-if="guessContentTypeFromUrl(u)?.startsWith('image')"
8+
:src="u"
9+
class="rounded-md cursor-zoom-in"
10+
:style="[maxWidth, minWidth]"
11+
ref="img"
12+
@click.stop="openZoom(i)"
13+
/>
14+
<video
15+
v-else-if="guessContentTypeFromUrl(u)?.startsWith('video')"
16+
:src="u"
17+
class="rounded-md"
18+
controls
19+
@click.stop
20+
/>
21+
<a
22+
v-else
23+
:href="u"
24+
target="_blank"
25+
class="flex gap-1 items-center py-1 px-3 me-2 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded border border-gray-300 hover:bg-gray-100 hover:text-lightPrimary focus:z-10 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 dark:bg-darkListTable dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700 rounded-default"
26+
>
27+
<svg class="w-4 h-4 me-2" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
28+
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
29+
</svg>
30+
{{ $t('Download file') }}
31+
</a>
32+
</template>
33+
</div>
2934
</template>
3035

3136

@@ -74,8 +79,10 @@ const props = defineProps({
7479
const trueContentType = ref(null);
7580
7681
onMounted(async () => {
77-
// try to get HEAD request
78-
try {
82+
// try to get HEAD request (single url only). For arrays we just guess by extension.
83+
if (!url.value) return;
84+
if (Array.isArray(url.value)) return;
85+
try {
7986
const response = await fetch(url.value, {
8087
method: 'HEAD',
8188
mode: 'cors',
@@ -101,6 +108,11 @@ const url = computed(() => {
101108
return props.record[`previewUrl_${props.meta.pluginInstanceId}`];
102109
});
103110
111+
const urls = computed(() => {
112+
if (!url.value) return [];
113+
return Array.isArray(url.value) ? url.value : [url.value];
114+
});
115+
104116
const maxWidth = computed(() => {
105117
const isShowPage = route.path.includes('/show/');
106118
const width = isShowPage
@@ -124,6 +136,7 @@ const guessedContentType = computed(() => {
124136
if (!url.value) {
125137
return null;
126138
}
139+
if (Array.isArray(url.value)) return null;
127140
const u = new URL(url.value, url.value.startsWith('http') ? undefined : location.origin);
128141
return guessContentType(u.pathname);
129142
});
@@ -141,19 +154,35 @@ function guessContentType(url) {
141154
}
142155
}
143156
157+
function guessContentTypeFromUrl(u) {
158+
if (!u) return null;
159+
try {
160+
const parsed = new URL(u, u.startsWith('http') ? undefined : location.origin);
161+
return guessContentType(parsed.pathname);
162+
} catch (e) {
163+
return guessContentType(u);
164+
}
165+
}
144166
145167
watch([contentType], async ([contentType]) => {
146168
// since content type might change after true guessing (HEAD request might be slow) we need to try initializing zoom again
147169
if (zoom.value) {
148170
zoom.value.detach();
149171
}
150172
await nextTick();
151-
if (contentType?.startsWith('image')) {
152-
zoom.value = mediumZoom(img.value, {
153-
margin: 24,
154-
});
173+
// For arrays we use click-to-open per image, for single we keep existing behavior.
174+
if (contentType?.startsWith('image') && !Array.isArray(url.value)) {
175+
zoom.value = mediumZoom(img.value, { margin: 24 });
155176
}
156177
157178
}, { immediate: true });
158179
180+
function openZoom(index) {
181+
if (!urls.value?.length) return;
182+
const el = Array.isArray(img.value) ? img.value[index] : img.value;
183+
if (!el) return;
184+
const z = mediumZoom(el, { margin: 24 });
185+
z.open();
186+
}
187+
159188
</script>

custom/uploader.vue

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
}"
2525
>
2626
<div class="flex flex-col items-center justify-center pt-5 pb-6">
27-
<img v-if="imgPreview" :src="imgPreview" class="w-100 mt-4 rounded-lg h-40 object-contain" />
27+
<img v-if="typeof imgPreview === 'string' && imgPreview" :src="imgPreview" class="w-100 mt-4 rounded-lg h-40 object-contain" />
2828

2929
<svg v-else class="w-8 h-8 mb-4 text-gray-500 dark:text-gray-400 !text-lightDropzoneText dark:!text-darkDropzoneText" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 16">
3030
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 13h3a3 3 0 0 0 0-6h-.025A5.56 5.56 0 0 0 16 6.5 5.5 5.5 0 0 0 5.207 5.021C5.137 5.017 5.071 5 5 5a4 4 0 0 0 0 8h2.167M10 15V6m0 0L8 8m2-2 2 2"/>
@@ -66,7 +66,7 @@
6666
</template>
6767

6868
<script setup lang="ts">
69-
import { computed, ref, onMounted, watch } from 'vue'
69+
import { computed, ref, onMounted, watch, getCurrentInstance } from 'vue'
7070
import { callAdminForthApi } from '@/utils'
7171
import { IconMagic } from '@iconify-prerendered/vue-mdi';
7272
import { useI18n } from 'vue-i18n';
@@ -75,7 +75,8 @@ import { useRoute } from 'vue-router';
7575
const route = useRoute();
7676
const { t } = useI18n();
7777
78-
const inputId = computed(() => `dropzone-file-${props.meta.pluginInstanceId}`);
78+
const instanceUid = getCurrentInstance()?.uid ?? Math.floor(Math.random() * 1000000);
79+
const inputId = computed(() => `dropzone-file-${props.meta.pluginInstanceId}-${instanceUid}`);
7980
8081
import ImageGenerator from '@@/plugins/UploadPlugin/imageGenerator.vue';
8182
import adminforth from '@/adminforth';
@@ -84,6 +85,7 @@ import adminforth from '@/adminforth';
8485
const props = defineProps({
8586
meta: Object,
8687
record: Object,
88+
value: [String, Number, Boolean, Object, Array, null],
8789
})
8890
8991
const emit = defineEmits([
@@ -102,10 +104,9 @@ const progress = ref(0);
102104
103105
const uploaded = ref(false);
104106
const uploadedSize = ref(0);
105-
106107
const downloadFileUrl = ref('');
107108
108-
watch(() => uploaded, (value) => {
109+
watch(uploaded, (value) => {
109110
emit('update:emptiness', !value);
110111
});
111112
@@ -129,7 +130,8 @@ onMounted(async () => {
129130
queryValues = {};
130131
}
131132
132-
if (queryValues[props.meta.pathColumnName]) {
133+
134+
if (typeof queryValues?.[props.meta.pathColumnName] === 'string' && queryValues[props.meta.pathColumnName]) {
133135
downloadFileUrl.value = queryValues[props.meta.pathColumnName];
134136
135137
const resp = await callAdminForthApi({
@@ -163,7 +165,28 @@ onMounted(async () => {
163165
files: [file],
164166
},
165167
});
166-
} else if (props.record[previewColumnName]) {
168+
}
169+
170+
const existingValue = (props as any).value;
171+
const existingFilePath =
172+
typeof existingValue === 'string' && existingValue.trim() ? existingValue : null;
173+
174+
if (!uploaded.value && existingFilePath) {
175+
const resp = await callAdminForthApi({
176+
path: `/plugin/${props.meta.pluginInstanceId}/get-file-download-url`,
177+
method: 'POST',
178+
body: { filePath: existingFilePath },
179+
});
180+
181+
if (!resp?.error && resp?.url) {
182+
imgPreview.value = resp.url;
183+
uploaded.value = true;
184+
emit('update:emptiness', false);
185+
return;
186+
}
187+
}
188+
189+
if (!uploaded.value && props.record?.[previewColumnName]) {
167190
imgPreview.value = props.record[previewColumnName];
168191
uploaded.value = true;
169192
emit('update:emptiness', false);

index.ts

Lines changed: 66 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,29 @@ export default class UploadPlugin extends AdminForthPlugin {
3232
}
3333
}
3434

35+
private normalizePaths(value: any): string[] {
36+
if (!value) return [];
37+
if (Array.isArray(value)) return value.filter(Boolean).map(String);
38+
return [String(value)];
39+
}
40+
41+
private async callStorageAdapter(primaryMethod: string, fallbackMethod: string, filePath: string) {
42+
const adapter: any = this.options.storageAdapter as any;
43+
const fn = adapter?.[primaryMethod] ?? adapter?.[fallbackMethod];
44+
if (typeof fn !== 'function') {
45+
throw new Error(`Storage adapter is missing method "${primaryMethod}" (fallback "${fallbackMethod}")`);
46+
}
47+
await fn.call(adapter, filePath);
48+
}
49+
50+
private markKeyForNotDeletion(filePath: string) {
51+
return this.callStorageAdapter('markKeyForNotDeletion', 'markKeyForNotDeletation', filePath);
52+
}
53+
54+
private markKeyForDeletion(filePath: string) {
55+
return this.callStorageAdapter('markKeyForDeletion', 'markKeyForDeletation', filePath);
56+
}
57+
3558
instanceUniqueRepresentation(pluginOptions: any) : string {
3659
return `${pluginOptions.pathColumnName}`;
3760
}
@@ -42,13 +65,19 @@ export default class UploadPlugin extends AdminForthPlugin {
4265
}
4366

4467
async genPreviewUrl(record: any) {
45-
if (this.options.preview?.previewUrl) {
46-
record[`previewUrl_${this.pluginInstanceId}`] = this.options.preview.previewUrl({ filePath: record[this.options.pathColumnName] });
47-
return;
48-
}
49-
const previewUrl = await this.options.storageAdapter.getDownloadUrl(record[this.options.pathColumnName], 1800);
68+
const value = record?.[this.options.pathColumnName];
69+
const paths = this.normalizePaths(value);
70+
if (!paths.length) return;
5071

51-
record[`previewUrl_${this.pluginInstanceId}`] = previewUrl;
72+
const makeUrl = async (filePath: string) => {
73+
if (this.options.preview?.previewUrl) {
74+
return this.options.preview.previewUrl({ filePath });
75+
}
76+
return await this.options.storageAdapter.getDownloadUrl(filePath, 1800);
77+
};
78+
79+
const urls = await Promise.all(paths.map(makeUrl));
80+
record[`previewUrl_${this.pluginInstanceId}`] = Array.isArray(value) ? urls : urls[0];
5281
}
5382

5483
async modifyResourceConfig(adminforth: IAdminForth, resourceConfig: AdminForthResource) {
@@ -128,15 +157,12 @@ export default class UploadPlugin extends AdminForthPlugin {
128157
resourceConfig.hooks.create.afterSave.push(async ({ record }: { record: any }) => {
129158
process.env.HEAVY_DEBUG && console.log('💾💾 after save ', record?.id);
130159

131-
if (record[pathColumnName]) {
132-
process.env.HEAVY_DEBUG && console.log('🪥🪥 remove ObjectTagging', record[pathColumnName]);
160+
const paths = this.normalizePaths(record?.[pathColumnName]);
161+
await Promise.all(paths.map(async (p) => {
162+
process.env.HEAVY_DEBUG && console.log('🪥🪥 remove ObjectTagging', p);
133163
// let it crash if it fails: this is a new file which just was uploaded.
134-
if (this.options.storageAdapter.markKeyForNotDeletion !== undefined) {
135-
await this.options.storageAdapter.markKeyForNotDeletion(record[pathColumnName]);
136-
} else {
137-
await this.options.storageAdapter.markKeyForNotDeletation(record[pathColumnName]);
138-
}
139-
}
164+
await this.markKeyForNotDeletion(p);
165+
}));
140166
return { ok: true };
141167
});
142168

@@ -176,18 +202,15 @@ export default class UploadPlugin extends AdminForthPlugin {
176202

177203
// add delete hook which sets tag adminforth-candidate-for-cleanup to true
178204
resourceConfig.hooks.delete.afterSave.push(async ({ record }: { record: any }) => {
179-
if (record[pathColumnName]) {
205+
const paths = this.normalizePaths(record?.[pathColumnName]);
206+
await Promise.all(paths.map(async (p) => {
180207
try {
181-
if (this.options.storageAdapter.markKeyForDeletion !== undefined) {
182-
await this.options.storageAdapter.markKeyForDeletion(record[pathColumnName]);
183-
} else {
184-
await this.options.storageAdapter.markKeyForDeletation(record[pathColumnName]);
185-
}
208+
await this.markKeyForDeletion(p);
186209
} catch (e) {
187210
// file might be e.g. already deleted, so we catch error
188-
console.error(`Error setting tag ${ADMINFORTH_NOT_YET_USED_TAG} to true for object ${record[pathColumnName]}. File will not be auto-cleaned up`, e);
211+
console.error(`Error setting tag ${ADMINFORTH_NOT_YET_USED_TAG} to true for object ${p}. File will not be auto-cleaned up`, e);
189212
}
190-
}
213+
}));
191214
return { ok: true };
192215
});
193216

@@ -200,28 +223,33 @@ export default class UploadPlugin extends AdminForthPlugin {
200223
resourceConfig.hooks.edit.afterSave.push(async ({ updates, oldRecord }: { updates: any, oldRecord: any }) => {
201224

202225
if (updates[pathColumnName] || updates[pathColumnName] === null) {
203-
if (oldRecord[pathColumnName]) {
226+
const oldValue = oldRecord?.[pathColumnName];
227+
const newValue = updates?.[pathColumnName];
228+
229+
const oldPaths = this.normalizePaths(oldValue);
230+
const newPaths = newValue === null ? [] : this.normalizePaths(newValue);
231+
232+
const oldSet = new Set(oldPaths);
233+
const newSet = new Set(newPaths);
234+
235+
const toDelete = oldPaths.filter((p) => !newSet.has(p));
236+
const toKeep = newPaths.filter((p) => !oldSet.has(p));
237+
238+
await Promise.all(toDelete.map(async (p) => {
204239
// put tag to delete old file
205240
try {
206-
if (this.options.storageAdapter.markKeyForDeletion !== undefined) {
207-
await this.options.storageAdapter.markKeyForDeletion(oldRecord[pathColumnName]);
208-
} else {
209-
await this.options.storageAdapter.markKeyForDeletation(oldRecord[pathColumnName]);
210-
}
241+
await this.markKeyForDeletion(p);
211242
} catch (e) {
212243
// file might be e.g. already deleted, so we catch error
213-
console.error(`Error setting tag ${ADMINFORTH_NOT_YET_USED_TAG} to true for object ${oldRecord[pathColumnName]}. File will not be auto-cleaned up`, e);
244+
console.error(`Error setting tag ${ADMINFORTH_NOT_YET_USED_TAG} to true for object ${p}. File will not be auto-cleaned up`, e);
214245
}
215-
}
216-
if (updates[pathColumnName] !== null) {
246+
}));
247+
248+
await Promise.all(toKeep.map(async (p) => {
217249
// remove tag from new file
218-
// in this case we let it crash if it fails: this is a new file which just was uploaded.
219-
if (this.options.storageAdapter.markKeyForNotDeletion !== undefined) {
220-
await this.options.storageAdapter.markKeyForNotDeletion(updates[pathColumnName]);
221-
} else {
222-
await this.options.storageAdapter.markKeyForNotDeletation(updates[pathColumnName]);
223-
}
224-
}
250+
// in this case we let it crash if it fails: this is a new file which just was uploaded.
251+
await this.markKeyForNotDeletion(p);
252+
}));
225253
}
226254
return { ok: true };
227255
});

0 commit comments

Comments
 (0)