mirror of
https://github.com/versia-pub/frontend.git
synced 2025-12-06 16:38:20 +01:00
feat: ✨ Add new composer
This commit is contained in:
parent
49d356e2ab
commit
4dfbd845d3
222
components/composer-old/composer.vue
Normal file
222
components/composer-old/composer.vue
Normal file
|
|
@ -0,0 +1,222 @@
|
||||||
|
<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,222 +1,47 @@
|
||||||
<template>
|
<template>
|
||||||
<RespondingTo v-if="respondingTo" :respondingTo="respondingTo" />
|
<Textarea :placeholder="chosenSplash" class="!border-none !ring-0 !outline-none rounded-none p-0 max-h-full min-h-48 !ring-offset-0" />
|
||||||
<div class="px-6 pb-4 pt-5">
|
|
||||||
<RichTextbox v-model:content="content" :loading="loading" :chosenSplash="chosenSplash" :characterLimit="characterLimit"
|
<DialogFooter class="items-center flex-row">
|
||||||
:handle-paste="handlePaste" />
|
<Button variant="ghost" size="icon">
|
||||||
<ContentWarning v-model:cw="cw" v-model:cwContent="cwContent" />
|
<AtSign class="!size-5" />
|
||||||
<FileUploader :files="files" ref="uploader" @add-file="(newFile) => {
|
</Button>
|
||||||
files.push(newFile);
|
<Button variant="ghost" size="icon">
|
||||||
}" @change-file="(changedFile) => {
|
<LetterText class="!size-5" />
|
||||||
const index = files.findIndex((file) => file.id === changedFile.id);
|
</Button>
|
||||||
if (index !== -1) {
|
<Button variant="ghost" size="icon">
|
||||||
files[index] = changedFile;
|
<Smile class="!size-5" />
|
||||||
}
|
</Button>
|
||||||
}" @remove-file="(id) => {
|
<Button variant="ghost" size="icon">
|
||||||
files.splice(files.findIndex((file) => file.id === id), 1);
|
<FilePlus2 class="!size-5" />
|
||||||
}" />
|
</Button>
|
||||||
<ActionButtons v-model:content="content" v-model:markdown="markdown" v-model:cw="cw" :loading="loading" :canSubmit="canSubmit"
|
<Button variant="ghost" size="icon">
|
||||||
:respondingType="respondingType" @send="send" @file-picker-open="openFilePicker" />
|
<TriangleAlert class="!size-5" />
|
||||||
</div>
|
</Button>
|
||||||
|
<Button type="submit" size="lg" class="ml-auto">
|
||||||
|
{{ relation?.type === "edit" ? "Save" : "Send" }}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { Instance, Status } from "@versia/client/types";
|
import type { Status } from "@versia/client/types";
|
||||||
import { nanoid } from "nanoid";
|
import {
|
||||||
import { computed, onMounted, ref, watch, watchEffect } from "vue";
|
AtSign,
|
||||||
import { useConfig, useEvent, useListen, useMagicKeys } from "#imports";
|
FilePlus2,
|
||||||
import ActionButtons from "./action-buttons.vue";
|
LetterText,
|
||||||
import ContentWarning from "./content-warning.vue";
|
Smile,
|
||||||
import RespondingTo from "./responding-to.vue";
|
TriangleAlert,
|
||||||
import RichTextbox from "./rich-text-box.vue";
|
} from "lucide-vue-next";
|
||||||
// biome-ignore lint/style/useImportType: <explanation>
|
import { Button } from "../ui/button";
|
||||||
import FileUploader from "./uploader/uploader.vue";
|
import { Textarea } from "../ui/textarea";
|
||||||
import type { FileData } from "./uploader/uploader.vue";
|
|
||||||
|
|
||||||
const uploader = ref<InstanceType<typeof FileUploader> | undefined>(undefined);
|
defineProps<{
|
||||||
const { Control_Enter, Command_Enter, Control_Alt } = useMagicKeys();
|
relation?: {
|
||||||
const content = ref("");
|
type: "reply" | "reblog" | "edit";
|
||||||
const respondingTo = ref<Status | null>(null);
|
note: Status;
|
||||||
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 splashes = useConfig().COMPOSER_SPLASHES;
|
||||||
const canSubmit = computed(
|
const chosenSplash = splashes[Math.floor(Math.random() * splashes.length)];
|
||||||
() =>
|
|
||||||
(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>
|
</script>
|
||||||
20
components/composer/dialog.vue
Normal file
20
components/composer/dialog.vue
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||||
|
import Composer from "./composer.vue";
|
||||||
|
|
||||||
|
useListen("composer:open", () => {
|
||||||
|
if (identity.value) {
|
||||||
|
open.value = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const open = ref(false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog v-model:open="open">
|
||||||
|
<DialogContent :hide-close="true" class="sm:max-w-xl max-w-full w-full grid-rows-[minmax(0,1fr)_auto] max-h-[90dvh] p-5 pt-6 top-0 sm:top-1/2 translate-y-0 sm:-translate-y-1/2">
|
||||||
|
<Composer />
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
|
@ -17,8 +17,8 @@
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { char, createRegExp, exactly } from "magic-regexp";
|
import { char, createRegExp, exactly } from "magic-regexp";
|
||||||
import EmojiSuggestbox from "../composer/emoji-suggestbox.vue";
|
import EmojiSuggestbox from "../composer-old/emoji-suggestbox.vue";
|
||||||
import MentionSuggestbox from "../composer/mention-suggestbox.vue";
|
import MentionSuggestbox from "../composer-old/mention-suggestbox.vue";
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
inheritAttrs: false,
|
inheritAttrs: false,
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
<div :class="['prose block relative dark:prose-invert duration-200 !max-w-full break-words prose-a:no-underline prose-a:hover:underline', $style.content]" v-html="content">
|
<div :class="['prose block relative dark:prose-invert duration-200 !max-w-full break-words prose-a:no-underline prose-a:hover:underline', $style.content]" v-html="content">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="quote" class="mt-4 rounded border">
|
<div v-if="quote" class="mt-4 rounded border overflow-hidden">
|
||||||
<Note :note="quote" :hide-actions="true" :small-layout="true" />
|
<Note :note="quote" :hide-actions="true" :small-layout="true" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<Card as="article" class="rounded-none border-0 duration-200 shadow-none">
|
<Card as="article" class="rounded-none border-0 duration-200 shadow-none max-w-full">
|
||||||
<CardHeader class="pb-4" as="header">
|
<CardHeader class="pb-4" as="header">
|
||||||
<ReblogHeader v-if="note.reblog" :avatar="note.account.avatar" :display-name="note.account.display_name"
|
<ReblogHeader v-if="note.reblog" :avatar="note.account.avatar" :display-name="note.account.display_name"
|
||||||
:url="reblogAccountUrl" />
|
:url="reblogAccountUrl" />
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
House,
|
House,
|
||||||
LogOut,
|
LogOut,
|
||||||
MapIcon,
|
MapIcon,
|
||||||
|
Pen,
|
||||||
Settings2,
|
Settings2,
|
||||||
} from "lucide-vue-next";
|
} from "lucide-vue-next";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
|
||||||
|
|
@ -53,6 +54,7 @@ import {
|
||||||
SidebarRail,
|
SidebarRail,
|
||||||
SidebarTrigger,
|
SidebarTrigger,
|
||||||
} from "~/components/ui/sidebar";
|
} from "~/components/ui/sidebar";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
import ThemeSwitcher from "./theme-switcher.vue";
|
import ThemeSwitcher from "./theme-switcher.vue";
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
|
|
@ -237,6 +239,12 @@ const instance = useInstance();
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<Button variant="default" size="lg" class="w-full" @click="useEvent('composer:open')">
|
||||||
|
<Pen />
|
||||||
|
Compose
|
||||||
|
</Button>
|
||||||
|
</SidebarMenuItem>
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarFooter>
|
</SidebarFooter>
|
||||||
<SidebarRail />
|
<SidebarRail />
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ export const buttonVariants = cva(
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default:
|
||||||
"bg-primary text-primary2-foreground shadow hover:bg-primary/90",
|
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||||
destructive:
|
destructive:
|
||||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||||
outline:
|
outline:
|
||||||
|
|
|
||||||
19
components/ui/dialog/Dialog.vue
Normal file
19
components/ui/dialog/Dialog.vue
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
DialogRoot,
|
||||||
|
type DialogRootEmits,
|
||||||
|
type DialogRootProps,
|
||||||
|
useForwardPropsEmits,
|
||||||
|
} from "radix-vue";
|
||||||
|
|
||||||
|
const props = defineProps<DialogRootProps>();
|
||||||
|
const emits = defineEmits<DialogRootEmits>();
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(props, emits);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogRoot v-bind="forwarded">
|
||||||
|
<slot />
|
||||||
|
</DialogRoot>
|
||||||
|
</template>
|
||||||
11
components/ui/dialog/DialogClose.vue
Normal file
11
components/ui/dialog/DialogClose.vue
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { DialogClose, type DialogCloseProps } from "radix-vue";
|
||||||
|
|
||||||
|
const props = defineProps<DialogCloseProps>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogClose v-bind="props">
|
||||||
|
<slot />
|
||||||
|
</DialogClose>
|
||||||
|
</template>
|
||||||
56
components/ui/dialog/DialogContent.vue
Normal file
56
components/ui/dialog/DialogContent.vue
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { X } from "lucide-vue-next";
|
||||||
|
import {
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
type DialogContentEmits,
|
||||||
|
type DialogContentProps,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
useForwardPropsEmits,
|
||||||
|
} from "radix-vue";
|
||||||
|
import { type HTMLAttributes, computed } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps<
|
||||||
|
DialogContentProps & {
|
||||||
|
class?: HTMLAttributes["class"];
|
||||||
|
hideClose?: boolean;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
const emits = defineEmits<DialogContentEmits>();
|
||||||
|
|
||||||
|
const delegatedProps = computed(() => {
|
||||||
|
const { class: _, ...delegated } = props;
|
||||||
|
|
||||||
|
return delegated;
|
||||||
|
});
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay
|
||||||
|
class="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
|
||||||
|
/>
|
||||||
|
<DialogContent
|
||||||
|
v-bind="forwarded"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
|
||||||
|
<DialogClose
|
||||||
|
v-if="!props.hideClose"
|
||||||
|
class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"
|
||||||
|
>
|
||||||
|
<X class="w-4 h-4" />
|
||||||
|
<span class="sr-only">Close</span>
|
||||||
|
</DialogClose>
|
||||||
|
</DialogContent>
|
||||||
|
</DialogPortal>
|
||||||
|
</template>
|
||||||
30
components/ui/dialog/DialogDescription.vue
Normal file
30
components/ui/dialog/DialogDescription.vue
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
DialogDescription,
|
||||||
|
type DialogDescriptionProps,
|
||||||
|
useForwardProps,
|
||||||
|
} from "radix-vue";
|
||||||
|
import { type HTMLAttributes, computed } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps<
|
||||||
|
DialogDescriptionProps & { class?: HTMLAttributes["class"] }
|
||||||
|
>();
|
||||||
|
|
||||||
|
const delegatedProps = computed(() => {
|
||||||
|
const { class: _, ...delegated } = props;
|
||||||
|
|
||||||
|
return delegated;
|
||||||
|
});
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogDescription
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
:class="cn('text-sm text-muted-foreground', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DialogDescription>
|
||||||
|
</template>
|
||||||
19
components/ui/dialog/DialogFooter.vue
Normal file
19
components/ui/dialog/DialogFooter.vue
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { HTMLAttributes } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps<{ class?: HTMLAttributes["class"] }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
16
components/ui/dialog/DialogHeader.vue
Normal file
16
components/ui/dialog/DialogHeader.vue
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { HTMLAttributes } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
:class="cn('flex flex-col gap-y-1.5 text-center sm:text-left', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
61
components/ui/dialog/DialogScrollContent.vue
Normal file
61
components/ui/dialog/DialogScrollContent.vue
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { X } from "lucide-vue-next";
|
||||||
|
import {
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
type DialogContentEmits,
|
||||||
|
type DialogContentProps,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
useForwardPropsEmits,
|
||||||
|
} from "radix-vue";
|
||||||
|
import { type HTMLAttributes, computed } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps<
|
||||||
|
DialogContentProps & { class?: HTMLAttributes["class"] }
|
||||||
|
>();
|
||||||
|
const emits = defineEmits<DialogContentEmits>();
|
||||||
|
|
||||||
|
const delegatedProps = computed(() => {
|
||||||
|
const { class: _, ...delegated } = props;
|
||||||
|
|
||||||
|
return delegated;
|
||||||
|
});
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay
|
||||||
|
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
|
||||||
|
>
|
||||||
|
<DialogContent
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
v-bind="forwarded"
|
||||||
|
@pointer-down-outside="(event) => {
|
||||||
|
const originalEvent = event.detail.originalEvent;
|
||||||
|
const target = originalEvent.target as HTMLElement;
|
||||||
|
if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
|
||||||
|
<DialogClose
|
||||||
|
class="absolute top-3 right-3 p-0.5 transition-colors rounded-md hover:bg-secondary"
|
||||||
|
>
|
||||||
|
<X class="w-4 h-4" />
|
||||||
|
<span class="sr-only">Close</span>
|
||||||
|
</DialogClose>
|
||||||
|
</DialogContent>
|
||||||
|
</DialogOverlay>
|
||||||
|
</DialogPortal>
|
||||||
|
</template>
|
||||||
31
components/ui/dialog/DialogTitle.vue
Normal file
31
components/ui/dialog/DialogTitle.vue
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { DialogTitle, type DialogTitleProps, useForwardProps } from "radix-vue";
|
||||||
|
import { type HTMLAttributes, computed } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps<
|
||||||
|
DialogTitleProps & { class?: HTMLAttributes["class"] }
|
||||||
|
>();
|
||||||
|
|
||||||
|
const delegatedProps = computed(() => {
|
||||||
|
const { class: _, ...delegated } = props;
|
||||||
|
|
||||||
|
return delegated;
|
||||||
|
});
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogTitle
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'text-lg font-semibold leading-none tracking-tight',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DialogTitle>
|
||||||
|
</template>
|
||||||
11
components/ui/dialog/DialogTrigger.vue
Normal file
11
components/ui/dialog/DialogTrigger.vue
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { DialogTrigger, type DialogTriggerProps } from "radix-vue";
|
||||||
|
|
||||||
|
const props = defineProps<DialogTriggerProps>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogTrigger v-bind="props">
|
||||||
|
<slot />
|
||||||
|
</DialogTrigger>
|
||||||
|
</template>
|
||||||
9
components/ui/dialog/index.ts
Normal file
9
components/ui/dialog/index.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
export { default as Dialog } from "./Dialog.vue";
|
||||||
|
export { default as DialogClose } from "./DialogClose.vue";
|
||||||
|
export { default as DialogContent } from "./DialogContent.vue";
|
||||||
|
export { default as DialogDescription } from "./DialogDescription.vue";
|
||||||
|
export { default as DialogFooter } from "./DialogFooter.vue";
|
||||||
|
export { default as DialogHeader } from "./DialogHeader.vue";
|
||||||
|
export { default as DialogScrollContent } from "./DialogScrollContent.vue";
|
||||||
|
export { default as DialogTitle } from "./DialogTitle.vue";
|
||||||
|
export { default as DialogTrigger } from "./DialogTrigger.vue";
|
||||||
|
|
@ -10,7 +10,7 @@ const props = defineProps<{
|
||||||
<template>
|
<template>
|
||||||
<main
|
<main
|
||||||
:class="cn(
|
:class="cn(
|
||||||
'relative flex min-h-svh flex-1 flex-col bg-background',
|
'relative flex min-h-svh max-w-full flex-1 flex-col bg-background',
|
||||||
'peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow',
|
'peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow',
|
||||||
props.class,
|
props.class,
|
||||||
)"
|
)"
|
||||||
|
|
|
||||||
23
components/ui/textarea/Textarea.vue
Normal file
23
components/ui/textarea/Textarea.vue
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useVModel } from "@vueuse/core";
|
||||||
|
import type { HTMLAttributes } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"];
|
||||||
|
defaultValue?: string | number;
|
||||||
|
modelValue?: string | number;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emits =
|
||||||
|
defineEmits<(e: "update:modelValue", payload: string | number) => void>();
|
||||||
|
|
||||||
|
const modelValue = useVModel(props, "modelValue", emits, {
|
||||||
|
passive: true,
|
||||||
|
defaultValue: props.defaultValue,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<textarea v-model="modelValue" :class="cn('flex min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50', props.class)" />
|
||||||
|
</template>
|
||||||
1
components/ui/textarea/index.ts
Normal file
1
components/ui/textarea/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { default as Textarea } from "./Textarea.vue";
|
||||||
|
|
@ -3,12 +3,12 @@
|
||||||
<SquarePattern />
|
<SquarePattern />
|
||||||
<slot />
|
<slot />
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
<ComposerModal />
|
<ComposerDialog />
|
||||||
<AttachmentDialog />
|
<AttachmentDialog />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import ComposerModal from "~/components/composer/modal.client.vue";
|
import ComposerDialog from "~/components/composer/dialog.vue";
|
||||||
import SquarePattern from "~/components/graphics/square-pattern.vue";
|
import SquarePattern from "~/components/graphics/square-pattern.vue";
|
||||||
import Sidebar from "~/components/sidebars/sidebar.vue";
|
import Sidebar from "~/components/sidebars/sidebar.vue";
|
||||||
import AttachmentDialog from "~/components/social-elements/notes/attachment-dialog.vue";
|
import AttachmentDialog from "~/components/social-elements/notes/attachment-dialog.vue";
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue