mirror of
https://github.com/versia-pub/frontend.git
synced 2025-12-06 16:38:20 +01:00
refactor: ♻️ Optimize file uploader
This commit is contained in:
parent
0ef2112029
commit
7f274e7780
|
|
@ -53,7 +53,7 @@ import RichTextboxInput from "../inputs/rich-textbox-input.vue";
|
|||
import Note from "../social-elements/notes/note.vue";
|
||||
import Button from "./button.vue";
|
||||
// biome-ignore lint/style/useImportType: Biome doesn't see the Vue code
|
||||
import FileUploader from "./file-uploader.vue";
|
||||
import FileUploader, { type FileData } from "./uploader/uploader.vue";
|
||||
|
||||
const uploader = ref<InstanceType<typeof FileUploader> | undefined>(undefined);
|
||||
const { Control_Enter, Command_Enter, Control_Alt } = useMagicKeys();
|
||||
|
|
@ -71,15 +71,7 @@ const openFilePicker = () => {
|
|||
uploader.value?.openFilePicker();
|
||||
};
|
||||
|
||||
const files = ref<
|
||||
{
|
||||
id: string;
|
||||
file: File;
|
||||
progress: number;
|
||||
api_id?: string;
|
||||
alt_text?: string;
|
||||
}[]
|
||||
>([]);
|
||||
const files = ref<FileData[]>([]);
|
||||
|
||||
const handlePaste = (event: ClipboardEvent) => {
|
||||
if (event.clipboardData) {
|
||||
|
|
@ -95,6 +87,7 @@ const handlePaste = (event: ClipboardEvent) => {
|
|||
id: nanoid(),
|
||||
file,
|
||||
progress: 0,
|
||||
uploading: true,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
|
@ -109,11 +102,7 @@ watch(
|
|||
files,
|
||||
(newFiles) => {
|
||||
// If a file is uploading, set loading to true
|
||||
if (newFiles.some((file) => file.progress < 1)) {
|
||||
loading.value = true;
|
||||
} else {
|
||||
loading.value = false;
|
||||
}
|
||||
loading.value = newFiles.some((file) => file.uploading);
|
||||
},
|
||||
{
|
||||
deep: true,
|
||||
|
|
@ -143,6 +132,7 @@ onMounted(() => {
|
|||
id: nanoid(),
|
||||
file: new File([], file.url),
|
||||
progress: 1,
|
||||
uploading: false,
|
||||
api_id: file.id,
|
||||
alt_text: file.description ?? undefined,
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -1,188 +0,0 @@
|
|||
<template>
|
||||
<div>
|
||||
<input type="file" ref="fileInput" @change="handleFileInput" style="display: none" multiple />
|
||||
<div class="flex flex-row gap-2 overflow-x-auto *:shrink-0 p-1 mb-4" v-if="files.length > 0">
|
||||
<div v-for="(data) in files" :key="data.id" role="button" tabindex="0"
|
||||
:class="['size-28 bg-dark-800 rounded flex items-center relative justify-center ring-1 ring-white/20 overflow-hidden', data.progress !== 1.0 && 'animate-pulse']"
|
||||
@keydown.enter="removeFile(data.id)">
|
||||
<template v-if="data.file.type.startsWith('image/')">
|
||||
<img :src="createObjectURL(data.file)" class="w-full h-full object-cover cursor-default"
|
||||
alt="Preview of file" />
|
||||
</template>
|
||||
<template v-else-if="data.file.type.startsWith('video/')">
|
||||
<video :src="createObjectURL(data.file)" class="w-full h-full object-cover cursor-default"></video>
|
||||
</template>
|
||||
<template v-else>
|
||||
<iconify-icon :icon="getIcon(data.file.type)" width="none" class="size-6" />
|
||||
</template>
|
||||
<!-- Shadow on media to better see buttons -->
|
||||
<div class="absolute inset-0 bg-black/70"></div>
|
||||
<div class="absolute bottom-1 right-1 p-1 bg-dark-800 text-white text-xs rounded cursor-default flex flex-row items-center gap-x-1"
|
||||
aria-label="File size">
|
||||
{{ formatBytes(data.file.size) }}
|
||||
<!-- Loader spinner -->
|
||||
<iconify-icon v-if="data.progress < 1.0" icon="tabler:loader-2" width="none"
|
||||
class="size-4 animate-spin text-primary-500" />
|
||||
</div>
|
||||
<button class="absolute top-1 right-1 p-1 bg-dark-800 text-white text-xs rounded size-6" role="button"
|
||||
tabindex="0" @pointerup="removeFile(data.id)" @keydown.enter="removeFile(data.id)">
|
||||
<iconify-icon icon="tabler:x" width="none" class="size-4" />
|
||||
</button>
|
||||
<!-- Alt text editor -->
|
||||
<Popover.Root :positioning="{
|
||||
strategy: 'fixed',
|
||||
}" v-if="data.api_id" @update:open="o => !o && updateAltText(data.id, data.alt_text)">
|
||||
<Popover.Trigger aria-hidden="true"
|
||||
class="absolute top-1 left-1 p-1 bg-dark-800 ring-1 ring-white/5 text-white text-xs rounded size-6">
|
||||
<iconify-icon icon="tabler:alt" width="none" class="size-4" />
|
||||
</Popover.Trigger>
|
||||
<Popover.Positioner class="!z-[100]">
|
||||
<Popover.Content
|
||||
class="p-1 bg-dark-400 rounded text-sm ring-1 ring-white/10 shadow-lg text-gray-300 !min-w-72">
|
||||
<textarea :disabled="data.progress < 1.0" @keydown.enter.stop v-model="data.alt_text"
|
||||
placeholder="Add alt text"
|
||||
class="w-full p-2 text-sm prose prose-invert bg-dark-900 rounded focus:!ring-0 !ring-none !border-none !outline-none placeholder:text-zinc-500 appearance-none focus:!border-none focus:!outline-none" />
|
||||
<Button theme="secondary" @click="updateAltText(data.id, data.alt_text)" class="w-full"
|
||||
:loading="data.progress < 1.0">
|
||||
<span>Edit</span>
|
||||
</Button>
|
||||
</Popover.Content>
|
||||
</Popover.Positioner>
|
||||
</Popover.Root>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Popover } from "@ark-ui/vue";
|
||||
import { nanoid } from "nanoid";
|
||||
import Button from "~/packages/ui/components/buttons/button.vue";
|
||||
|
||||
const files = defineModel<
|
||||
{
|
||||
id: string;
|
||||
file: File;
|
||||
// 0.0 -> Not uploading
|
||||
// 0.5 -> Uploading
|
||||
// 1.0 -> Uploaded
|
||||
progress: number;
|
||||
api_id?: string;
|
||||
alt_text?: string;
|
||||
}[]
|
||||
>("files", {
|
||||
required: true,
|
||||
});
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const openFilePicker = () => {
|
||||
fileInput.value?.click();
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
openFilePicker,
|
||||
});
|
||||
|
||||
const createObjectURL = URL.createObjectURL;
|
||||
|
||||
const handleFileInput = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
if (target.files) {
|
||||
files.value.push(
|
||||
...Array.from(target.files).map((file) => ({
|
||||
id: nanoid(),
|
||||
file,
|
||||
progress: 0,
|
||||
})),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Upload new files (not existing, currently being uploaded files)
|
||||
watch(
|
||||
files,
|
||||
(newFiles) => {
|
||||
for (const data of newFiles) {
|
||||
if (data.progress === 0) {
|
||||
uploadFile(data.file);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
deep: true,
|
||||
},
|
||||
);
|
||||
|
||||
const updateAltText = (id: string, altText?: string) => {
|
||||
// Set loading
|
||||
files.value = files.value.map((data) => {
|
||||
if (data.id === id) {
|
||||
return { ...data, progress: 0.5 };
|
||||
}
|
||||
return data;
|
||||
});
|
||||
|
||||
client.value
|
||||
?.updateMedia(
|
||||
files.value.find((data) => data.id === id)?.api_id as string,
|
||||
{ description: altText },
|
||||
)
|
||||
.then(() => {
|
||||
files.value = files.value.map((data) => {
|
||||
if (data.id === id) {
|
||||
return { ...data, progress: 1.0 };
|
||||
}
|
||||
return data;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const getIcon = (mimeType: string) => {
|
||||
if (mimeType.startsWith("image/")) {
|
||||
return "tabler:photo";
|
||||
}
|
||||
if (mimeType.startsWith("video/")) {
|
||||
return "tabler:video";
|
||||
}
|
||||
if (mimeType.startsWith("audio/")) {
|
||||
return "tabler:music";
|
||||
}
|
||||
return "tabler:file";
|
||||
};
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0) {
|
||||
return "0 Bytes";
|
||||
}
|
||||
const k = 1000;
|
||||
const dm = 2;
|
||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${Number.parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
const removeFile = (id: string) => {
|
||||
files.value = files.value.filter((data) => data.id !== id);
|
||||
};
|
||||
|
||||
const uploadFile = async (file: File) => {
|
||||
files.value = files.value.map((data) => {
|
||||
if (data.file === file) {
|
||||
return { ...data, progress: 0.5 };
|
||||
}
|
||||
return data;
|
||||
});
|
||||
|
||||
client.value.uploadMedia(file).then((response) => {
|
||||
const attachment = response.data;
|
||||
|
||||
files.value = files.value.map((data) => {
|
||||
if (data.file === file) {
|
||||
return { ...data, api_id: attachment.id, progress: 1.0 };
|
||||
}
|
||||
return data;
|
||||
});
|
||||
});
|
||||
};
|
||||
</script>
|
||||
36
components/composer/uploader/alt-text-editor.vue
Normal file
36
components/composer/uploader/alt-text-editor.vue
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<template>
|
||||
<Popover.Root :positioning="{
|
||||
strategy: 'fixed',
|
||||
}" @update:open="o => !o && $emit('update-alt-text', fileData.alt_text)">
|
||||
<Popover.Trigger aria-hidden="true"
|
||||
class="absolute top-1 left-1 p-1 bg-dark-800 ring-1 ring-white/5 text-white text-xs rounded size-6">
|
||||
<iconify-icon icon="tabler:alt" width="none" class="size-4" />
|
||||
</Popover.Trigger>
|
||||
<Popover.Positioner class="!z-[100]">
|
||||
<Popover.Content
|
||||
class="p-1 bg-dark-400 rounded text-sm ring-1 ring-white/10 shadow-lg text-gray-300 !min-w-72">
|
||||
<textarea :disabled="fileData.uploading" @keydown.enter.stop v-model="fileData.alt_text"
|
||||
placeholder="Add alt text"
|
||||
class="w-full p-2 text-sm prose prose-invert bg-dark-900 rounded focus:!ring-0 !ring-none !border-none !outline-none placeholder:text-zinc-500 appearance-none focus:!border-none focus:!outline-none" />
|
||||
<Button theme="secondary" @click="$emit('update-alt-text', fileData.alt_text)" class="w-full"
|
||||
:loading="fileData.uploading">
|
||||
<span>Edit</span>
|
||||
</Button>
|
||||
</Popover.Content>
|
||||
</Popover.Positioner>
|
||||
</Popover.Root>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Popover } from "@ark-ui/vue";
|
||||
import Button from "~/packages/ui/components/buttons/button.vue";
|
||||
import type { FileData } from "./uploader.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
fileData: FileData;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
"update-alt-text": [text?: string];
|
||||
}>();
|
||||
</script>
|
||||
31
components/composer/uploader/file-preview.vue
Normal file
31
components/composer/uploader/file-preview.vue
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<template>
|
||||
<div role="button" tabindex="0" :class="[
|
||||
'size-28 bg-dark-800 rounded flex items-center relative justify-center ring-1 ring-white/20 overflow-hidden',
|
||||
fileData.uploading && 'animate-pulse'
|
||||
]" @keydown.enter="$emit('remove', fileData.id)">
|
||||
<PreviewContent :file="fileData.file" />
|
||||
<FileShadowOverlay />
|
||||
<FileSize :size="fileData.file.size" :uploading="fileData.uploading" />
|
||||
<RemoveButton @remove="$emit('remove', fileData.id)" />
|
||||
<AltTextEditor v-if="fileData.api_id" :file-data="fileData"
|
||||
@update-alt-text="(text) => $emit('update-alt-text', fileData.id, text)" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import AltTextEditor from "./alt-text-editor.vue";
|
||||
import FileShadowOverlay from "./file-shadow-overlay.vue";
|
||||
import FileSize from "./file-size.vue";
|
||||
import PreviewContent from "./preview-content.vue";
|
||||
import RemoveButton from "./remove-button.vue";
|
||||
import type { FileData } from "./uploader.vue";
|
||||
|
||||
defineProps<{
|
||||
fileData: FileData;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
remove: [id: string];
|
||||
"update-alt-text": [id: string, text?: string];
|
||||
}>();
|
||||
</script>
|
||||
3
components/composer/uploader/file-shadow-overlay.vue
Normal file
3
components/composer/uploader/file-shadow-overlay.vue
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<template>
|
||||
<div class="absolute inset-0 bg-black/70"></div>
|
||||
</template>
|
||||
26
components/composer/uploader/file-size.vue
Normal file
26
components/composer/uploader/file-size.vue
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<template>
|
||||
<div class="absolute bottom-1 right-1 p-1 bg-dark-800 text-white text-xs rounded cursor-default flex flex-row items-center gap-x-1"
|
||||
aria-label="File size">
|
||||
{{ formatBytes(size) }}
|
||||
<iconify-icon v-if="uploading" icon="tabler:loader-2" width="none"
|
||||
class="size-4 animate-spin text-primary-500" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const props = defineProps<{
|
||||
size: number;
|
||||
uploading: boolean;
|
||||
}>();
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0) {
|
||||
return "0 Bytes";
|
||||
}
|
||||
const k = 1000;
|
||||
const dm = 2;
|
||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${Number.parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`;
|
||||
};
|
||||
</script>
|
||||
32
components/composer/uploader/preview-content.vue
Normal file
32
components/composer/uploader/preview-content.vue
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<template>
|
||||
<template v-if="file.type.startsWith('image/')">
|
||||
<img :src="createObjectURL(file)" class="w-full h-full object-cover cursor-default" alt="Preview of file" />
|
||||
</template>
|
||||
<template v-else-if="file.type.startsWith('video/')">
|
||||
<video :src="createObjectURL(file)" class="w-full h-full object-cover cursor-default" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<iconify-icon :icon="getIcon(file.type)" width="none" class="size-6" />
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const props = defineProps<{
|
||||
file: File;
|
||||
}>();
|
||||
|
||||
const createObjectURL = URL.createObjectURL;
|
||||
|
||||
const getIcon = (mimeType: string) => {
|
||||
if (mimeType.startsWith("image/")) {
|
||||
return "tabler:photo";
|
||||
}
|
||||
if (mimeType.startsWith("video/")) {
|
||||
return "tabler:video";
|
||||
}
|
||||
if (mimeType.startsWith("audio/")) {
|
||||
return "tabler:music";
|
||||
}
|
||||
return "tabler:file";
|
||||
};
|
||||
</script>
|
||||
12
components/composer/uploader/remove-button.vue
Normal file
12
components/composer/uploader/remove-button.vue
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<template>
|
||||
<button class="absolute top-1 right-1 p-1 bg-dark-800 text-white text-xs rounded size-6" role="button" tabindex="0"
|
||||
@pointerup="$emit('remove')" @keydown.enter="$emit('remove')">
|
||||
<iconify-icon icon="tabler:x" width="none" class="size-4" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineEmits<{
|
||||
remove: [];
|
||||
}>();
|
||||
</script>
|
||||
118
components/composer/uploader/uploader.vue
Normal file
118
components/composer/uploader/uploader.vue
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
<template>
|
||||
<div>
|
||||
<input type="file" ref="fileInput" @change="handleFileInput" style="display: none" multiple />
|
||||
<div class="flex flex-row gap-2 overflow-x-auto *:shrink-0 p-1 mb-4" v-if="files.length > 0">
|
||||
<FilePreview v-for="data in files" :key="data.id" :file-data="data" @remove="removeFile"
|
||||
@update-alt-text="updateAltText" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { nanoid } from "nanoid";
|
||||
import FilePreview from "./file-preview.vue";
|
||||
|
||||
const files = defineModel<FileData[]>("files", {
|
||||
required: true,
|
||||
});
|
||||
|
||||
export interface FileData {
|
||||
id: string;
|
||||
file: File;
|
||||
uploading: boolean;
|
||||
progress: number;
|
||||
api_id?: string;
|
||||
alt_text?: string;
|
||||
}
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const openFilePicker = () => {
|
||||
fileInput.value?.click();
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
openFilePicker,
|
||||
});
|
||||
|
||||
const handleFileInput = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
if (target.files) {
|
||||
files.value.push(
|
||||
...Array.from(target.files).map((file) => ({
|
||||
id: nanoid(),
|
||||
file,
|
||||
progress: 0,
|
||||
uploading: true,
|
||||
})),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Upload new files (not existing, currently being uploaded files)
|
||||
watch(
|
||||
files,
|
||||
(newFiles) => {
|
||||
for (const data of newFiles) {
|
||||
if (data.progress === 0) {
|
||||
uploadFile(data.file);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
deep: true,
|
||||
},
|
||||
);
|
||||
|
||||
const removeFile = (id: string) => {
|
||||
files.value = files.value.filter((data) => data.id !== id);
|
||||
};
|
||||
|
||||
const updateAltText = (id: string, altText?: string) => {
|
||||
files.value = files.value.map((data) => {
|
||||
if (data.id === id) {
|
||||
return { ...data, uploading: true };
|
||||
}
|
||||
return data;
|
||||
});
|
||||
|
||||
client.value
|
||||
?.updateMedia(
|
||||
files.value.find((data) => data.id === id)?.api_id as string,
|
||||
{ description: altText },
|
||||
)
|
||||
.then(() => {
|
||||
files.value = files.value.map((data) => {
|
||||
if (data.id === id) {
|
||||
return { ...data, uploading: false };
|
||||
}
|
||||
return data;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const uploadFile = async (file: File) => {
|
||||
files.value = files.value.map((data) => {
|
||||
if (data.file === file) {
|
||||
return { ...data, uploading: true, progress: 0.1 };
|
||||
}
|
||||
return data;
|
||||
});
|
||||
|
||||
client.value.uploadMedia(file).then((response) => {
|
||||
const attachment = response.data;
|
||||
|
||||
files.value = files.value.map((data) => {
|
||||
if (data.file === file) {
|
||||
return {
|
||||
...data,
|
||||
api_id: attachment.id,
|
||||
uploading: false,
|
||||
progress: 1.0,
|
||||
};
|
||||
}
|
||||
return data;
|
||||
});
|
||||
});
|
||||
};
|
||||
</script>
|
||||
Loading…
Reference in a new issue