refactor: 🔥 Remove old composer code

This commit is contained in:
Jesse Wierzbinski 2024-12-01 17:20:57 +01:00
parent 027847aa03
commit c6f8ba081d
No known key found for this signature in database
18 changed files with 0 additions and 887 deletions

View file

@ -1,55 +0,0 @@
<template>
<div class="flex flex-row gap-1 border-white/20">
<Button title="Mention someone" @click="content = content + '@'">
<iconify-icon height="1.5rem" width="1.5rem" icon="tabler:at" aria-hidden="true" />
</Button>
<Button title="Toggle Markdown" @click="markdown = !markdown" :toggled="markdown">
<iconify-icon width="1.25rem" height="1.25rem"
:icon="markdown ? 'tabler:markdown' : 'tabler:markdown-off'" aria-hidden="true" />
</Button>
<Button title="Use a custom emoji">
<iconify-icon width="1.25rem" height="1.25rem" icon="tabler:mood-smile" aria-hidden="true" />
</Button>
<Button title="Add media" @click="emit('filePickerOpen')">
<iconify-icon width="1.25rem" height="1.25rem" icon="tabler:photo-up" aria-hidden="true" />
</Button>
<Button title="Add a file" @click="emit('filePickerOpen')">
<iconify-icon width="1.25rem" height="1.25rem" icon="tabler:file-upload" aria-hidden="true" />
</Button>
<Button title="Add content warning" @click="cw = !cw" :toggled="cw">
<iconify-icon width="1.25rem" height="1.25rem" icon="tabler:rating-18-plus" aria-hidden="true" />
</Button>
<ButtonBase theme="primary" :loading="loading" @click="emit('send')" class="ml-auto rounded-full"
:disabled="!canSubmit || loading">
{{
respondingType === "edit" ? "Edit!" : "Send!"
}}
</ButtonBase>
</div>
</template>
<script lang="ts" setup>
import ButtonBase from "~/packages/ui/components/buttons/button.vue";
import Button from "./button.vue";
defineProps<{
loading: boolean;
canSubmit: boolean;
respondingType: string | null;
}>();
const emit = defineEmits<{
send: [];
filePickerOpen: [];
}>();
const cw = defineModel<boolean>("cw", {
required: true,
});
const content = defineModel<string>("content", {
required: true,
});
const markdown = defineModel<boolean>("markdown", {
required: true,
});
</script>

View file

@ -1,126 +0,0 @@
<template>
<div class="max-h-40 max-w-full rounded ring-1 ring-dark-300 bg-dark-800 fixed z-20" :style="{
left: `${x}px`,
top: `${y}px`,
width: `${width}px`,
}" v-show="topSuggestions && topSuggestions.length > 0">
<OverlayScrollbarsComponent class="w-full [&>div]:flex">
<div v-for="(suggestion, index) in topSuggestions" :key="suggestion.key"
@click="emit('autocomplete', suggestion.key)"
:ref="el => { if (el) suggestionRefs[index] = el as Element }" :title="suggestion.key"
:class="['flex justify-center shrink-0 items-center size-12 p-2 hover:bg-dark-900/70', index === selectedSuggestionIndex && 'bg-primary2-500']">
<slot :suggestion="suggestion"></slot>
</div>
</OverlayScrollbarsComponent>
</div>
</template>
<script lang="ts" setup>
import { OverlayScrollbarsComponent } from "overlayscrollbars-vue";
const props = defineProps<{
currentlyTyping: string | null;
textarea: HTMLTextAreaElement | undefined;
suggestions: Array<{ key: string; value: unknown }>;
distanceFunction: (a: string, b: string) => number;
}>();
const suggestionRefs = ref<Element[]>([]);
// Allow the user to navigate the suggestions with the arrow keys
// and select a suggestion with the Tab or Enter key
const { Tab, ArrowRight, ArrowLeft, Enter } = useMagicKeys({
target: props.textarea,
passive: false,
onEventFired(e) {
if (
["Tab", "Enter", "ArrowRight", "ArrowLeft"].includes(e.key) &&
topSuggestions.value !== null
) {
e.preventDefault();
}
},
});
const topSuggestions = ref<Array<{ key: string; value: unknown }> | null>(null);
const selectedSuggestionIndex = ref<number | null>(null);
const x = ref(0);
const y = ref(0);
const width = ref(0);
const TOP_PADDING = 10;
useEventListener(props.textarea, "keyup", () => {
recalculatePosition();
});
const recalculatePosition = () => {
if (props.textarea) {
const target = props.textarea;
const position = target.selectionEnd;
// Get x, y position of the cursor in the textarea
const { top, left } = target.getBoundingClientRect();
const lineHeight = Number.parseInt(
getComputedStyle(target).lineHeight ?? "0",
10,
);
const lines = target.value.slice(0, position).split("\n");
const line = lines.length - 1;
x.value = left;
// Spawn one line below the cursor, so add +1
y.value = top + (line + 1) * lineHeight + TOP_PADDING;
width.value = target.clientWidth;
}
};
watchEffect(() => {
if (props.currentlyTyping !== null) {
topSuggestions.value = props.suggestions
.map((suggestion) => ({
...suggestion,
distance: props.distanceFunction(
props.currentlyTyping as string,
suggestion.key,
),
}))
.sort((a, b) => a.distance - b.distance)
.slice(0, 20);
} else {
topSuggestions.value = null;
}
if (ArrowRight?.value && topSuggestions.value !== null) {
selectedSuggestionIndex.value =
(selectedSuggestionIndex.value ?? -1) + 1;
if (selectedSuggestionIndex.value >= topSuggestions.value.length) {
selectedSuggestionIndex.value = 0;
}
suggestionRefs.value[selectedSuggestionIndex.value]?.scrollIntoView({
behavior: "smooth",
block: "nearest",
});
}
if (ArrowLeft?.value && topSuggestions.value !== null) {
selectedSuggestionIndex.value =
(selectedSuggestionIndex.value ?? topSuggestions.value.length) - 1;
if (selectedSuggestionIndex.value < 0) {
selectedSuggestionIndex.value = topSuggestions.value.length - 1;
}
suggestionRefs.value[selectedSuggestionIndex.value]?.scrollIntoView({
behavior: "smooth",
block: "nearest",
});
}
if ((Tab?.value || Enter?.value) && topSuggestions.value !== null) {
const suggestion =
topSuggestions.value[selectedSuggestionIndex.value ?? 0];
if (suggestion) {
emit("autocomplete", suggestion.key);
}
}
});
const emit = defineEmits<{
autocomplete: [suggestion: string];
}>();
</script>

