mirror of
https://github.com/versia-pub/frontend.git
synced 2025-12-06 08:28:20 +01:00
feat: ✨ Add file uploads to composer
This commit is contained in:
parent
294740c97f
commit
027847aa03
2
app.vue
2
app.vue
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
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>
|
||||||
25
components/modals-old/composable.ts
Normal file
25
components/modals-old/composable.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
69
components/modals/confirm-inline.vue
Normal file
69
components/modals/confirm-inline.vue
Normal 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>
|
||||||
102
components/modals/confirm.vue
Normal file
102
components/modals/confirm.vue
Normal 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>
|
||||||
19
components/ui/alert-dialog/AlertDialog.vue
Normal file
19
components/ui/alert-dialog/AlertDialog.vue
Normal 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>
|
||||||
22
components/ui/alert-dialog/AlertDialogAction.vue
Normal file
22
components/ui/alert-dialog/AlertDialogAction.vue
Normal 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>
|
||||||
29
components/ui/alert-dialog/AlertDialogCancel.vue
Normal file
29
components/ui/alert-dialog/AlertDialogCancel.vue
Normal 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>
|
||||||
44
components/ui/alert-dialog/AlertDialogContent.vue
Normal file
44
components/ui/alert-dialog/AlertDialogContent.vue
Normal 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>
|
||||||
27
components/ui/alert-dialog/AlertDialogDescription.vue
Normal file
27
components/ui/alert-dialog/AlertDialogDescription.vue
Normal 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>
|
||||||
21
components/ui/alert-dialog/AlertDialogFooter.vue
Normal file
21
components/ui/alert-dialog/AlertDialogFooter.vue
Normal 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>
|
||||||
16
components/ui/alert-dialog/AlertDialogHeader.vue
Normal file
16
components/ui/alert-dialog/AlertDialogHeader.vue
Normal 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>
|
||||||
24
components/ui/alert-dialog/AlertDialogTitle.vue
Normal file
24
components/ui/alert-dialog/AlertDialogTitle.vue
Normal 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>
|
||||||
11
components/ui/alert-dialog/AlertDialogTrigger.vue
Normal file
11
components/ui/alert-dialog/AlertDialogTrigger.vue
Normal 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>
|
||||||
9
components/ui/alert-dialog/index.ts
Normal file
9
components/ui/alert-dialog/index.ts
Normal 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";
|
||||||
16
components/ui/badge/Badge.vue
Normal file
16
components/ui/badge/Badge.vue
Normal 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>
|
||||||
25
components/ui/badge/index.ts
Normal file
25
components/ui/badge/index.ts
Normal 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>;
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
|
|
|
||||||
27
components/ui/label/Label.vue
Normal file
27
components/ui/label/Label.vue
Normal 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>
|
||||||
1
components/ui/label/index.ts
Normal file
1
components/ui/label/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { default as Label } from "./Label.vue";
|
||||||
|
|
@ -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",
|
error.response.data.error ??
|
||||||
type: "error",
|
|
||||||
description:
|
|
||||||
error.response.data.error ??
|
|
||||||
"No error message provided",
|
"No error message provided",
|
||||||
});
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue