feat: Add new composer

This commit is contained in:
Jesse Wierzbinski 2024-11-30 19:15:23 +01:00
parent 49d356e2ab
commit 4dfbd845d3
No known key found for this signature in database
40 changed files with 584 additions and 222 deletions

BIN
bun.lockb

Binary file not shown.

View 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>

View file

@ -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>

View 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>

View file

@ -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,

View file

@ -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>

View file

@ -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" />

View file

@ -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 />

View file

@ -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:

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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";

View file

@ -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,
)" )"

View 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>

View file

@ -0,0 +1 @@
export { default as Textarea } from "./Textarea.vue";

View file

@ -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";