View file

@ -1,18 +0,0 @@
<template>
<button v-bind="$props"
:class="['rounded text-gray-300 hover:bg-dark-900/70 p-2 flex items-center justify-center duration-200', toggled && 'bg-primary2-500/70 hover:bg-primary2-900/70']">
<slot />
</button>
</template>
<script lang="ts" setup>
import type { ButtonHTMLAttributes } from "vue";
interface Props extends /* @vue-ignore */ ButtonHTMLAttributes {}
defineProps<
Props & {
toggled?: boolean;
}
>();
</script>

View file

@ -1,222 +0,0 @@
<template>
<RespondingTo v-if="respondingTo" :respondingTo="respondingTo" />
<div class="px-6 pb-4 pt-5">
<RichTextbox v-model:content="content" :loading="loading" :chosenSplash="chosenSplash" :characterLimit="characterLimit"
:handle-paste="handlePaste" />
<ContentWarning v-model:cw="cw" v-model:cwContent="cwContent" />
<FileUploader :files="files" ref="uploader" @add-file="(newFile) => {
files.push(newFile);
}" @change-file="(changedFile) => {
const index = files.findIndex((file) => file.id === changedFile.id);
if (index !== -1) {
files[index] = changedFile;
}
}" @remove-file="(id) => {
files.splice(files.findIndex((file) => file.id === id), 1);
}" />
<ActionButtons v-model:content="content" v-model:markdown="markdown" v-model:cw="cw" :loading="loading" :canSubmit="canSubmit"
:respondingType="respondingType" @send="send" @file-picker-open="openFilePicker" />
</div>
</template>
<script lang="ts" setup>
import type { Instance, Status } from "@versia/client/types";
import { nanoid } from "nanoid";
import { computed, onMounted, ref, watch, watchEffect } from "vue";
import { useConfig, useEvent, useListen, useMagicKeys } from "#imports";
import ActionButtons from "./action-buttons.vue";
import ContentWarning from "./content-warning.vue";
import RespondingTo from "./responding-to.vue";
import RichTextbox from "./rich-text-box.vue";
// biome-ignore lint/style/useImportType: <explanation>
import FileUploader from "./uploader/uploader.vue";
import type { FileData } from "./uploader/uploader.vue";
const uploader = ref<InstanceType<typeof FileUploader> | undefined>(undefined);
const { Control_Enter, Command_Enter, Control_Alt } = useMagicKeys();
const content = ref("");
const respondingTo = ref<Status | null>(null);
const respondingType = ref<"reply" | "quote" | "edit" | null>(null);
const cw = ref(false);
const cwContent = ref("");
const markdown = ref(true);
const splashes = useConfig().COMPOSER_SPLASHES;
const chosenSplash = ref(
splashes[Math.floor(Math.random() * splashes.length)] as string,
);
const openFilePicker = () => {
uploader.value?.openFilePicker();
};
const files = ref<FileData[]>([]);
const handlePaste = (event: ClipboardEvent) => {
if (event.clipboardData) {
const items = Array.from(event.clipboardData.items);
const newFiles = items
.filter((item) => item.kind === "file")
.map((item) => item.getAsFile())
.filter((file): file is File => file !== null);
if (newFiles.length > 0) {
event.preventDefault();
files.value.push(
...newFiles.map((file) => ({
id: nanoid(),
file,
progress: 0,
uploading: true,
})),
);
}
}
};
watch(Control_Alt as ComputedRef<boolean>, () => {
chosenSplash.value = splashes[
Math.floor(Math.random() * splashes.length)
] as string;
});
watch(
files,
(newFiles) => {
loading.value = newFiles.some((file) => file.uploading);
},
{
deep: true,
},
);
onMounted(() => {
useListen("composer:reply", (note: Status) => {
respondingTo.value = note;
respondingType.value = "reply";
if (note.account.id !== identity.value?.account.id) {
content.value = `@${note.account.acct} `;
}
});
useListen("composer:quote", (note: Status) => {
respondingTo.value = note;
respondingType.value = "quote";
if (note.account.id !== identity.value?.account.id) {
content.value = `@${note.account.acct} `;
}
});
useListen("composer:edit", async (note: Status) => {
loading.value = true;
files.value = note.media_attachments.map((file) => ({
id: nanoid(),
file: new File([], file.url),
progress: 1,
uploading: false,
api_id: file.id,
alt_text: file.description ?? undefined,
}));
const source = await client.value.getStatusSource(note.id);
if (source?.data) {
respondingTo.value = note;
respondingType.value = "edit";
content.value = source.data.text;
cwContent.value = source.data.spoiler_text;
}
loading.value = false;
});
});
watchEffect(() => {
if (Control_Enter?.value || Command_Enter?.value) {
send();
}
});
const props = defineProps<{
instance: Instance;
}>();
const loading = ref(false);
const canSubmit = computed(
() =>
(content.value?.trim().length > 0 || files.value.length > 0) &&
content.value?.trim().length <= characterLimit.value,
);
const send = async () => {
if (!(identity.value && client.value)) {
throw new Error("Not authenticated");
}
try {
loading.value = true;
if (respondingType.value === "edit" && respondingTo.value) {
const response = await client.value.editStatus(
respondingTo.value.id,
{
status: content.value?.trim() ?? "",
content_type: markdown.value
? "text/markdown"
: "text/plain",
spoiler_text: cw.value ? cwContent.value.trim() : undefined,
sensitive: cw.value,
media_ids: files.value
.filter((file) => !!file.api_id)
.map((file) => file.api_id) as string[],
},
);
if (!response.data) {
throw new Error("Failed to edit status");
}
content.value = "";
loading.value = false;
useEvent("composer:send-edit", response.data);
useEvent("composer:close");
return;
}
const response = await client.value.postStatus(
content.value?.trim() ?? "",
{
content_type: markdown.value ? "text/markdown" : "text/plain",
in_reply_to_id:
respondingType.value === "reply"
? respondingTo.value?.id
: undefined,
quote_id:
respondingType.value === "quote"
? respondingTo.value?.id
: undefined,
spoiler_text: cw.value ? cwContent.value.trim() : undefined,
sensitive: cw.value,
media_ids: files.value
.filter((file) => !!file.api_id)
.map((file) => file.api_id) as string[],
},
);
if (!response.data) {
throw new Error("Failed to send status");
}
content.value = "";
loading.value = false;
useEvent("composer:send", response.data as Status);
useEvent("composer:close");
} catch (error) {
console.error(error);
loading.value = false;
}
};
const characterLimit = computed(
() => props.instance?.configuration.statuses.max_characters ?? 0,
);
</script>

View file

@ -1,16 +0,0 @@
<template>
<div v-if="cw" class="mb-4">
<input type="text" v-model="cwContent" placeholder="Add a content warning"
class="w-full p-2 mt-1 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"
aria-label="Content warning" />
</div>
</template>
<script lang="ts" setup>
const cw = defineModel<boolean>("cw", {
required: true,
});
const cwContent = defineModel<string>("cwContent", {
required: true,
});
</script>

View file

@ -1,29 +0,0 @@
<template>
<AutocompleteSuggestbox :currently-typing="currentlyTypingEmoji" :textarea="textarea" :suggestions="emojis"
:distance-function="distance">
<template #default="{ suggestion }">
<Avatar :src="(suggestion.value as Emoji).url"
class="w-full h-full [&>img]:object-contain !bg-transparent rounded"
:alt="`Emoji with shortcode ${(suggestion.value as Emoji).shortcode}`" />
</template>
</AutocompleteSuggestbox>
</template>
<script lang="ts" setup>
import type { Emoji } from "@versia/client/types";
import { distance } from "fastest-levenshtein";
import Avatar from "../avatars/avatar.vue";
import AutocompleteSuggestbox from "./autocomplete-suggestbox.vue";
defineProps<{
currentlyTypingEmoji: string | null;
textarea: HTMLTextAreaElement | undefined;
}>();
const emojis = computed(
() =>
identity.value?.emojis.map((emoji) => ({
key: emoji.shortcode,
value: emoji,
})) ?? [],
);
</script>

