WARNING: THIS SITE IS A MIRROR OF GITHUB.COM / IT CANNOT LOGIN OR REGISTER ACCOUNTS / THE CONTENTS ARE PROVIDED AS-IS / THIS SITE ASSUMES NO RESPONSIBILITY FOR ANY DISPLAYED CONTENT OR LINKS / IF YOU FOUND SOMETHING MAY NOT GOOD FOR EVERYONE, CONTACT ADMIN AT ilovescratch@foxmail.com
Skip to content

Commit ded98e2

Browse files
committed
Merge branch 'main' of github.com:devforth/adminforth-upload
2 parents a81e1af + 6785dfa commit ded98e2

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
@@ -41,6 +41,29 @@ export default class UploadPlugin extends AdminForthPlugin {
4141
}
4242
}
4343

44+
private normalizePaths(value: any): string[] {
45+
if (!value) return [];
46+
if (Array.isArray(value)) return value.filter(Boolean).map(String);
47+
return [String(value)];
48+
}
49+
50+
private async callStorageAdapter(primaryMethod: string, fallbackMethod: string, filePath: string) {
51+
const adapter: any = this.options.storageAdapter as any;
52+
const fn = adapter?.[primaryMethod] ?? adapter?.[fallbackMethod];
53+
if (typeof fn !== 'function') {
54+
throw new Error(`Storage adapter is missing method "${primaryMethod}" (fallback "${fallbackMethod}")`);
55+
}
56+
await fn.call(adapter, filePath);
57+
}
58+
59+
private markKeyForNotDeletion(filePath: string) {
60+
return this.callStorageAdapter('markKeyForNotDeletion', 'markKeyForNotDeletation', filePath);
61+
}
62+
63+
private markKeyForDeletion(filePath: string) {
64+
return this.callStorageAdapter('markKeyForDeletion', 'markKeyForDeletation', filePath);
65+
}
66+
4467
private async generateImages(jobId: string, prompt: string, recordId: any, adminUser: any, headers: any) {
4568
if (this.options.generation.rateLimit?.limit) {
4669
// rate limit
@@ -128,13 +151,19 @@ export default class UploadPlugin extends AdminForthPlugin {
128151
}
129152

130153
async genPreviewUrl(record: any) {
131-
if (this.options.preview?.previewUrl) {
132-
record[`previewUrl_${this.pluginInstanceId}`] = this.options.preview.previewUrl({ filePath: record[this.options.pathColumnName] });
133-
return;
134-
}
135-
const previewUrl = await this.options.storageAdapter.getDownloadUrl(record[this.options.pathColumnName], 1800);
154+
const value = record?.[this.options.pathColumnName];
155+
const paths = this.normalizePaths(value);
156+
if (!paths.length) return;
136157

137-
record[`previewUrl_${this.pluginInstanceId}`] = previewUrl;
158+
const makeUrl = async (filePath: string) => {
159+
if (this.options.preview?.previewUrl) {
160+
return this.options.preview.previewUrl({ filePath });
161+
}
162+
return await this.options.storageAdapter.getDownloadUrl(filePath, 1800);
163+
};
164+
165+
const urls = await Promise.all(paths.map(makeUrl));
166+
record[`previewUrl_${this.pluginInstanceId}`] = Array.isArray(value) ? urls : urls[0];
138167
}
139168

140169
async modifyResourceConfig(adminforth: IAdminForth, resourceConfig: AdminForthResource) {
@@ -214,15 +243,12 @@ export default class UploadPlugin extends AdminForthPlugin {
214243
resourceConfig.hooks.create.afterSave.push(async ({ record }: { record: any }) => {
215244
process.env.HEAVY_DEBUG && console.log('💾💾 after save ', record?.id);
216245

217-
if (record[pathColumnName]) {
218-
process.env.HEAVY_DEBUG && console.log('🪥🪥 remove ObjectTagging', record[pathColumnName]);
246+
const paths = this.normalizePaths(record?.[pathColumnName]);
247+
await Promise.all(paths.map(async (p) => {
248+
process.env.HEAVY_DEBUG && console.log('🪥🪥 remove ObjectTagging', p);
219249
// let it crash if it fails: this is a new file which just was uploaded.
220-
if (this.options.storageAdapter.markKeyForNotDeletion !== undefined) {
221-
await this.options.storageAdapter.markKeyForNotDeletion(record[pathColumnName]);
222-
} else {
223-
await this.options.storageAdapter.markKeyForNotDeletation(record[pathColumnName]);
224-
}
225-
}
250+
await this.markKeyForNotDeletion(p);
251+
}));
226252
return { ok: true };
227253
});
228254

@@ -262,18 +288,15 @@ export default class UploadPlugin extends AdminForthPlugin {
262288

263289
// add delete hook which sets tag adminforth-candidate-for-cleanup to true
264290
resourceConfig.hooks.delete.afterSave.push(async ({ record }: { record: any }) => {
265-
if (record[pathColumnName]) {
291+
const paths = this.normalizePaths(record?.[pathColumnName]);
292+
await Promise.all(paths.map(async (p) => {
266293
try {
267-
if (this.options.storageAdapter.markKeyForDeletion !== undefined) {
268-
await this.options.storageAdapter.markKeyForDeletion(record[pathColumnName]);
269-
} else {
270-
await this.options.storageAdapter.markKeyForDeletation(record[pathColumnName]);
271-
}
294+
await this.markKeyForDeletion(p);
272295
} catch (e) {
273296
// file might be e.g. already deleted, so we catch error
274-
console.error(`Error setting tag ${ADMINFORTH_NOT_YET_USED_TAG} to true for object ${record[pathColumnName]}. File will not be auto-cleaned up`, e);
297+
console.error(`Error setting tag ${ADMINFORTH_NOT_YET_USED_TAG} to true for object ${p}. File will not be auto-cleaned up`, e);
275298
}
276-
}
299+
}));
277300
return { ok: true };
278301
});
279302

@@ -286,28 +309,33 @@ export default class UploadPlugin extends AdminForthPlugin {
286309
resourceConfig.hooks.edit.afterSave.push(async ({ updates, oldRecord }: { updates: any, oldRecord: any }) => {
287310

288311
if (updates[pathColumnName] || updates[pathColumnName] === null) {
289-
if (oldRecord[pathColumnName]) {
312+
const oldValue = oldRecord?.[pathColumnName];
313+
const newValue = updates?.[pathColumnName];
314+
315+
const oldPaths = this.normalizePaths(oldValue);
316+
const newPaths = newValue === null ? [] : this.normalizePaths(newValue);
317+
318+
const oldSet = new Set(oldPaths);
319+
const newSet = new Set(newPaths);
320+
321+
const toDelete = oldPaths.filter((p) => !newSet.has(p));
322+
const toKeep = newPaths.filter((p) => !oldSet.has(p));
323+
324+
await Promise.all(toDelete.map(async (p) => {
290325
// put tag to delete old file
291326
try {
292-
if (this.options.storageAdapter.markKeyForDeletion !== undefined) {
293-
await this.options.storageAdapter.markKeyForDeletion(oldRecord[pathColumnName]);
294-
} else {
295-
await this.options.storageAdapter.markKeyForDeletation(oldRecord[pathColumnName]);
296-
}
327+
await this.markKeyForDeletion(p);
297328
} catch (e) {
298329
// file might be e.g. already deleted, so we catch error
299-
console.error(`Error setting tag ${ADMINFORTH_NOT_YET_USED_TAG} to true for object ${oldRecord[pathColumnName]}. File will not be auto-cleaned up`, e);
330+
console.error(`Error setting tag ${ADMINFORTH_NOT_YET_USED_TAG} to true for object ${p}. File will not be auto-cleaned up`, e);
300331
}
301-
}
302-
if (updates[pathColumnName] !== null) {
332+
}));
333+
334+
await Promise.all(toKeep.map(async (p) => {
303335
// remove tag from new file
304-
// in this case we let it crash if it fails: this is a new file which just was uploaded.
305-
if (this.options.storageAdapter.markKeyForNotDeletion !== undefined) {
306-
await this.options.storageAdapter.markKeyForNotDeletion(updates[pathColumnName]);
307-
} else {
308-
await this.options.storageAdapter.markKeyForNotDeletation(updates[pathColumnName]);
309-
}
310-
}
336+
// in this case we let it crash if it fails: this is a new file which just was uploaded.
337+
await this.markKeyForNotDeletion(p);
338+
}));
311339
}
312340
return { ok: true };
313341
});

0 commit comments

Comments
 (0)