refactor: ♻️ Redesign post composer

This commit is contained in:
Jesse Wierzbinski 2025-12-09 22:12:23 +01:00
parent ef7475aead
commit 7ff9d2302a
No known key found for this signature in database
17 changed files with 327 additions and 203 deletions

View file

@ -1,18 +0,0 @@
<template>
<Tooltip>
<TooltipTrigger as="div">
<slot />
</TooltipTrigger>
<TooltipContent>
<p>{{ tooltip }}</p>
</TooltipContent>
</Tooltip>
</template>
<script lang="ts" setup>
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
const { tooltip } = defineProps<{
tooltip: string;
}>();
</script>

View file

@ -1,85 +0,0 @@
<template>
<ComposerButton :tooltip="m.game_tough_seal_adore()">
<Button variant="ghost" size="icon">
<AtSign class="!size-5" />
</Button>
</ComposerButton>
<ComposerButton :tooltip="m.plane_born_koala_hope()">
<Toggle variant="default" size="sm" :model-value="contentType === 'text/html'" @update:model-value="
(i) =>
(contentType = i ? 'text/html' : 'text/plain')
">
<LetterText class="!size-5" />
</Toggle>
</ComposerButton>
<VisibilityPicker v-model:visibility="visibility">
<Button variant="ghost" size="icon" :disabled="relation?.type === 'edit'">
<component :is="visibilities[visibility].icon" />
</Button>
</VisibilityPicker>
<ComposerButton :tooltip="m.blue_ornate_coyote_tickle()">
<Button variant="ghost" size="icon">
<Smile class="!size-5" />
</Button>
</ComposerButton>
<ComposerButton :tooltip="m.top_patchy_earthworm_vent()">
<Button variant="ghost" size="icon" @click="emit('pickFile')">
<FilePlus2 class="!size-5" />
</Button>
</ComposerButton>
<ComposerButton :tooltip="m.frail_broad_mallard_dart()">
<Toggle variant="default" size="sm" v-model="sensitive">
<TriangleAlert class="!size-5" />
</Toggle>
</ComposerButton>
<CharacterCounter class="ml-auto" :max="authStore.instance?.configuration.statuses.max_characters ?? 0" :current="rawContent.length" />
<Button type="submit" size="lg" :disabled="sending || !canSend" @click="emit('submit')">
<Loader v-if="sending" class="!size-5 animate-spin" />
{{
relation?.type === "edit"
? m.gaudy_strong_puma_slide()
: m.free_teal_bulldog_learn()
}}
</Button>
</template>
<script lang="ts" setup>
import {
AtSign,
FilePlus2,
LetterText,
Loader,
Smile,
TriangleAlert,
} from "lucide-vue-next";
import * as m from "~~/paraglide/messages.js";
import { Button } from "../ui/button";
import { Toggle } from "../ui/toggle";
import ComposerButton from "./button.vue";
import CharacterCounter from "./character-counter.vue";
import { visibilities } from "./visibilities";
import VisibilityPicker from "./visibility-picker.vue";
const { relation, sending, canSend, rawContent } = defineProps<{
relation?: ComposerState["relation"];
sending: boolean;
canSend: boolean;
rawContent: string;
}>();
const authStore = useAuthStore();
const contentType = defineModel<ComposerState["contentType"]>("contentType", {
required: true,
});
const visibility = defineModel<ComposerState["visibility"]>("visibility", {
required: true,
});
const sensitive = defineModel<ComposerState["sensitive"]>("sensitive", {
required: true,
});
const emit = defineEmits<{
submit: [];
pickFile: [];
}>();
</script>

View file

@ -1,39 +0,0 @@
<template>
<Tooltip>
<TooltipTrigger as-child>
<div v-bind="$attrs" class="m-1">
<TriangleAlert v-if="isOverflowing" class="text-destructive-foreground size-6" />
<svg v-else viewBox="0 0 100 100" class="transform rotate-[-90deg] size-6">
<!-- Background Circle -->
<circle cx="50" cy="50" r="46" stroke="currentColor" class="text-muted" stroke-width="8"
fill="none" />
<!-- Progress Circle -->
<circle cx="50" cy="50" r="46" stroke="currentColor" stroke-width="8" fill="none"
stroke-dasharray="100" :stroke-dashoffset="100 - percentage" pathLength="100"
stroke-linecap="round" class="text-accent-foreground transition-all duration-500" />
</svg>
</div>
</TooltipTrigger>
<TooltipContent class="text-center">
<p>{{ current }} / {{ max }}</p>
<p v-if="isOverflowing">Too long!</p>
</TooltipContent>
</Tooltip>
</template>
<script lang="ts" setup>
import { TriangleAlert } from "lucide-vue-next";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
const { max, current } = defineProps<{
max: number;
current: number;
}>();
const percentage = computed(() => {
return Math.min((current / max) * 100, 100);
});
const isOverflowing = computed(() => {
return current > max;
});
</script>

View file

@ -3,29 +3,94 @@
<Note :note="relation.note" :hide-actions="true" :small-layout="true" />
</div>
<ContentWarning v-if="store.sensitive" v-model="store.contentWarning" />
<EditorContent @paste-files="uploadFiles" v-model:content="store.content" v-model:raw-content="store.rawContent" :placeholder="getRandomSplash()"
class="[&>.tiptap]:!border-none [&>.tiptap]:!ring-0 [&>.tiptap]:!outline-none [&>.tiptap]:rounded-none p-0 [&>.tiptap]:max-h-[50dvh] [&>.tiptap]:overflow-y-auto [&>.tiptap]:min-h-48 [&>.tiptap]:!ring-offset-0 [&>.tiptap]:h-full"
:disabled="store.sending" :mode="store.contentType === 'text/html' ? 'rich' : 'plain'" />
<InputGroup class="p-1">
<InputGroupAddon v-if="store.sensitive" align="block-start" class="pt-3">
<Input v-model:model-value="store.contentWarning" placeholder="Put your content warning here" />
</InputGroupAddon>
<div class="w-full flex flex-row gap-2 overflow-x-auto *:shrink-0 pb-2">
<input type="file" ref="fileInput" @change="uploadFileFromEvent" class="hidden" multiple />
<Files v-model:files="store.files" :composer-key="composerKey" />
</div>
<EditorContent data-slot="input-group-control" @paste-files="uploadFiles" v-model:content="store.content"
v-model:raw-content="store.rawContent" :placeholder="getRandomSplash()"
class=" placeholder:text-muted-foreground flex field-sizing-content min-h-58 w-full px-4 text-base disabled:opacity-50 md:text-sm flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none"
:disabled="store.sending" :mode="store.contentType === 'text/html' ? 'rich' : 'plain'" />
<DialogFooter class="items-center flex-row overflow-x-auto">
<ComposerButtons @submit="send" @pick-file="fileInput?.click()" v-model:content-type="store.contentType" v-model:sensitive="store.sensitive" v-model:visibility="store.visibility" :relation="store.relation" :sending="store.sending" :can-send="store.canSend" :raw-content="store.rawContent" />
</DialogFooter>
<InputGroupAddon v-if="store.files.length > 0" align="block-end" class="overflow-x-auto *:shrink-0">
<Files v-model:files="store.files" :composer-key="composerKey" />
</InputGroupAddon>
<InputGroupAddon align="block-end">
<Select v-model:model-value="store.contentType">
<SelectTrigger as-child disable-default-classes disable-select-icon>
<InputGroupButton variant="ghost" size="icon-sm">
<LetterText v-if="store.contentType === 'text/html'" />
<Type v-else />
</InputGroupButton>
</SelectTrigger>
<SelectContent>
<SelectItem value="text/plain">
Plain text
</SelectItem>
<SelectItem value="text/html">
Rich text
</SelectItem>
</SelectContent>
</Select>
<VisibilityPicker v-model:visibility="store.visibility">
<InputGroupButton variant="ghost" size="icon-sm" :disabled="store.relation?.type === 'edit'">
<component :is="visibilities[store.visibility].icon" />
</InputGroupButton>
</VisibilityPicker>
<InputGroupButton variant="ghost" size="icon-sm" @click="fileInput?.click()">
<FilePlus2 />
</InputGroupButton>
<Toggle size="sm" v-model="store.sensitive">
<TriangleAlert />
</Toggle>
<InputGroupText :class="['ml-auto', charactersLeft < 0 && 'text-destructive']">
{{ charactersLeft.toLocaleString(getLocale(), {
maximumFractionDigits: 2,
notation: 'compact',
compactDisplay: 'short',
}) }}
</InputGroupText>
<Separator orientation="vertical" class="h-4!" />
<InputGroupButton variant="default" size="icon-sm" :disabled="store.sending || !store.canSend"
@click="send">
<Spinner v-if="store.sending" />
<ArrowUp v-else />
<span class="sr-only">Send</span>
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
<input type="file" ref="fileInput" @change="uploadFileFromEvent" class="hidden" multiple />
</template>
<script lang="ts" setup>
import {
ArrowUp,
FilePlus2,
LetterText,
TriangleAlert,
Type,
} from "lucide-vue-next";
import { Separator } from "reka-ui";
import Note from "~/components/notes/note.vue";
import { getLocale } from "~~/paraglide/runtime";
import EditorContent from "../editor/content.vue";
import { DialogFooter } from "../ui/dialog";
import ComposerButtons from "./buttons.vue";
import ContentWarning from "./content-warning.vue";
import { Input } from "../ui/input";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
} from "../ui/input-group";
import { Select, SelectContent, SelectItem, SelectTrigger } from "../ui/select";
import { Spinner } from "../ui/spinner";
import { Toggle } from "../ui/toggle";
import Files from "./files.vue";
import { visibilities } from "./visibilities";
import VisibilityPicker from "./visibility-picker.vue";
const props = defineProps<{
relation?: ComposerState["relation"];
@ -38,6 +103,12 @@ const composerKey = props.relation
? (`${props.relation.type}-${props.relation.note.id}` as const)
: "blank";
const store = useComposerStore(composerKey)();
const authStore = useAuthStore();
const charactersLeft = computed(() => {
const max = authStore.instance?.configuration.statuses.max_characters ?? 0;
return max - store.rawContent.length;
});
watch([Control_Enter, Command_Enter], () => {
if (store.sending || !preferences.ctrl_enter_send.value) {

View file

@ -1,9 +0,0 @@
<template>
<Input v-model:model-value="contentWarning" placeholder="Put your content warning here" />
</template>
<script lang="ts" setup>
import { Input } from "../ui/input";
const contentWarning = defineModel<string>();
</script>

View file

@ -81,7 +81,7 @@ const relation = ref(
>
<DialogContent
:hide-close="true"
class="sm:max-w-xl max-w-full w-[calc(100%-2*0.5rem)] grid-cols-1 max-h-[90dvh] p-5 pt-6 top-2 sm:top-1/2 translate-y-0 sm:-translate-y-1/2"
class="sm:max-w-xl max-w-full w-[calc(100%-2*0.5rem)] grid-cols-1 max-h-[90dvh] p-0 top-2 sm:top-1/2 translate-y-0 sm:-translate-y-1/2 border-none bg-transparent shadow-none"
>
<DialogTitle class="sr-only">
{{