refactor: ♻️ Optimize file uploader

This commit is contained in:
Jesse Wierzbinski 2024-11-01 00:24:03 +01:00
parent 0ef2112029
commit 7f274e7780
No known key found for this signature in database
9 changed files with 263 additions and 203 deletions

View file

@ -53,7 +53,7 @@ import RichTextboxInput from "../inputs/rich-textbox-input.vue";
import Note from "../social-elements/notes/note.vue"; import Note from "../social-elements/notes/note.vue";
import Button from "./button.vue"; import Button from "./button.vue";
// biome-ignore lint/style/useImportType: Biome doesn't see the Vue code // 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 uploader = ref<InstanceType<typeof FileUploader> | undefined>(undefined);
const { Control_Enter, Command_Enter, Control_Alt } = useMagicKeys(); const { Control_Enter, Command_Enter, Control_Alt } = useMagicKeys();
@ -71,15 +71,7 @@ const openFilePicker = () => {
uploader.value?.openFilePicker(); uploader.value?.openFilePicker();
}; };
const files = ref< const files = ref<FileData[]>([]);
{
id: string;
file: File;
progress: number;
api_id?: string;
alt_text?: string;
}[]
>([]);
const handlePaste = (event: ClipboardEvent) => { const handlePaste = (event: ClipboardEvent) => {
if (event.clipboardData) { if (event.clipboardData) {
@ -95,6 +87,7 @@ const handlePaste = (event: ClipboardEvent) => {
id: nanoid(), id: nanoid(),
file, file,
progress: 0, progress: 0,
uploading: true,
})), })),
); );
} }
@ -109,11 +102,7 @@ watch(
files, files,
(newFiles) => { (newFiles) => {
// If a file is uploading, set loading to true // If a file is uploading, set loading to true
if (newFiles.some((file) => file.progress < 1)) { loading.value = newFiles.some((file) => file.uploading);
loading.value = true;
} else {
loading.value = false;
}
}, },
{ {
deep: true, deep: true,
@ -143,6 +132,7 @@ onMounted(() => {
id: nanoid(), id: nanoid(),
file: new File([], file.url), file: new File([], file.url),
progress: 1, progress: 1,
uploading: false,
api_id: file.id, api_id: file.id,
alt_text: file.description ?? undefined, alt_text: file.description ?? undefined,
})); }));

View file

@ -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>

View 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>

View 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>

View file

@ -0,0 +1,3 @@
<template>
<div class="absolute inset-0 bg-black/70"></div>
</template>

View 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>

View 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>

View 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>

View 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>