mirror of
https://github.com/versia-pub/frontend.git
synced 2026-03-13 03:29:16 +01:00
feat: ✨ Add file uploads to composer
This commit is contained in:
parent
294740c97f
commit
027847aa03
27 changed files with 718 additions and 31 deletions
|
|
@ -6,6 +6,11 @@
|
|||
class="!border-none !ring-0 !outline-none rounded-none p-0 max-h-full min-h-48 !ring-offset-0"
|
||||
:disabled="sending" />
|
||||
|
||||
<div class="w-full flex flex-row gap-2 overflow-x-auto *:shrink-0 pb-2">
|
||||
<input type="file" ref="fileInput" @change="uploadFileFromEvent" class="hidden" multiple />
|
||||
<Files v-model:files="state.files" />
|
||||
</div>
|
||||
|
||||
<DialogFooter class="items-center flex-row">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
|
|
@ -65,7 +70,7 @@
|
|||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as="div">
|
||||
<Button variant="ghost" size="icon">
|
||||
<Button variant="ghost" size="icon" @click="fileInput?.click()">
|
||||
<FilePlus2 class="!size-5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
|
|
@ -114,8 +119,10 @@ import { Button } from "../ui/button";
|
|||
import { Input } from "../ui/input";
|
||||
import { Textarea } from "../ui/textarea";
|
||||
import { Toggle } from "../ui/toggle";
|
||||
import Files from "./files.vue";
|
||||
|
||||
const { Control_Enter, Command_Enter } = useMagicKeys();
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
watch([Control_Enter, Command_Enter], () => {
|
||||
if (sending.value) {
|
||||
|
|
@ -141,7 +148,7 @@ const state = reactive({
|
|||
files: [] as {
|
||||
apiId?: string;
|
||||
file: File;
|
||||
alt: string;
|
||||
alt?: string;
|
||||
uploading: boolean;
|
||||
updating: boolean;
|
||||
}[],
|
||||
|
|
@ -191,6 +198,47 @@ const submit = async () => {
|
|||
}
|
||||
};
|
||||
|
||||
const uploadFileFromEvent = (e: Event) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
const files = Array.from(target.files ?? []);
|
||||
|
||||
uploadFiles(files);
|
||||
|
||||
target.value = "";
|
||||
};
|
||||
|
||||
const uploadFiles = (files: File[]) => {
|
||||
for (const file of files) {
|
||||
state.files.push({
|
||||
file,
|
||||
uploading: true,
|
||||
updating: false,
|
||||
});
|
||||
|
||||
client.value
|
||||
.uploadMedia(file)
|
||||
.then((media) => {
|
||||
const index = state.files.findIndex((f) => f.file === file);
|
||||
|
||||
if (!state.files[index]) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.files[index].apiId = media.data.id;
|
||||
state.files[index].uploading = false;
|
||||
})
|
||||
.catch(() => {
|
||||
const index = state.files.findIndex((f) => f.file === file);
|
||||
|
||||
if (!state.files[index]) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.files.splice(index, 1);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const visibilities = {
|
||||
public: {
|
||||
icon: Globe,
|
||||
|
|
|
|||
126
components/composer/file-preview.vue
Normal file
126
components/composer/file-preview.vue
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
<template>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as="button"
|
||||
:disabled="file.uploading || file.updating"
|
||||
class="block bg-card text-card-foreground shadow-sm h-28 overflow-hidden rounded relative min-w-28 *:disabled:opacity-50">
|
||||
<Avatar class="h-28 w-full" shape="square">
|
||||
<AvatarImage class="!object-contain" :src="createObjectURL(file.file)" />
|
||||
</Avatar>
|
||||
<Badge v-if="!file.uploading && !file.updating" class="absolute bottom-1 right-1" variant="default">{{ formatBytes(file.file.size) }}</Badge>
|
||||
<Badge v-else class="absolute bottom-1 right-1 rounded px-1 !opacity-100" variant="default"><Loader class="animate-spin size-4" /></Badge>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent class="min-w-48">
|
||||
<DropdownMenuLabel>{{ file.file.name }}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem @click="editName">
|
||||
<TextCursorInput class="mr-2 h-4 w-4" />
|
||||
<span>Rename</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="editCaption">
|
||||
<Captions class="mr-2 h-4 w-4" />
|
||||
<span>Add caption</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem @click="emit('remove')">
|
||||
<Delete class="mr-2 h-4 w-4" />
|
||||
<span>Remove</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Captions, Delete, Loader, TextCursorInput } from "lucide-vue-next";
|
||||
import { confirmModalService } from "~/components/modals/composable.ts";
|
||||
import { Avatar, AvatarImage } from "~/components/ui/avatar";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
|
||||
const file = defineModel<{
|
||||
apiId?: string;
|
||||
file: File;
|
||||
alt?: string;
|
||||
uploading: boolean;
|
||||
updating: boolean;
|
||||
}>("file", {
|
||||
required: true,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
remove: [];
|
||||
}>();
|
||||
|
||||
const editName = async () => {
|
||||
const result = await confirmModalService.confirm({
|
||||
title: "Enter a new name",
|
||||
defaultValue: file.value.file.name,
|
||||
confirmText: "Edit",
|
||||
inputType: "text",
|
||||
});
|
||||
|
||||
if (result.confirmed) {
|
||||
file.value.updating = true;
|
||||
file.value.file = new File(
|
||||
[file.value.file],
|
||||
result.value ?? file.value.file.name,
|
||||
{
|
||||
type: file.value.file.type,
|
||||
lastModified: file.value.file.lastModified,
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
await client.value.updateMedia(file.value.apiId ?? "", {
|
||||
file: file.value.file,
|
||||
});
|
||||
} finally {
|
||||
file.value.updating = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const editCaption = async () => {
|
||||
const result = await confirmModalService.confirm({
|
||||
title: "Enter a caption",
|
||||
message:
|
||||
"Captions are useful for people with visual impairments, or when the image can't be displayed.",
|
||||
defaultValue: file.value.alt,
|
||||
confirmText: "Add",
|
||||
inputType: "textarea",
|
||||
});
|
||||
|
||||
if (result.confirmed) {
|
||||
file.value.updating = true;
|
||||
file.value.alt = result.value;
|
||||
|
||||
try {
|
||||
await client.value.updateMedia(file.value.apiId ?? "", {
|
||||
description: file.value.alt,
|
||||
});
|
||||
} finally {
|
||||
file.value.updating = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const createObjectURL = URL.createObjectURL;
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0) {
|
||||
return "0 Bytes";
|
||||
}
|
||||
const k = 1000;
|
||||
const digitsAfterPoint = 2;
|
||||
const sizes = ["B", "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(digitsAfterPoint))} ${sizes[i]}`;
|
||||
};
|
||||
</script>
|
||||
19
components/composer/files.vue
Normal file
19
components/composer/files.vue
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<template>
|
||||
<FilePreview v-for="(file, index) in files" :key="file.apiId" :file="file" @update:file="files[index] = $event" @remove="files.splice(index, 1)" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import FilePreview from "./file-preview.vue";
|
||||
|
||||
defineModel<
|
||||
{
|
||||
apiId?: string;
|
||||
file: File;
|
||||
alt?: string;
|
||||
uploading: boolean;
|
||||
updating: boolean;
|
||||
}[]
|
||||
>("files", {
|
||||
required: true,
|
||||
});
|
||||
</script>
|
||||
Loading…
Add table
Add a link
Reference in a new issue