View file

@ -1,41 +0,0 @@
<template>
<AutocompleteSuggestbox :currently-typing="currentlyTypingMention" :textarea="textarea" :suggestions="mentions"
:distance-function="distance">
<template #default="{ suggestion }">
<Avatar :src="(suggestion.value as Account).avatar" class="w-full h-full rounded"
:alt="`User ${(suggestion.value as Account).acct}`" />
</template>
</AutocompleteSuggestbox>
</template>
<script lang="ts" setup>
import type { Account } from "@versia/client/types";
import { distance } from "fastest-levenshtein";
import Avatar from "../avatars/avatar.vue";
import AutocompleteSuggestbox from "./autocomplete-suggestbox.vue";
const props = defineProps<{
currentlyTypingMention: string | null;
textarea: HTMLTextAreaElement | undefined;
}>();
const mentions = ref<{ key: string; value: Account }[]>([]);
watch(
() => props.currentlyTypingMention,
async (value) => {
if (!value) {
return;
}
const users = await client.value.searchAccount(value, { limit: 20 });
mentions.value = users.data
.map((user) => ({
key: user.username,
value: user,
distance: distance(value, user.username),
}))
.sort((a, b) => a.distance - b.distance)
.slice(0, 20);
},
);
</script>

View file

@ -1,61 +0,0 @@
<template>
<HeadlessTransitionRoot as="template" :show="open">
<Dialog.Root v-model:open="open" :close-on-escape="true" :close-on-interact-outside="true"
@update:open="o => open = o">
<Teleport to="body">
<Dialog.Positioner
class="flex items-start z-50 justify-center p-4 text-center sm:items-center sm:p-0 fixed inset-0 w-screen h-screen overflow-y-hidden">
<HeadlessTransitionChild as="template" enter="ease-out duration-200" enter-from="opacity-0"
enter-to="opacity-100" leave="ease-in duration-200" leave-from="opacity-100"
leave-to="opacity-0">
<Dialog.Backdrop class="fixed inset-0 bg-black/70" @click="open = false" />
</HeadlessTransitionChild>
<HeadlessTransitionChild as="template" enter="ease-out duration-200"
enter-from="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enter-to="opacity-100 translate-y-0 sm:scale-100" leave="ease-in duration-200"
leave-from="opacity-100 translate-y-0 sm:scale-100"
leave-to="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">
<Dialog.Content class="overflow-y-auto w-full max-h-full md:py-16">
<div
class="relative overflow-hidden max-w-xl mx-auto rounded-lg bg-dark-700 ring-1 ring-dark-800 text-left shadow-xl transition-all w-full">
<Composer v-if="instance" :instance="instance as any" />
</div>
</Dialog.Content>
</HeadlessTransitionChild>
</Dialog.Positioner>
</Teleport>
</Dialog.Root>
</HeadlessTransitionRoot>
</template>
<script lang="ts" setup>
import { Dialog } from "@ark-ui/vue";
import Composer from "./composer.vue";
const open = ref(false);
useListen("note:reply", async (note) => {
open.value = true;
await nextTick();
useEvent("composer:reply", note);
});
useListen("note:quote", async (note) => {
open.value = true;
await nextTick();
useEvent("composer:quote", note);
});
useListen("note:edit", async (note) => {
open.value = true;
await nextTick();
useEvent("composer:edit", note);
});
useListen("composer:open", () => {
if (identity.value) {
open.value = true;
}
});
useListen("composer:close", () => {
open.value = false;
});
const instance = useInstance();
</script>

