mirror of
https://github.com/versia-pub/frontend.git
synced 2025-12-06 08:28: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>
|
||||
<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>
|
||||
<Textarea :placeholder="chosenSplash" class="!border-none !ring-0 !outline-none rounded-none p-0 max-h-full min-h-48 !ring-offset-0" />
|
||||
|
||||
<DialogFooter class="items-center flex-row">
|
||||
<Button variant="ghost" size="icon">
|
||||
<AtSign class="!size-5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon">
|
||||
<LetterText class="!size-5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Smile class="!size-5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon">
|
||||
<FilePlus2 class="!size-5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon">
|
||||
<TriangleAlert class="!size-5" />
|
||||
</Button>
|
||||
<Button type="submit" size="lg" class="ml-auto">
|
||||
{{ relation?.type === "edit" ? "Save" : "Send" }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</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";
|
||||
import type { Status } from "@versia/client/types";
|
||||
import {
|
||||
AtSign,
|
||||
FilePlus2,
|
||||
LetterText,
|
||||
Smile,
|
||||
TriangleAlert,
|
||||
} from "lucide-vue-next";
|
||||
import { Button } from "../ui/button";
|
||||
import { Textarea } from "../ui/textarea";
|
||||
|
||||
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;
|
||||
defineProps<{
|
||||
relation?: {
|
||||
type: "reply" | "reblog" | "edit";
|
||||
note: Status;
|
||||
};
|
||||
}>();
|
||||
|
||||
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,
|
||||
);
|
||||
const splashes = useConfig().COMPOSER_SPLASHES;
|
||||
const chosenSplash = splashes[Math.floor(Math.random() * splashes.length)];
|
||||
</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>
|
||||
import { char, createRegExp, exactly } from "magic-regexp";
|
||||
import EmojiSuggestbox from "../composer/emoji-suggestbox.vue";
|
||||
import MentionSuggestbox from "../composer/mention-suggestbox.vue";
|
||||
import EmojiSuggestbox from "../composer-old/emoji-suggestbox.vue";
|
||||
import MentionSuggestbox from "../composer-old/mention-suggestbox.vue";
|
||||
|
||||
defineOptions({
|
||||
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>
|
||||
|
||||
<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" />
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<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">
|
||||
<ReblogHeader v-if="note.reblog" :avatar="note.account.avatar" :display-name="note.account.display_name"
|
||||
:url="reblogAccountUrl" />
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
House,
|
||||
LogOut,
|
||||
MapIcon,
|
||||
Pen,
|
||||
Settings2,
|
||||
} from "lucide-vue-next";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
|
||||
|
|
@ -53,6 +54,7 @@ import {
|
|||
SidebarRail,
|
||||
SidebarTrigger,
|
||||
} from "~/components/ui/sidebar";
|
||||
import { Button } from "../ui/button";
|
||||
import ThemeSwitcher from "./theme-switcher.vue";
|
||||
|
||||
const data = {
|
||||
|
|
@ -237,6 +239,12 @@ const instance = useInstance();
|
|||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<Button variant="default" size="lg" class="w-full" @click="useEvent('composer:open')">
|
||||
<Pen />
|
||||
Compose
|
||||
</Button>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarFooter>
|
||||
<SidebarRail />
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ export const buttonVariants = cva(
|
|||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary2-foreground shadow hover:bg-primary/90",
|
||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
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>
|
||||
<main
|
||||
: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',
|
||||
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 />
|
||||
<slot />
|
||||
</Sidebar>
|
||||
<ComposerModal />
|
||||
<ComposerDialog />
|
||||
<AttachmentDialog />
|
||||
</template>
|
||||
|
||||
<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 Sidebar from "~/components/sidebars/sidebar.vue";
|
||||
import AttachmentDialog from "~/components/social-elements/notes/attachment-dialog.vue";
|
||||
|
|
|
|||
Loading…
Reference in a new issue