mirror of
https://github.com/versia-pub/frontend.git
synced 2025-12-06 08:28:20 +01:00
feat: ✨ Add file uploads to composer
This commit is contained in:
parent
f91df20dc1
commit
d62d267c60
|
|
@ -5,9 +5,10 @@
|
||||||
class="!rounded-none !bg-pink-500/10" />
|
class="!rounded-none !bg-pink-500/10" />
|
||||||
</OverlayScrollbarsComponent>
|
</OverlayScrollbarsComponent>
|
||||||
</div>
|
</div>
|
||||||
<div class="px-6 py-4">
|
<div class="px-6 pb-4 pt-5">
|
||||||
<div class="pb-2 relative">
|
<div class="pb-2 relative">
|
||||||
<textarea :disabled="submitting" ref="textarea" v-model="content" :placeholder="chosenSplash"
|
<textarea :disabled="submitting" ref="textarea" v-model="content" :placeholder="chosenSplash"
|
||||||
|
@paste="handlePaste"
|
||||||
class="resize-none min-h-48 prose prose-invert max-h-[70dvh] w-full p-0 focus:!ring-0 !ring-none !border-none !outline-none placeholder:text-zinc-500 bg-transparent appearance-none focus:!border-none focus:!outline-none disabled:cursor-not-allowed"></textarea>
|
class="resize-none min-h-48 prose prose-invert max-h-[70dvh] w-full p-0 focus:!ring-0 !ring-none !border-none !outline-none placeholder:text-zinc-500 bg-transparent appearance-none focus:!border-none focus:!outline-none disabled:cursor-not-allowed"></textarea>
|
||||||
<div
|
<div
|
||||||
:class="['absolute bottom-0 right-0 p-2 text-gray-400 font-semibold text-xs', remainingCharacters < 0 && 'text-red-500']">
|
:class="['absolute bottom-0 right-0 p-2 text-gray-400 font-semibold text-xs', remainingCharacters < 0 && 'text-red-500']">
|
||||||
|
|
@ -21,6 +22,7 @@
|
||||||
<input type="text" v-model="cwContent" placeholder="Add a content warning"
|
<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" />
|
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" />
|
||||||
</div>
|
</div>
|
||||||
|
<ComposerFileUploader v-model:files="files" ref="uploader" />
|
||||||
<div class="flex flex-row gap-1 border-white/20">
|
<div class="flex flex-row gap-1 border-white/20">
|
||||||
<ComposerButton title="Mention someone">
|
<ComposerButton title="Mention someone">
|
||||||
<iconify-icon height="1.5rem" width="1.5rem" icon="tabler:at" aria-hidden="true" />
|
<iconify-icon height="1.5rem" width="1.5rem" icon="tabler:at" aria-hidden="true" />
|
||||||
|
|
@ -32,10 +34,10 @@
|
||||||
<ComposerButton title="Use a custom emoji">
|
<ComposerButton title="Use a custom emoji">
|
||||||
<iconify-icon width="1.25rem" height="1.25rem" icon="tabler:mood-smile" aria-hidden="true" />
|
<iconify-icon width="1.25rem" height="1.25rem" icon="tabler:mood-smile" aria-hidden="true" />
|
||||||
</ComposerButton>
|
</ComposerButton>
|
||||||
<ComposerButton title="Add media">
|
<ComposerButton title="Add media" @click="openFilePicker">
|
||||||
<iconify-icon width="1.25rem" height="1.25rem" icon="tabler:photo-up" aria-hidden="true" />
|
<iconify-icon width="1.25rem" height="1.25rem" icon="tabler:photo-up" aria-hidden="true" />
|
||||||
</ComposerButton>
|
</ComposerButton>
|
||||||
<ComposerButton title="Add a file">
|
<ComposerButton title="Add a file" @click="openFilePicker">
|
||||||
<iconify-icon width="1.25rem" height="1.25rem" icon="tabler:file-upload" aria-hidden="true" />
|
<iconify-icon width="1.25rem" height="1.25rem" icon="tabler:file-upload" aria-hidden="true" />
|
||||||
</ComposerButton>
|
</ComposerButton>
|
||||||
<ComposerButton title="Add content warning" @click="cw = !cw" :toggled="cw">
|
<ComposerButton title="Add content warning" @click="cw = !cw" :toggled="cw">
|
||||||
|
|
@ -50,11 +52,14 @@
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { char, createRegExp, exactly } from "magic-regexp";
|
import { char, createRegExp, exactly } from "magic-regexp";
|
||||||
|
import { nanoid } from "nanoid";
|
||||||
import type { Instance } from "~/types/mastodon/instance";
|
import type { Instance } from "~/types/mastodon/instance";
|
||||||
import type { Status } from "~/types/mastodon/status";
|
import type { Status } from "~/types/mastodon/status";
|
||||||
import { OverlayScrollbarsComponent } from "#imports";
|
import { OverlayScrollbarsComponent } from "#imports";
|
||||||
|
import type FileUploader from "./file-uploader.vue";
|
||||||
|
|
||||||
const textarea = ref<HTMLTextAreaElement | undefined>(undefined);
|
const textarea = ref<HTMLTextAreaElement | undefined>(undefined);
|
||||||
|
const uploader = ref<InstanceType<typeof FileUploader> | undefined>(undefined);
|
||||||
const { input: content } = useTextareaAutosize({
|
const { input: content } = useTextareaAutosize({
|
||||||
element: textarea,
|
element: textarea,
|
||||||
});
|
});
|
||||||
|
|
@ -73,6 +78,10 @@ const currentlyBeingTypedEmoji = computed(() => {
|
||||||
return match ? match.at(-1)?.replace(":", "") ?? "" : null;
|
return match ? match.at(-1)?.replace(":", "") ?? "" : null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const openFilePicker = () => {
|
||||||
|
uploader.value?.openFilePicker();
|
||||||
|
};
|
||||||
|
|
||||||
const autocompleteEmoji = (emoji: string) => {
|
const autocompleteEmoji = (emoji: string) => {
|
||||||
// Replace the end of the string with the emoji
|
// Replace the end of the string with the emoji
|
||||||
content.value = content.value?.replace(
|
content.value = content.value?.replace(
|
||||||
|
|
@ -84,9 +93,39 @@ const autocompleteEmoji = (emoji: string) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const files = ref<
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
file: File;
|
||||||
|
progress: number;
|
||||||
|
api_id?: string;
|
||||||
|
}[]
|
||||||
|
>([]);
|
||||||
|
|
||||||
|
const handlePaste = (event: ClipboardEvent) => {
|
||||||
|
if (event.clipboardData) {
|
||||||
|
event.preventDefault();
|
||||||
|
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) {
|
||||||
|
files.value.push(
|
||||||
|
...newFiles.map((file) => ({
|
||||||
|
id: nanoid(),
|
||||||
|
file,
|
||||||
|
progress: 0,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
watch(Control_Alt, () => {
|
watch(Control_Alt, () => {
|
||||||
chosenSplash.value = splashes[Math.floor(Math.random() * splashes.length)];
|
chosenSplash.value = splashes[Math.floor(Math.random() * splashes.length)];
|
||||||
});
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
useListen("composer:reply", (note: Status) => {
|
useListen("composer:reply", (note: Status) => {
|
||||||
respondingTo.value = note;
|
respondingTo.value = note;
|
||||||
|
|
@ -129,7 +168,7 @@ const send = async () => {
|
||||||
Authorization: `Bearer ${tokenData.value?.access_token}`,
|
Authorization: `Bearer ${tokenData.value?.access_token}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
status: content.value.trim(),
|
status: content.value?.trim() ?? "",
|
||||||
content_type: markdown.value ? "text/markdown" : "text/plain",
|
content_type: markdown.value ? "text/markdown" : "text/plain",
|
||||||
in_reply_to_id:
|
in_reply_to_id:
|
||||||
respondingType.value === "reply"
|
respondingType.value === "reply"
|
||||||
|
|
@ -141,6 +180,9 @@ const send = async () => {
|
||||||
: null,
|
: null,
|
||||||
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
|
||||||
|
.filter((file) => !!file.api_id)
|
||||||
|
.map((file) => file.api_id),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
.then(async (res) => {
|
.then(async (res) => {
|
||||||
|
|
|
||||||
130
components/composer/file-uploader.vue
Normal file
130
components/composer/file-uploader.vue
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
<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">
|
||||||
|
<div v-for="(data) in files.toReversed()" :key="data.id" role="button" tabindex="0"
|
||||||
|
:class="['size-28 bg-dark-800 rounded flex items-center relative justify-center ring-1 ring-white/20 overflow-hidden', data.progress !== 1.0 && 'animate-pulse']"
|
||||||
|
@keydown.enter="removeFile(data.id)">
|
||||||
|
<template v-if="data.file.type.startsWith('image/')">
|
||||||
|
<img :src="createObjectURL(data.file)" class="w-full h-full object-cover cursor-default"
|
||||||
|
alt="Preview of file" />
|
||||||
|
</template>
|
||||||
|
<template v-else-if="data.file.type.startsWith('video/')">
|
||||||
|
<video :src="createObjectURL(data.file)" class="w-full h-full object-cover cursor-default"></video>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<iconify-icon :icon="getIcon(data.file.type)" width="none" class="size-6" />
|
||||||
|
</template>
|
||||||
|
<div class="absolute bottom-1 right-1 p-1 bg-dark-800/70 text-white text-xs rounded cursor-default flex flex-row items-center gap-x-1"
|
||||||
|
aria-label="File size">
|
||||||
|
{{ formatBytes(data.file.size) }}
|
||||||
|
<!-- Loader spinner -->
|
||||||
|
<iconify-icon v-if="data.progress < 1.0" icon="tabler:loader-2" width="none"
|
||||||
|
class="size-4 animate-spin text-pink-500" />
|
||||||
|
</div>
|
||||||
|
<button class="absolute top-1 right-1 p-1 bg-dark-800/50 text-white text-xs rounded size-6"
|
||||||
|
role="button" tabindex="0" @pointerup="removeFile(data.id)" @keydown.enter="removeFile(data.id)">
|
||||||
|
<iconify-icon icon="tabler:x" width="none" class="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { nanoid } from "nanoid";
|
||||||
|
|
||||||
|
const files = defineModel<
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
file: File;
|
||||||
|
// 0.0 -> Not uploading
|
||||||
|
// 0.5 -> Uploading
|
||||||
|
// 1.0 -> Uploaded
|
||||||
|
progress: number;
|
||||||
|
api_id?: string;
|
||||||
|
}[]
|
||||||
|
>("files", {
|
||||||
|
required: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const tokenData = useTokenData();
|
||||||
|
const client = useMegalodon(tokenData);
|
||||||
|
const fileInput = ref<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
const openFilePicker = () => {
|
||||||
|
fileInput.value?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
openFilePicker,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createObjectURL = URL.createObjectURL;
|
||||||
|
|
||||||
|
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,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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 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";
|
||||||
|
};
|
||||||
|
|
||||||
|
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]}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFile = (id: string) => {
|
||||||
|
files.value = files.value.filter((data) => data.id !== id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadFile = async (file: File) => {
|
||||||
|
files.value = files.value.map((data) => {
|
||||||
|
if (data.file === file) {
|
||||||
|
return { ...data, progress: 0.5 };
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
});
|
||||||
|
|
||||||
|
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, progress: 1.0 };
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
@ -34,10 +34,11 @@ export default defineNuxtConfig({
|
||||||
? "unsafe-none"
|
? "unsafe-none"
|
||||||
: "require-corp",
|
: "require-corp",
|
||||||
contentSecurityPolicy: {
|
contentSecurityPolicy: {
|
||||||
"img-src": ["'self'", "data:", "https:"],
|
"img-src": ["'self'", "data:", "https:", "blob:"],
|
||||||
"script-src": ["'nonce-{{nonce}}'", "'strict-dynamic'"],
|
"script-src": ["'nonce-{{nonce}}'", "'strict-dynamic'"],
|
||||||
// Add https because of some browsers blocking form-action to 'self' if the page is from a redirect
|
// Add https because of some browsers blocking form-action to 'self' if the page is from a redirect
|
||||||
"form-action": ["'self'", "https:"],
|
"form-action": ["'self'", "https:"],
|
||||||
|
"media-src": ["'self'", "https:", "blob:"],
|
||||||
},
|
},
|
||||||
crossOriginResourcePolicy: "cross-origin",
|
crossOriginResourcePolicy: "cross-origin",
|
||||||
xFrameOptions: "DENY",
|
xFrameOptions: "DENY",
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@
|
||||||
"magic-regexp": "^0.8.0",
|
"magic-regexp": "^0.8.0",
|
||||||
"megalodon": "^10.0.1",
|
"megalodon": "^10.0.1",
|
||||||
"mitt": "^3.0.1",
|
"mitt": "^3.0.1",
|
||||||
|
"nanoid": "^5.0.7",
|
||||||
"nuxt": "^3.11.2",
|
"nuxt": "^3.11.2",
|
||||||
"nuxt-headlessui": "^1.2.0",
|
"nuxt-headlessui": "^1.2.0",
|
||||||
"nuxt-security": "^2.0.0-rc.6",
|
"nuxt-security": "^2.0.0-rc.6",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue