mirror of
https://github.com/versia-pub/frontend.git
synced 2025-12-06 08:28:20 +01:00
refactor: 🔥 Remove old composer code
This commit is contained in:
parent
027847aa03
commit
c6f8ba081d
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
<template>
|
||||
<div class="absolute inset-0 bg-black/70"></div>
|
||||
</template>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
Loading…
Reference in a new issue