Skip to content

Commit df60b0e

Browse files
authored
Merge pull request #19 from devforth/feature/AdminForth/1208/add-ability-to-attachremove-im
Feature/admin forth/1208/add ability to attachremove im
2 parents 7f1cf0c + 45f30df commit df60b0e

File tree

3 files changed

+193
-54
lines changed

3 files changed

+193
-54
lines changed

custom/imageGenerator.vue

Lines changed: 187 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,43 @@
3434

3535
<!-- Thumbnails -->
3636
<div class="mt-2 flex flex-wrap gap-2">
37-
<img
38-
v-for="(img, idx) in attachmentFiles"
39-
:key="idx"
40-
:src="img"
41-
class="w-20 h-20 object-cover rounded cursor-pointer border hover:border-blue-500 transition"
42-
:alt="`Generated image ${idx + 1}`"
43-
@click="zoomImage(img)"
37+
<div class="group relative" v-for="(img, key) in requestAttachmentFilesUrls">
38+
<img
39+
:key="key"
40+
:src="img"
41+
class="w-20 h-20 object-cover rounded cursor-pointer border hover:border-blue-500 transition"
42+
:alt="`Generated image ${key + 1}`"
43+
@click="zoomImage(img)"
44+
/>
45+
<div
46+
class="opacity-0 group-hover:opacity-100 flex items-center justify-center w-5 h-5 bg-black absolute -top-2 -end-2 rounded-full border-2 border-white cursor-pointer hover:border-gray-300 hover:scale-110"
47+
@click="removeFileFromList(key)"
48+
>
49+
<Tooltip class="absolute top-0 end-0">
50+
<div>
51+
<div class="w-4 h-4 absolute"></div>
52+
<IconCloseOutline class="w-3 h-3 text-white hover:text-gray-300" />
53+
</div>
54+
<template #tooltip>
55+
Remove file
56+
</template>
57+
</Tooltip>
58+
</div>
59+
</div>
60+
<input
61+
ref="fileInput"
62+
class="hidden"
63+
type="file"
64+
@change="handleAddFile"
65+
accept="image/*"
4466
/>
67+
<button v-if="!uploading" @click="fileInput?.click()" type="button" class="relative group hover:border-gray-500 transition border-gray-300 flex items-center justify-center w-20 h-20 border-2 border-dashed rounded-md">
68+
<div class="flex flex-col items-center justify-center gap-2 mt-4 mb-4">
69+
<IconCloseOutline class="group-hover:text-gray-500 transition rotate-45 w-6 h-6 text-gray-300 hover:text-gray-300" />
70+
<p class="text-gray-300 group-hover:text-gray-500 transition bottom-0">Ctrl + v</p>
71+
</div>
72+
</button>
73+
<Skeleton v-else type="image" class="w-20 h-20" />
4574
</div>
4675

4776
<!-- Fullscreen Modal -->
@@ -175,38 +204,39 @@
175204

