mirror of
https://github.com/versia-pub/frontend.git
synced 2025-12-06 08:28:20 +01:00
refactor: ♻️ Refactor composer
This commit is contained in:
parent
70222d127b
commit
343765a331
55
components/composer/action-buttons.vue
Normal file
55
components/composer/action-buttons.vue
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
<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,59 +1,36 @@
|
||||||
<template>
|
<template>
|
||||||
<div v-if="respondingTo" class="mb-4" role="region" aria-label="Responding to">
|
<RespondingTo v-if="respondingTo" :respondingTo="respondingTo" />
|
||||||
<OverlayScrollbarsComponent :defer="true" class="max-h-72 overflow-y-auto">
|
|
||||||
<Note :element="respondingTo" :small="true" :disabled="true" class="!rounded-none !bg-primary-500/10" />
|
|
||||||
</OverlayScrollbarsComponent>
|
|
||||||
</div>
|
|
||||||
<div class="px-6 pb-4 pt-5">
|
<div class="px-6 pb-4 pt-5">
|
||||||
<RichTextboxInput v-model:model-content="content" @paste="handlePaste" :disabled="loading"
|
<RichTextbox v-model:content="content" :loading="loading" :chosenSplash="chosenSplash" :characterLimit="characterLimit"
|
||||||
:placeholder="chosenSplash" :max-characters="characterLimit" class="focus:!ring-0 max-h-[70dvh]" />
|
:handle-paste="handlePaste" />
|
||||||
<!-- Content warning textbox -->
|
<ContentWarning v-model:cw="cw" v-model:cwContent="cwContent" />
|
||||||
<div v-if="cw" class="mb-4">
|
<FileUploader :files="files" ref="uploader" @add-file="(newFile) => {
|
||||||
<input type="text" v-model="cwContent" placeholder="Add a content warning"
|
files.push(newFile);
|
||||||
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"
|
}" @change-file="(changedFile) => {
|
||||||
aria-label="Content warning" />
|
const index = files.findIndex((file) => file.id === changedFile.id);
|
||||||
</div>
|
if (index !== -1) {
|
||||||
<FileUploader v-model:files="files" ref="uploader" />
|
files[index] = changedFile;
|
||||||
<div class="flex flex-row gap-1 border-white/20">
|
}
|
||||||
<Button title="Mention someone" @click="content = content + '@'">
|
}" @remove-file="(id) => {
|
||||||
<iconify-icon height="1.5rem" width="1.5rem" icon="tabler:at" aria-hidden="true" />
|
files.splice(files.findIndex((file) => file.id === id), 1);
|
||||||
</Button>
|
}" />
|
||||||
<Button title="Toggle Markdown" @click="markdown = !markdown" :toggled="markdown">
|
<ActionButtons v-model:content="content" v-model:markdown="markdown" v-model:cw="cw" :loading="loading" :canSubmit="canSubmit"
|
||||||
<iconify-icon width="1.25rem" height="1.25rem"
|
:respondingType="respondingType" @send="send" @file-picker-open="openFilePicker" />
|
||||||
: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="openFilePicker">
|
|
||||||
<iconify-icon width="1.25rem" height="1.25rem" icon="tabler:photo-up" aria-hidden="true" />
|
|
||||||
</Button>
|
|
||||||
<Button title="Add a file" @click="openFilePicker">
|
|
||||||
<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="send" class="ml-auto rounded-full"
|
|
||||||
:disabled="!canSubmit || loading">
|
|
||||||
{{
|
|
||||||
respondingType === "edit" ? "Edit!" : "Send!"
|
|
||||||
}}
|
|
||||||
</ButtonBase>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { Instance, Status } from "@versia/client/types";
|
import type { Instance, Status } from "@versia/client/types";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import ButtonBase from "~/packages/ui/components/buttons/button.vue";
|
import { computed, onMounted, ref, watch, watchEffect } from "vue";
|
||||||
import { OverlayScrollbarsComponent } from "#imports";
|
import { useConfig, useEvent, useListen, useMagicKeys } from "#imports";
|
||||||
import RichTextboxInput from "../inputs/rich-textbox-input.vue";
|
import ActionButtons from "./action-buttons.vue";
|
||||||
import Note from "../social-elements/notes/note.vue";
|
import ContentWarning from "./content-warning.vue";
|
||||||
import Button from "./button.vue";
|
import RespondingTo from "./responding-to.vue";
|
||||||
// biome-ignore lint/style/useImportType: Biome doesn't see the Vue code
|
import RichTextbox from "./rich-text-box.vue";
|
||||||
import FileUploader, { type FileData } from "./uploader/uploader.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 uploader = ref<InstanceType<typeof FileUploader> | undefined>(undefined);
|
||||||
const { Control_Enter, Command_Enter, Control_Alt } = useMagicKeys();
|
const { Control_Enter, Command_Enter, Control_Alt } = useMagicKeys();
|
||||||
|
|
@ -65,7 +42,9 @@ const cwContent = ref("");
|
||||||
const markdown = ref(true);
|
const markdown = ref(true);
|
||||||
|
|
||||||
const splashes = useConfig().COMPOSER_SPLASHES;
|
const splashes = useConfig().COMPOSER_SPLASHES;
|
||||||
const chosenSplash = ref(splashes[Math.floor(Math.random() * splashes.length)]);
|
const chosenSplash = ref(
|
||||||
|
splashes[Math.floor(Math.random() * splashes.length)] as string,
|
||||||
|
);
|
||||||
|
|
||||||
const openFilePicker = () => {
|
const openFilePicker = () => {
|
||||||
uploader.value?.openFilePicker();
|
uploader.value?.openFilePicker();
|
||||||
|
|
@ -95,13 +74,14 @@ const handlePaste = (event: ClipboardEvent) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
watch(Control_Alt as ComputedRef<boolean>, () => {
|
watch(Control_Alt as ComputedRef<boolean>, () => {
|
||||||
chosenSplash.value = splashes[Math.floor(Math.random() * splashes.length)];
|
chosenSplash.value = splashes[
|
||||||
|
Math.floor(Math.random() * splashes.length)
|
||||||
|
] as string;
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
files,
|
files,
|
||||||
(newFiles) => {
|
(newFiles) => {
|
||||||
// If a file is uploading, set loading to true
|
|
||||||
loading.value = newFiles.some((file) => file.uploading);
|
loading.value = newFiles.some((file) => file.uploading);
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -137,7 +117,6 @@ onMounted(() => {
|
||||||
alt_text: file.description ?? undefined,
|
alt_text: file.description ?? undefined,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Fetch source
|
|
||||||
const source = await client.value.getStatusSource(note.id);
|
const source = await client.value.getStatusSource(note.id);
|
||||||
|
|
||||||
if (source?.data) {
|
if (source?.data) {
|
||||||
|
|
@ -169,21 +148,28 @@ const canSubmit = computed(
|
||||||
);
|
);
|
||||||
|
|
||||||
const send = async () => {
|
const send = async () => {
|
||||||
loading.value = true;
|
|
||||||
if (!(identity.value && client.value)) {
|
if (!(identity.value && client.value)) {
|
||||||
throw new Error("Not authenticated");
|
throw new Error("Not authenticated");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
if (respondingType.value === "edit" && respondingTo.value) {
|
if (respondingType.value === "edit" && respondingTo.value) {
|
||||||
const response = await client.value.editStatus(respondingTo.value.id, {
|
const response = await client.value.editStatus(
|
||||||
|
respondingTo.value.id,
|
||||||
|
{
|
||||||
status: content.value?.trim() ?? "",
|
status: content.value?.trim() ?? "",
|
||||||
content_type: markdown.value ? "text/markdown" : "text/plain",
|
content_type: markdown.value
|
||||||
|
? "text/markdown"
|
||||||
|
: "text/plain",
|
||||||
spoiler_text: cw.value ? cwContent.value.trim() : undefined,
|
spoiler_text: cw.value ? cwContent.value.trim() : undefined,
|
||||||
sensitive: cw.value,
|
sensitive: cw.value,
|
||||||
media_ids: files.value
|
media_ids: files.value
|
||||||
.filter((file) => !!file.api_id)
|
.filter((file) => !!file.api_id)
|
||||||
.map((file) => file.api_id) as string[],
|
.map((file) => file.api_id) as string[],
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
if (!response.data) {
|
if (!response.data) {
|
||||||
throw new Error("Failed to edit status");
|
throw new Error("Failed to edit status");
|
||||||
|
|
@ -224,6 +210,10 @@ const send = async () => {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
useEvent("composer:send", response.data as Status);
|
useEvent("composer:send", response.data as Status);
|
||||||
useEvent("composer:close");
|
useEvent("composer:close");
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const characterLimit = computed(
|
const characterLimit = computed(
|
||||||
|
|
|
||||||
16
components/composer/content-warning.vue
Normal file
16
components/composer/content-warning.vue
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<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>
|
||||||
17
components/composer/responding-to.vue
Normal file
17
components/composer/responding-to.vue
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<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-primary-500/10" />
|
||||||
|
</OverlayScrollbarsComponent>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { Status } from "@versia/client/types";
|
||||||
|
import { OverlayScrollbarsComponent } from "#imports";
|
||||||
|
import Note from "../social-elements/notes/note.vue";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
respondingTo: Status;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
19
components/composer/rich-text-box.vue
Normal file
19
components/composer/rich-text-box.vue
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
<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>
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
<div>
|
<div>
|
||||||
<input type="file" ref="fileInput" @change="handleFileInput" style="display: none" multiple />
|
<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">
|
<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="removeFile"
|
<FilePreview v-for="data in files" :key="data.id" :file-data="data" @remove="(id: string) => emit('removeFile', id)"
|
||||||
@update-alt-text="updateAltText" />
|
@update-alt-text="updateAltText" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -35,6 +35,12 @@ defineExpose({
|
||||||
openFilePicker,
|
openFilePicker,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
changeFile: [changedFile: FileData];
|
||||||
|
addFile: [newFile: FileData];
|
||||||
|
removeFile: [id: string];
|
||||||
|
}>();
|
||||||
|
|
||||||
const handleFileInput = (event: Event) => {
|
const handleFileInput = (event: Event) => {
|
||||||
const target = event.target as HTMLInputElement;
|
const target = event.target as HTMLInputElement;
|
||||||
if (target.files) {
|
if (target.files) {
|
||||||
|
|
@ -64,54 +70,49 @@ watch(
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const removeFile = (id: string) => {
|
|
||||||
files.value = files.value.filter((data) => data.id !== id);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateAltText = (id: string, altText?: string) => {
|
const updateAltText = (id: string, altText?: string) => {
|
||||||
files.value = files.value.map((data) => {
|
const foundFile = files.value.find((data) => data.id === id);
|
||||||
if (data.id === id) {
|
|
||||||
return { ...data, uploading: true };
|
if (!foundFile) {
|
||||||
|
throw new Error("File with ID doesn't exist");
|
||||||
}
|
}
|
||||||
return data;
|
|
||||||
|
emit("changeFile", {
|
||||||
|
...foundFile,
|
||||||
|
uploading: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
client.value
|
client.value
|
||||||
?.updateMedia(
|
?.updateMedia(foundFile.api_id as string, { description: altText })
|
||||||
files.value.find((data) => data.id === id)?.api_id as string,
|
|
||||||
{ description: altText },
|
|
||||||
)
|
|
||||||
.then(() => {
|
.then(() => {
|
||||||
files.value = files.value.map((data) => {
|
emit("changeFile", {
|
||||||
if (data.id === id) {
|
...foundFile,
|
||||||
return { ...data, uploading: false };
|
uploading: false,
|
||||||
}
|
|
||||||
return data;
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const uploadFile = async (file: File) => {
|
const uploadFile = async (file: File) => {
|
||||||
files.value = files.value.map((data) => {
|
const foundFile = files.value.find((data) => data.file === file);
|
||||||
if (data.file === file) {
|
|
||||||
return { ...data, uploading: true, progress: 0.1 };
|
if (!foundFile) {
|
||||||
|
throw new Error("File doesn't exist");
|
||||||
}
|
}
|
||||||
return data;
|
|
||||||
|
emit("changeFile", {
|
||||||
|
...foundFile,
|
||||||
|
uploading: true,
|
||||||
|
progress: 0.1,
|
||||||
});
|
});
|
||||||
|
|
||||||
client.value.uploadMedia(file).then((response) => {
|
client.value.uploadMedia(file).then((response) => {
|
||||||
const attachment = response.data;
|
const attachment = response.data;
|
||||||
|
|
||||||
files.value = files.value.map((data) => {
|
emit("changeFile", {
|
||||||
if (data.file === file) {
|
...foundFile,
|
||||||
return {
|
|
||||||
...data,
|
|
||||||
api_id: attachment.id,
|
|
||||||
uploading: false,
|
uploading: false,
|
||||||
progress: 1.0,
|
progress: 1.0,
|
||||||
};
|
api_id: attachment.id,
|
||||||
}
|
|
||||||
return data;
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -25,28 +25,16 @@ defineOptions({
|
||||||
});
|
});
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
maxCharacters?: number;
|
maxCharacters?: number;
|
||||||
modelContent: string;
|
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const modelContent = defineModel<string>("modelContent", {
|
||||||
"update:modelContent": [value: string];
|
required: true,
|
||||||
}>();
|
});
|
||||||
|
|
||||||
const textarea = ref<HTMLTextAreaElement | undefined>(undefined);
|
const textarea = ref<HTMLTextAreaElement | undefined>(undefined);
|
||||||
const { input: content } = useTextareaAutosize({
|
const { input: content } = useTextareaAutosize({
|
||||||
element: textarea,
|
element: textarea,
|
||||||
input: props.modelContent,
|
input: modelContent,
|
||||||
});
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.modelContent,
|
|
||||||
(value) => {
|
|
||||||
content.value = value;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
watch(content, (newValue) => {
|
|
||||||
emit("update:modelContent", newValue);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const remainingCharacters = computed(
|
const remainingCharacters = computed(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue