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>
|
||||
<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>
|
||||
<RespondingTo v-if="respondingTo" :respondingTo="respondingTo" />
|
||||
<div class="px-6 pb-4 pt-5">
|
||||
<RichTextboxInput v-model:model-content="content" @paste="handlePaste" :disabled="loading"
|
||||
:placeholder="chosenSplash" :max-characters="characterLimit" class="focus:!ring-0 max-h-[70dvh]" />
|
||||
<!-- Content warning textbox -->
|
||||
<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>
|
||||
<FileUploader v-model:files="files" ref="uploader" />
|
||||
<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="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>
|
||||
<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 ButtonBase from "~/packages/ui/components/buttons/button.vue";
|
||||
import { OverlayScrollbarsComponent } from "#imports";
|
||||
import RichTextboxInput from "../inputs/rich-textbox-input.vue";
|
||||
import Note from "../social-elements/notes/note.vue";
|
||||
import Button from "./button.vue";
|
||||
// biome-ignore lint/style/useImportType: Biome doesn't see the Vue code
|
||||
import FileUploader, { type FileData } from "./uploader/uploader.vue";
|
||||
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();
|
||||
|
|
@ -65,7 +42,9 @@ const cwContent = ref("");
|
|||
const markdown = ref(true);
|
||||
|
||||
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 = () => {
|
||||
uploader.value?.openFilePicker();
|
||||
|
|
@ -95,13 +74,14 @@ const handlePaste = (event: ClipboardEvent) => {
|
|||
};
|
||||
|
||||
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(
|
||||
files,
|
||||
(newFiles) => {
|
||||
// If a file is uploading, set loading to true
|
||||
loading.value = newFiles.some((file) => file.uploading);
|
||||
},
|
||||
{
|
||||
|
|
@ -137,7 +117,6 @@ onMounted(() => {
|
|||
alt_text: file.description ?? undefined,
|
||||
}));
|
||||
|
||||
// Fetch source
|
||||
const source = await client.value.getStatusSource(note.id);
|
||||
|
||||
if (source?.data) {
|
||||
|
|
@ -169,61 +148,72 @@ const canSubmit = computed(
|
|||
);
|
||||
|
||||
const send = async () => {
|
||||
loading.value = true;
|
||||
if (!(identity.value && client.value)) {
|
||||
throw new Error("Not authenticated");
|
||||
}
|
||||
|
||||
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[],
|
||||
});
|
||||
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 edit status");
|
||||
throw new Error("Failed to send status");
|
||||
}
|
||||
|
||||
content.value = "";
|
||||
loading.value = false;
|
||||
useEvent("composer:send-edit", response.data);
|
||||
useEvent("composer:send", response.data as Status);
|
||||
useEvent("composer:close");
|
||||
return;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
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");
|
||||
};
|
||||
|
||||
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>
|
||||
<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="removeFile"
|
||||
<FilePreview v-for="data in files" :key="data.id" :file-data="data" @remove="(id: string) => emit('removeFile', id)"
|
||||
@update-alt-text="updateAltText" />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -35,6 +35,12 @@ 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) {
|
||||
|
|
@ -64,54 +70,49 @@ watch(
|
|||
},
|
||||
);
|
||||
|
||||
const removeFile = (id: string) => {
|
||||
files.value = files.value.filter((data) => data.id !== id);
|
||||
};
|
||||
|
||||
const updateAltText = (id: string, altText?: string) => {
|
||||
files.value = files.value.map((data) => {
|
||||
if (data.id === id) {
|
||||
return { ...data, uploading: true };
|
||||
}
|
||||
return data;
|
||||
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(
|
||||
files.value.find((data) => data.id === id)?.api_id as string,
|
||||
{ description: altText },
|
||||
)
|
||||
?.updateMedia(foundFile.api_id as string, { description: altText })
|
||||
.then(() => {
|
||||
files.value = files.value.map((data) => {
|
||||
if (data.id === id) {
|
||||
return { ...data, uploading: false };
|
||||
}
|
||||
return data;
|
||||
emit("changeFile", {
|
||||
...foundFile,
|
||||
uploading: false,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const uploadFile = async (file: File) => {
|
||||
files.value = files.value.map((data) => {
|
||||
if (data.file === file) {
|
||||
return { ...data, uploading: true, progress: 0.1 };
|
||||
}
|
||||
return data;
|
||||
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;
|
||||
|
||||
files.value = files.value.map((data) => {
|
||||
if (data.file === file) {
|
||||
return {
|
||||
...data,
|
||||
api_id: attachment.id,
|
||||
uploading: false,
|
||||
progress: 1.0,
|
||||
};
|
||||
}
|
||||
return data;
|
||||
emit("changeFile", {
|
||||
...foundFile,
|
||||
uploading: false,
|
||||
progress: 1.0,
|
||||
api_id: attachment.id,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -25,28 +25,16 @@ defineOptions({
|
|||
});
|
||||
const props = defineProps<{
|
||||
maxCharacters?: number;
|
||||
modelContent: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelContent": [value: string];
|
||||
}>();
|
||||
const modelContent = defineModel<string>("modelContent", {
|
||||
required: true,
|
||||
});
|
||||
|
||||
const textarea = ref<HTMLTextAreaElement | undefined>(undefined);
|
||||
const { input: content } = useTextareaAutosize({
|
||||
element: textarea,
|
||||
input: props.modelContent,
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.modelContent,
|
||||
(value) => {
|
||||
content.value = value;
|
||||
},
|
||||
);
|
||||
|
||||
watch(content, (newValue) => {
|
||||
emit("update:modelContent", newValue);
|
||||
input: modelContent,
|
||||
});
|
||||
|
||||
const remainingCharacters = computed(
|
||||
|
|
|
|||
Loading…
Reference in a new issue