176205
<script setup lang="ts">
177206
178-
import { ref, onMounted, nextTick, Ref, h, computed, watch, reactive } from 'vue'
207+
import { ref, onMounted, nextTick, Ref, watch, onUnmounted } from 'vue'
179208
import { Carousel } from 'flowbite';
180209
import { callAdminForthApi } from '@/utils';
181210
import { useI18n } from 'vue-i18n';
182211
import adminforth from '@/adminforth';
183212
import { ProgressBar } from '@/afcl';
184213
import * as Handlebars from 'handlebars';
214+
import { IconCloseOutline } from '@iconify-prerendered/vue-flowbite';
215+
import { Tooltip, Skeleton } from '@/afcl'
216+
import { useRoute } from 'vue-router';
217+
218+
const { t: $t, t } = useI18n();
219+
const route = useRoute();
185220
186-
const { t: $t } = useI18n();
187221
188222
const prompt = ref('');
189223
const emit = defineEmits(['close', 'uploadImage']);
190-
const props = defineProps(['meta', 'record']);
224+
const props = defineProps({
225+
meta: Object,
226+
record: Object,
227+
humanifySize: Function,
228+
});
191229
const images = ref([]);
192230
const loading = ref(false);
193231
const attachmentFiles = ref<string[]>([])
232+
const requestAttachmentFiles = ref<Blob[] | null>([]);
233+
const requestAttachmentFilesUrls = ref<string[]>([]);
194234
const stopGeneration = ref(false);
195-
196-
function minifyField(field: string): string {
197-
if (field.length > 100) {
198-
return field.slice(0, 100) + '...';
199-
}
200-
return field;
201-
}
235+
const fileInput = ref<HTMLInputElement | null>(null);
202236
203237
const caurosel = ref(null);
204238
onMounted(async () => {
205239
// Initialize carousel
206-
const context = {
207-
field: props.meta.pathColumnLabel,
208-
resource: props.meta.resourceLabel,
209-
};
210240
let template = '';
211241
if (props.meta.generationPrompt) {
212242
template = props.meta.generationPrompt;
@@ -230,13 +260,20 @@ onMounted(async () => {
230260
});
231261
232262
if (resp?.files?.length) {
233-
attachmentFiles.value = resp.files;
263+
requestAttachmentFilesUrls.value = resp.files;
234264
}
235265
} catch (err) {
236266
console.error('Failed to fetch attachment files', err);
237267
}
268+
269+
for ( const fileUrl in requestAttachmentFilesUrls.value ) {
270+
const image = await fetch(fileUrl);
271+
const imageBlob = await image.blob();
272+
requestAttachmentFiles.value!.push(imageBlob);
273+
}
238274
});
239275
276+
240277
async function slide(direction: number) {
241278
if (!caurosel.value) return;
242279
const curPos = caurosel.value.getActiveItem().position;
@@ -323,11 +360,13 @@ async function generateImages() {
323360
method: 'POST',
324361
body: {
325362
prompt: prompt.value,
326-
recordId: props.record[props.meta.recorPkFieldName]
363+
recordId: props.record[props.meta.recorPkFieldName],
364+
requestAttachmentFiles: requestAttachmentFilesUrls.value,
327365
},
328366
});
329367
} catch (e) {
330368
console.error(e);
369+
return;
331370
}
332371
333372
if (resp?.error) {
@@ -451,4 +490,129 @@ watch(zoomedImage, async (val) => {
451490
}).show()
452491
}
453492
})
493+
494+
async function handleAddFile(event, clipboardFile = null) {
495+
if (clipboardFile) {
496+
clipboardFile = renameFile(clipboardFile, `pasted_image_${Date.now()}.png`);
497+
}
498+
const files = event?.target?.files || (clipboardFile ? [clipboardFile] : []);
499+
for (let i = 0; i < files.length; i++) {
500+
if (requestAttachmentFiles.value.find((f: any) => f.name === files[i].name)) {
501+
adminforth.alert({
502+
message: $t('File with the same name already added'),
503+
variant: 'warning',
504+
timeout: 5000,
505+
});
506+
continue;
507+
}
508+
const file = files[i];
509+
const fileUrl = await uploadFile(file);
510+
if (!fileUrl) continue;
511+
requestAttachmentFiles.value!.push(file);
512+
requestAttachmentFilesUrls.value.push(fileUrl);
513+
}
514+
fileInput.value!.value = '';
515+
}
516+
517+
function removeFileFromList(index: number) {
518+
requestAttachmentFiles.value!.splice(index, 1);
519+
requestAttachmentFilesUrls.value.splice(index, 1);
520+
}
521+
522+
const uploading = ref(false);
523+
524+
async function uploadFile(file: any): Promise<string> {
525+
const { name, size, type } = file;
526+
527+
let imgPreview = '';
528+
529+
const extension = name.split('.').pop();
530+
const nameNoExtension = name.replace(`.${extension}`, '');
531+
532+
if (props.meta.maxFileSize && size > props.meta.maxFileSize) {
533+
adminforth.alert({
534+
message: t('Sorry but the file size {size} is too large. Please upload a file with a maximum size of {maxFileSize}', {
535+
size: props.humanifySize(size),
536+
maxFileSize: props.humanifySize(props.meta.maxFileSize),
537+
}),
538+
variant: 'danger'
539+
});
540+
return;
541+
}
542+
543+
try {
544+
uploading.value = true;
545+
const { uploadUrl, uploadExtraParams, filePath, error, previewUrl } = await callAdminForthApi({
546+
path: `/plugin/${props.meta.pluginInstanceId}/get_file_upload_url`,
547+
method: 'POST',
548+
body: {
549+
originalFilename: nameNoExtension,
550+
contentType: type,
551+
size,
552+
originalExtension: extension,
553+
recordPk: route?.params?.primaryKey,
554+
},
555+
});
556+
if (error) {
557+
adminforth.alert({
558+
message: t('File was not uploaded because of error: {error}', { error }),
559+
variant: 'danger'
560+
});
561+
imgPreview = null;
562+
return;
563+
}
564+
565+
const xhr = new XMLHttpRequest();
566+
const success = await new Promise((resolve) => {
567+
xhr.addEventListener('loadend', () => {
568+
const success = xhr.readyState === 4 && xhr.status === 200;
569+
// try to read response
570+
resolve(success);
571+
});
572+
xhr.open('PUT', uploadUrl, true);
573+
xhr.setRequestHeader('Content-Type', type);
574+
uploadExtraParams && Object.entries(uploadExtraParams).forEach(([key, value]: [string, string]) => {
575+
xhr.setRequestHeader(key, value);
576+
})
577+
xhr.send(file);
578+
});
579+
580+
if (success) {
581+
imgPreview = previewUrl;
582+
} else {
583+
throw new Error('File upload failed');
584+
}
585+
} catch (err) {
586+
uploading.value = false;
587+
console.error('File upload failed', err);
588+
}
589+
uploading.value = false;
590+
return imgPreview;
591+
}
592+
593+
async function uploadImageOnPaste(event) {
594+
const items = event.clipboardData?.items;
595+
if (!items) return;
596+
597+
for (let item of items) {
598+
if (item.type.startsWith('image/')) {
599+
const file = item.getAsFile();
600+
if (file) {
601+
await handleAddFile(null, file);
602+
}
603+
}
604+
}
605+
}
606+
607+
function renameFile(file, newName) {
608+
return new File([file], newName, { type: file.type });
609+
}
610+
611+
onMounted(() => {
612+
document.addEventListener('paste', uploadImageOnPaste);
613+
});
614+
615+
onUnmounted(() => {
616+
document.removeEventListener('paste', uploadImageOnPaste);
617+
});
454618
</script>

custom/uploader.vue

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
<template>
22
<div class="relative w-full">
33
<ImageGenerator v-if="showImageGen" @close="showImageGen = false" :record="record" :meta="meta"
4-
@uploadImage="uploadGeneratedImage"
4+
@uploadImage="uploadGeneratedImage"
5+
:humanifySize="humanifySize"
56
></ImageGenerator>
67

78
<button v-if="meta.generateImages"

index.ts

Lines changed: 4 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -104,39 +104,14 @@ export default class UploadPlugin extends AdminForthPlugin {
104104
return this.callStorageAdapter('markKeyForDeletion', 'markKeyForDeletation', filePath);
105105
}
106106

107-
private async generateImages(jobId: string, prompt: string, recordId: any, adminUser: any, headers: any) {
107+
private async generateImages(jobId: string, prompt: string,requestAttachmentFiles: string[], recordId: any, adminUser: any, headers: any) {
108108
if (this.options.generation.rateLimit?.limit) {
109-
// rate limit
110-
// const { error } = RateLimiter.checkRateLimit(
111-
// this.pluginInstanceId,
112-
// this.options.generation.rateLimit?.limit,
113-
// this.adminforth.auth.getClientIp(headers),
114-
// );
115109
if (!await this.rateLimiter.consume(`${this.pluginInstanceId}-${this.adminforth.auth.getClientIp(headers)}`)) {
116110
jobs.set(jobId, { status: "failed", error: this.options.generation.rateLimit.errorMessage });
117111
return { error: this.options.generation.rateLimit.errorMessage };
118112
}
119113
}
120-
let attachmentFiles = [];
121-
if (this.options.generation.attachFiles) {
122-
// TODO - does it require additional allowed action to check this record id has access to get the image?
123-
// or should we mention in docs that user should do validation in method itself
124-
const record = await this.adminforth.resource(this.resourceConfig.resourceId).get(
125-
[Filters.EQ(this.resourceConfig.columns.find(c => c.primaryKey)?.name, recordId)]
126-
);
127-
128-
129-
if (!record) {
130-
return { error: `Record with id ${recordId} not found` };
131-
}
132-
133-
attachmentFiles = await this.options.generation.attachFiles({ record, adminUser });
134-
// if files is not array, make it array
135-
if (!Array.isArray(attachmentFiles)) {
136-
attachmentFiles = [attachmentFiles];
137-
}
138-
139-
}
114+
let attachmentFiles = requestAttachmentFiles;
140115

141116
let error: string | undefined = undefined;
142117

@@ -448,12 +423,11 @@ export default class UploadPlugin extends AdminForthPlugin {
448423
method: 'POST',
449424
path: `/plugin/${this.pluginInstanceId}/create-image-generation-job`,
450425
handler: async ({ body, adminUser, headers }) => {
451-
const { prompt, recordId } = body;
452-
426+
const { prompt, recordId, requestAttachmentFiles } = body;
453427
const jobId = randomUUID();
454428
jobs.set(jobId, { status: "in_progress" });
455429

456-
this.generateImages(jobId, prompt, recordId, adminUser, headers);
430+
this.generateImages(jobId, prompt, requestAttachmentFiles, recordId, adminUser, headers);
457431
setTimeout(() => jobs.delete(jobId), 1_800_000);
458432
setTimeout(() => {jobs.set(jobId, { status: "timeout" });}, 300_000);
459433

0 commit comments

Comments
 (0)