View file

@ -1,17 +0,0 @@
<template>
<div v-if="respondingTo" class="mb-4" role="region" aria-label="Responding to">
<OverlayScrollbarsComponent :defer="true" class="max-h-72 overflow-y-auto">
<Note :element="respondingTo" :small="true" :disabled="true" class="!rounded-none !bg-primary2-500/10" />
</OverlayScrollbarsComponent>
</div>
</template>
<script lang="ts" setup>
import type { Status } from "@versia/client/types";
import { OverlayScrollbarsComponent } from "overlayscrollbars-vue";
import Note from "../social-elements/notes/note.vue";
const props = defineProps<{
respondingTo: Status;
}>();
</script>

View file

@ -1,19 +0,0 @@
<template>
<RichTextboxInput v-model:model-content="content" @paste="handlePaste" :disabled="loading"
:placeholder="chosenSplash" :max-characters="characterLimit" class="focus:!ring-0 max-h-[70dvh]" />
</template>
<script lang="ts" setup>
import RichTextboxInput from "../inputs/rich-textbox-input.vue";
defineProps<{
loading: boolean;
chosenSplash: string;
characterLimit: number;
handlePaste: (event: ClipboardEvent) => void;
}>();
const content = defineModel<string>("content", {
required: true,
});
</script>

View file

@ -1,8 +0,0 @@
<template>
<div class="w-full max-w-full rounded ring-1 ring-dark-300 bg-dark-800 absolute z-20 flex flex-col">
<slot />
</div>
</template>
<script lang="ts" setup>
</script>

View file

@ -1,53 +0,0 @@
<template>
<Popover.Root :positioning="{
strategy: 'fixed',
}" @update:open="o => !o" :close-on-interact-outside="false">
<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 w-72 space-y-2">
<div class="flex items-center justify-between px-1 pt-1 gap-x-1">
<Popover.CloseTrigger :as-child="true">
<Button theme="outline" aria-label="Close" class="text-xs !p-1">
<iconify-icon icon="tabler:x" width="1rem" height="1rem" />
</Button>
</Popover.CloseTrigger>
<h3 class="text-xs font-semibold">Alt Text</h3>
<a :href="`https://www.w3.org/WAI/tutorials/images/decision-tree/`" target="_blank"
class="text-xs text-gray-300 ml-auto mr-1" title="Learn more about alt text">
<iconify-icon icon="tabler:info-circle" width="1rem" height="1rem" />
</a>
</div>
<PreviewContent :file="fileData.file" class="rounded" />
<textarea :disabled="fileData.uploading" @keydown.enter.stop v-model="fileData.alt_text"
placeholder="Describe this image for screen readers"
rows="5"
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" />
<Popover.CloseTrigger :as-child="true">
<Button theme="secondary" @click="$emit('update-alt-text', fileData.alt_text)" class="w-full"
:loading="fileData.uploading">
<span>Edit</span>
</Button>
</Popover.CloseTrigger>
</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 PreviewContent from "./preview-content.vue";
import type { FileData } from "./uploader.vue";
const props = defineProps<{
fileData: FileData;
}>();
defineEmits<{
"update-alt-text": [text?: string];
}>();
</script>

View file

@ -1,30 +0,0 @@
<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',
]" @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

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

View file

@ -1,26 +0,0 @@
<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-primary2-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

@ -1,32 +0,0 @@
<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

@ -1,12 +0,0 @@
<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

@ -1,119 +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">
<FilePreview v-for="data in files" :key="data.id" :file-data="data" @remove="(id: string) => emit('removeFile', id)"
@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 emit = defineEmits<{
changeFile: [changedFile: FileData];
addFile: [newFile: FileData];
removeFile: [id: string];
}>();
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 updateAltText = (id: string, altText?: string) => {
const foundFile = files.value.find((data) => data.id === id);
if (!foundFile) {
throw new Error("File with ID doesn't exist");
}
emit("changeFile", {
...foundFile,
uploading: true,
});
client.value
?.updateMedia(foundFile.api_id as string, { description: altText })
.then(() => {
emit("changeFile", {
...foundFile,
uploading: false,
});
});
};
const uploadFile = async (file: File) => {
const foundFile = files.value.find((data) => data.file === file);
if (!foundFile) {
throw new Error("File doesn't exist");
}
emit("changeFile", {
...foundFile,
uploading: true,
progress: 0.1,
});
client.value.uploadMedia(file).then((response) => {
const attachment = response.data;
emit("changeFile", {
...foundFile,
uploading: false,
progress: 1.0,
api_id: attachment.id,
});
});
};
</script>