feat: Add file uploads to composer

This commit is contained in:
Jesse Wierzbinski 2024-12-01 17:20:21 +01:00
parent 294740c97f
commit 027847aa03
No known key found for this signature in database
27 changed files with 718 additions and 31 deletions

View file

@ -21,7 +21,7 @@ import "~/styles/theme.css";
import "~/styles/index.css"; import "~/styles/index.css";
import { convert } from "html-to-text"; import { convert } from "html-to-text";
import "iconify-icon"; import "iconify-icon";
import ConfirmationModal from "./components/modals/confirmation.vue"; import ConfirmationModal from "./components/modals/confirm.vue";
import NotificationsRenderer from "./components/notifications/notifications-renderer.vue"; import NotificationsRenderer from "./components/notifications/notifications-renderer.vue";
import { Toaster } from "./components/ui/sonner"; import { Toaster } from "./components/ui/sonner";
import { SettingIds } from "./settings"; import { SettingIds } from "./settings";

View file

@ -6,6 +6,11 @@
class="!border-none !ring-0 !outline-none rounded-none p-0 max-h-full min-h-48 !ring-offset-0" class="!border-none !ring-0 !outline-none rounded-none p-0 max-h-full min-h-48 !ring-offset-0"
:disabled="sending" /> :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"> <DialogFooter class="items-center flex-row">
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
@ -65,7 +70,7 @@
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger as="div"> <TooltipTrigger as="div">
<Button variant="ghost" size="icon"> <Button variant="ghost" size="icon" @click="fileInput?.click()">
<FilePlus2 class="!size-5" /> <FilePlus2 class="!size-5" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
@ -114,8 +119,10 @@ import { Button } from "../ui/button";
import { Input } from "../ui/input"; import { Input } from "../ui/input";
import { Textarea } from "../ui/textarea"; import { Textarea } from "../ui/textarea";
import { Toggle } from "../ui/toggle"; import { Toggle } from "../ui/toggle";
import Files from "./files.vue";
const { Control_Enter, Command_Enter } = useMagicKeys(); const { Control_Enter, Command_Enter } = useMagicKeys();
const fileInput = ref<HTMLInputElement | null>(null);
watch([Control_Enter, Command_Enter], () => { watch([Control_Enter, Command_Enter], () => {
if (sending.value) { if (sending.value) {
@ -141,7 +148,7 @@ const state = reactive({
files: [] as { files: [] as {
apiId?: string; apiId?: string;
file: File; file: File;
alt: string; alt?: string;
uploading: boolean; uploading: boolean;
updating: 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 = { const visibilities = {
public: { public: {
icon: Globe, icon: Globe,

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

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

View file

@ -0,0 +1,25 @@
import {
confirmModalService,
confirmModalWithInputService,
} from "./service.ts";
import type { ConfirmModalOptions, ConfirmModalResult } from "./types.ts";
export function useConfirmModal() {
const confirm = (
options: ConfirmModalOptions,
): Promise<ConfirmModalResult> => {
return confirmModalService.confirm(options);
};
const confirmWithInput = (
options: ConfirmModalOptions,
placeholder?: string,
): Promise<ConfirmModalResult> => {
return confirmModalWithInputService.confirm(options, placeholder);
};
return {
confirm,
confirmWithInput,
};
}

View file

@ -1,25 +1,34 @@
import { export type ConfirmModalOptions = {
confirmModalService, title?: string;
confirmModalWithInputService, message?: string;
} from "./service.ts"; confirmText?: string;
import type { ConfirmModalOptions, ConfirmModalResult } from "./types.ts"; cancelText?: string;
inputType?: "none" | "text" | "textarea";
defaultValue?: string;
};
export function useConfirmModal() { export type ConfirmModalResult = {
const confirm = ( confirmed: boolean;
options: ConfirmModalOptions, value?: string;
): Promise<ConfirmModalResult> => { };
return confirmModalService.confirm(options);
};
const confirmWithInput = ( class ConfirmModalService {
options: ConfirmModalOptions, private modalRef = ref<{
placeholder?: string, open: (options: ConfirmModalOptions) => Promise<ConfirmModalResult>;
): Promise<ConfirmModalResult> => { } | null>(null);
return confirmModalWithInputService.confirm(options, placeholder);
};
return { register(modal: {
confirm, open: (options: ConfirmModalOptions) => Promise<ConfirmModalResult>;
confirmWithInput, }) {
}; this.modalRef.value = modal;
}
confirm(options: ConfirmModalOptions): Promise<ConfirmModalResult> {
if (!this.modalRef.value) {
throw new Error("Confirmation modal not initialized");
}
return this.modalRef.value.open(options);
}
} }
export const confirmModalService = new ConfirmModalService();

View file

@ -0,0 +1,69 @@
<script setup lang="ts">
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import type { ConfirmModalOptions, ConfirmModalResult } from "./composable.ts";
defineProps<{
modalOptions: ConfirmModalOptions;
}>();
defineEmits<{
confirm: (result: ConfirmModalResult) => void;
cancel: () => void;
}>();
const inputValue = ref<string>("");
</script>
<template>
<Dialog>
<DialogTrigger :as-child="true">
<slot />
</DialogTrigger>
<DialogContent class="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{{ modalOptions.title }}</DialogTitle>
<DialogDescription>
{{ modalOptions.message }}
</DialogDescription>
</DialogHeader>
<div v-if="modalOptions.inputType === 'text'" class="grid gap-4 py-4">
<div class="grid grid-cols-4 items-center gap-4">
<Label for="confirmInput" class="text-right">Value</Label>
<Input id="confirmInput" v-model="inputValue" class="col-span-3" />
</div>
</div>
<div v-else-if="modalOptions.inputType === 'textarea'" class="grid gap-4 py-4">
<div class="grid grid-cols-4 items-center gap-4">
<Label for="confirmTextarea" class="text-right">Value</Label>
<Textarea id="confirmTextarea" v-model="inputValue" class="col-span-3" />
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="() => $emit('cancel')">
{{ modalOptions.cancelText }}
</Button>
<Button @click="() => $emit('confirm', {
confirmed: true,
value: inputValue,
})">
{{ modalOptions.confirmText }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>

View file

@ -0,0 +1,102 @@
<script setup lang="ts">
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { ref } from "vue";
import {
type ConfirmModalOptions,
type ConfirmModalResult,
confirmModalService,
} from "./composable.ts";
const isOpen = ref(false);
const modalOptions = ref<ConfirmModalOptions>({
title: "Confirm",
message: "",
inputType: "none",
confirmText: "Confirm",
cancelText: "Cancel",
});
const inputValue = ref("");
const resolvePromise = ref<((result: ConfirmModalResult) => void) | null>(null);
function open(options: ConfirmModalOptions): Promise<ConfirmModalResult> {
isOpen.value = true;
modalOptions.value = {
title: options.title || "Confirm",
message: options.message,
inputType: options.inputType || "none",
confirmText: options.confirmText || "Confirm",
cancelText: options.cancelText || "Cancel",
};
inputValue.value = options.defaultValue || "";
return new Promise((resolve) => {
resolvePromise.value = resolve;
});
}
function handleConfirm() {
if (resolvePromise.value) {
resolvePromise.value({
confirmed: true,
value: inputValue.value,
});
}
isOpen.value = false;
}
function handleCancel() {
if (resolvePromise.value) {
resolvePromise.value({
confirmed: false,
});
}
isOpen.value = false;
}
confirmModalService.register({
open,
});
</script>
<template>
<AlertDialog :key="String(isOpen)" :open="isOpen" @update:open="isOpen = false">
<AlertDialogContent class="sm:max-w-[425px] flex flex-col">
<AlertDialogHeader>
<AlertDialogTitle>{{ modalOptions.title }}</AlertDialogTitle>
<AlertDialogDescription v-if="modalOptions.message">
{{ modalOptions.message }}
</AlertDialogDescription>
</AlertDialogHeader>
<Input v-if="modalOptions.inputType === 'text'" v-model="inputValue" />
<Textarea v-else-if="modalOptions.inputType === 'textarea'" v-model="inputValue" rows="6" />
<AlertDialogFooter class="w-full">
<AlertDialogCancel :as-child="true">
<Button variant="outline" @click="handleCancel">
{{ modalOptions.cancelText }}
</Button>
</AlertDialogCancel>
<AlertDialogAction :as-child="true">
<Button @click="handleConfirm">
{{ modalOptions.confirmText }}
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</template>

View file

@ -0,0 +1,19 @@
<script setup lang="ts">
import {
type AlertDialogEmits,
type AlertDialogProps,
AlertDialogRoot,
useForwardPropsEmits,
} from "radix-vue";
const props = defineProps<AlertDialogProps>();
const emits = defineEmits<AlertDialogEmits>();
const forwarded = useForwardPropsEmits(props, emits);
</script>
<template>
<AlertDialogRoot v-bind="forwarded">
<slot />
</AlertDialogRoot>
</template>

View file

@ -0,0 +1,22 @@
<script setup lang="ts">
import { cn } from "@/lib/utils";
import { AlertDialogAction, type AlertDialogActionProps } from "radix-vue";
import { type HTMLAttributes, computed } from "vue";
import { buttonVariants } from "~/components/ui/button";
const props = defineProps<
AlertDialogActionProps & { class?: HTMLAttributes["class"] }
>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
</script>
<template>
<AlertDialogAction v-bind="delegatedProps" :class="cn(buttonVariants(), props.class)">
<slot />
</AlertDialogAction>
</template>

View file

@ -0,0 +1,29 @@
<script setup lang="ts">
import { cn } from "@/lib/utils";
import { AlertDialogCancel, type AlertDialogCancelProps } from "radix-vue";
import { type HTMLAttributes, computed } from "vue";
import { buttonVariants } from "~/components/ui/button";
const props = defineProps<
AlertDialogCancelProps & { class?: HTMLAttributes["class"] }
>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
</script>
<template>
<AlertDialogCancel
v-bind="delegatedProps"
:class="cn(
buttonVariants({ variant: 'outline' }),
'mt-2 sm:mt-0',
props.class,
)"
>
<slot />
</AlertDialogCancel>
</template>

View file

@ -0,0 +1,44 @@
<script setup lang="ts">
import { cn } from "@/lib/utils";
import {
AlertDialogContent,
type AlertDialogContentEmits,
type AlertDialogContentProps,
AlertDialogOverlay,
AlertDialogPortal,
useForwardPropsEmits,
} from "radix-vue";
import { type HTMLAttributes, computed } from "vue";
const props = defineProps<
AlertDialogContentProps & { class?: HTMLAttributes["class"] }
>();
const emits = defineEmits<AlertDialogContentEmits>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<AlertDialogPortal>
<AlertDialogOverlay
class="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
/>
<AlertDialogContent
v-bind="forwarded"
:class="
cn(
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
props.class,
)
"
>
<slot />
</AlertDialogContent>
</AlertDialogPortal>
</template>

View file

@ -0,0 +1,27 @@
<script setup lang="ts">
import { cn } from "@/lib/utils";
import {
AlertDialogDescription,
type AlertDialogDescriptionProps,
} from "radix-vue";
import { type HTMLAttributes, computed } from "vue";
const props = defineProps<
AlertDialogDescriptionProps & { class?: HTMLAttributes["class"] }
>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
</script>
<template>
<AlertDialogDescription
v-bind="delegatedProps"
:class="cn('text-sm text-muted-foreground', props.class)"
>
<slot />
</AlertDialogDescription>
</template>

View file

@ -0,0 +1,21 @@
<script setup lang="ts">
import { cn } from "@/lib/utils";
import type { HTMLAttributes } from "vue";
const props = defineProps<{
class?: HTMLAttributes["class"];
}>();
</script>
<template>
<div
:class="
cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2',
props.class,
)
"
>
<slot />
</div>
</template>

View file

@ -0,0 +1,16 @@
<script setup lang="ts">
import { cn } from "@/lib/utils";
import type { HTMLAttributes } from "vue";
const props = defineProps<{
class?: HTMLAttributes["class"];
}>();
</script>
<template>
<div
:class="cn('flex flex-col gap-y-2 text-center sm:text-left', props.class)"
>
<slot />
</div>
</template>

View file

@ -0,0 +1,24 @@
<script setup lang="ts">
import { cn } from "@/lib/utils";
import { AlertDialogTitle, type AlertDialogTitleProps } from "radix-vue";
import { type HTMLAttributes, computed } from "vue";
const props = defineProps<
AlertDialogTitleProps & { class?: HTMLAttributes["class"] }
>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
</script>
<template>
<AlertDialogTitle
v-bind="delegatedProps"
:class="cn('text-lg font-semibold', props.class)"
>
<slot />
</AlertDialogTitle>
</template>

View file

@ -0,0 +1,11 @@
<script setup lang="ts">
import { AlertDialogTrigger, type AlertDialogTriggerProps } from "radix-vue";
const props = defineProps<AlertDialogTriggerProps>();
</script>
<template>
<AlertDialogTrigger v-bind="props">
<slot />
</AlertDialogTrigger>
</template>

View file

@ -0,0 +1,9 @@
export { default as AlertDialog } from "./AlertDialog.vue";
export { default as AlertDialogAction } from "./AlertDialogAction.vue";
export { default as AlertDialogCancel } from "./AlertDialogCancel.vue";
export { default as AlertDialogContent } from "./AlertDialogContent.vue";
export { default as AlertDialogDescription } from "./AlertDialogDescription.vue";
export { default as AlertDialogFooter } from "./AlertDialogFooter.vue";
export { default as AlertDialogHeader } from "./AlertDialogHeader.vue";
export { default as AlertDialogTitle } from "./AlertDialogTitle.vue";
export { default as AlertDialogTrigger } from "./AlertDialogTrigger.vue";

View file

@ -0,0 +1,16 @@
<script setup lang="ts">
import { cn } from "@/lib/utils";
import type { HTMLAttributes } from "vue";
import { type BadgeVariants, badgeVariants } from ".";
const props = defineProps<{
variant?: BadgeVariants["variant"];
class?: HTMLAttributes["class"];
}>();
</script>
<template>
<div :class="cn(badgeVariants({ variant }), props.class)">
<slot />
</div>
</template>

View file

@ -0,0 +1,25 @@
import { type VariantProps, cva } from "class-variance-authority";
export { default as Badge } from "./Badge.vue";
export const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
export type BadgeVariants = VariantProps<typeof badgeVariants>;

View file

@ -9,7 +9,7 @@ const props = defineProps<{ class?: HTMLAttributes["class"] }>();
<div <div
:class=" :class="
cn( cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2', 'flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2 gap-y-2',
props.class, props.class,
) )
" "

View file

@ -0,0 +1,27 @@
<script setup lang="ts">
import { cn } from "@/lib/utils";
import { Label, type LabelProps } from "radix-vue";
import { type HTMLAttributes, computed } from "vue";
const props = defineProps<LabelProps & { class?: HTMLAttributes["class"] }>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
</script>
<template>
<Label
v-bind="delegatedProps"
:class="
cn(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
props.class,
)
"
>
<slot />
</Label>
</template>

View file

@ -0,0 +1 @@
export { default as Label } from "./Label.vue";

View file

@ -1,4 +1,5 @@
import { Client, type Token } from "@versia/client"; import { Client, type Token } from "@versia/client";
import { toast } from "vue-sonner";
export const useClient = ( export const useClient = (
customToken: MaybeRef<Token | null> = null, customToken: MaybeRef<Token | null> = null,
@ -11,13 +12,10 @@ export const useClient = (
identity.value?.tokens.access_token ?? identity.value?.tokens.access_token ??
undefined, undefined,
(error) => { (error) => {
useEvent("notification:new", { toast.error(
title: "An error occured",
type: "error",
description:
error.response.data.error ?? error.response.data.error ??
"No error message provided", "No error message provided",
}); );
}, },
), ),
); );