mirror of
https://github.com/versia-pub/frontend.git
synced 2025-12-06 08:28:20 +01:00
refactor: ♻️ Rewrite composer code
Some checks failed
Some checks failed
This commit is contained in:
parent
fa8603d816
commit
18cf63de51
18
components/composer/button.vue
Normal file
18
components/composer/button.vue
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
<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>
|
||||||
84
components/composer/buttons.vue
Normal file
84
components/composer/buttons.vue
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
<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="(identity as Identity).instance.configuration.statuses.max_characters" :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 { type ComposerState, visibilities } from "./composer";
|
||||||
|
import VisibilityPicker from "./visibility-picker.vue";
|
||||||
|
|
||||||
|
const { relation, sending, canSend, rawContent } = defineProps<{
|
||||||
|
relation?: ComposerState["relation"];
|
||||||
|
sending: boolean;
|
||||||
|
canSend: boolean;
|
||||||
|
rawContent: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
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>
|
||||||
39
components/composer/character-counter.vue
Normal file
39
components/composer/character-counter.vue
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
<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>
|
||||||
240
components/composer/composer.ts
Normal file
240
components/composer/composer.ts
Normal file
|
|
@ -0,0 +1,240 @@
|
||||||
|
import type { ResponseError } from "@versia/client";
|
||||||
|
import type { Attachment, Status, StatusSource } from "@versia/client/schemas";
|
||||||
|
import { AtSign, Globe, Lock, LockOpen } from "lucide-vue-next";
|
||||||
|
import type { FunctionalComponent } from "vue";
|
||||||
|
import { toast } from "vue-sonner";
|
||||||
|
import type { z } from "zod";
|
||||||
|
import * as m from "~/paraglide/messages.js";
|
||||||
|
|
||||||
|
export interface ComposerState {
|
||||||
|
relation?: {
|
||||||
|
type: "reply" | "quote" | "edit";
|
||||||
|
note: z.infer<typeof Status>;
|
||||||
|
source?: z.infer<typeof StatusSource>;
|
||||||
|
};
|
||||||
|
content: string;
|
||||||
|
rawContent: string;
|
||||||
|
sensitive: boolean;
|
||||||
|
contentWarning: string;
|
||||||
|
contentType: "text/html" | "text/plain";
|
||||||
|
visibility: z.infer<typeof Status.shape.visibility>;
|
||||||
|
files: {
|
||||||
|
apiId?: string;
|
||||||
|
file: File;
|
||||||
|
alt?: string;
|
||||||
|
uploading: boolean;
|
||||||
|
updating: boolean;
|
||||||
|
}[];
|
||||||
|
sending: boolean;
|
||||||
|
canSend: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { play } = useAudio();
|
||||||
|
export const state = reactive<ComposerState>({
|
||||||
|
relation: undefined,
|
||||||
|
content: "",
|
||||||
|
rawContent: "",
|
||||||
|
sensitive: false,
|
||||||
|
contentWarning: "",
|
||||||
|
contentType: "text/html",
|
||||||
|
visibility: preferences.default_visibility.value,
|
||||||
|
files: [],
|
||||||
|
sending: false,
|
||||||
|
canSend: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
state,
|
||||||
|
(newState) => {
|
||||||
|
const characterLimit = (identity.value as Identity).instance
|
||||||
|
.configuration.statuses.max_characters;
|
||||||
|
const characterCount = newState.rawContent.length;
|
||||||
|
|
||||||
|
state.canSend =
|
||||||
|
characterCount > 0
|
||||||
|
? characterCount <= characterLimit
|
||||||
|
: newState.files.length > 0;
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
export const visibilities: Record<
|
||||||
|
z.infer<typeof Status.shape.visibility>,
|
||||||
|
{
|
||||||
|
icon: FunctionalComponent;
|
||||||
|
name: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
public: {
|
||||||
|
icon: Globe,
|
||||||
|
name: m.lost_trick_dog_grace(),
|
||||||
|
text: m.last_mean_peacock_zip(),
|
||||||
|
},
|
||||||
|
unlisted: {
|
||||||
|
icon: LockOpen,
|
||||||
|
name: m.funny_slow_jannes_walk(),
|
||||||
|
text: m.grand_strong_gibbon_race(),
|
||||||
|
},
|
||||||
|
private: {
|
||||||
|
icon: Lock,
|
||||||
|
name: m.grassy_empty_raven_startle(),
|
||||||
|
text: m.white_teal_ostrich_yell(),
|
||||||
|
},
|
||||||
|
direct: {
|
||||||
|
icon: AtSign,
|
||||||
|
name: m.pretty_bold_baboon_wave(),
|
||||||
|
text: m.lucky_mean_robin_link(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getRandomSplash = (): string => {
|
||||||
|
const splashes = useConfig().COMPOSER_SPLASHES;
|
||||||
|
|
||||||
|
return splashes[Math.floor(Math.random() * splashes.length)] as string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const calculateMentionsFromReply = (
|
||||||
|
note: z.infer<typeof Status>,
|
||||||
|
): string => {
|
||||||
|
const peopleToMention = note.mentions
|
||||||
|
.concat(note.account)
|
||||||
|
// Deduplicate mentions
|
||||||
|
.filter((men, i, a) => a.indexOf(men) === i)
|
||||||
|
// Remove self
|
||||||
|
.filter((men) => men.id !== identity.value?.account.id);
|
||||||
|
|
||||||
|
if (peopleToMention.length === 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const mentions = peopleToMention.map((me) => `@${me.acct}`).join(" ");
|
||||||
|
|
||||||
|
return `${mentions} `;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fileFromUrl = (url: URL | string): Promise<File> => {
|
||||||
|
return fetch(url).then((response) => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to fetch file");
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.blob().then((blob) => {
|
||||||
|
const file = new File([blob], "file", { type: blob.type });
|
||||||
|
return file;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const stateFromRelation = async (
|
||||||
|
relationType: "reply" | "quote" | "edit",
|
||||||
|
note: z.infer<typeof Status>,
|
||||||
|
source?: z.infer<typeof StatusSource>,
|
||||||
|
): Promise<void> => {
|
||||||
|
state.relation = {
|
||||||
|
type: relationType,
|
||||||
|
note,
|
||||||
|
source,
|
||||||
|
};
|
||||||
|
state.content = note.content || calculateMentionsFromReply(note);
|
||||||
|
state.rawContent = source?.text || "";
|
||||||
|
|
||||||
|
if (relationType === "edit") {
|
||||||
|
state.sensitive = note.sensitive;
|
||||||
|
state.contentWarning = source?.spoiler_text || note.spoiler_text;
|
||||||
|
state.visibility = note.visibility;
|
||||||
|
state.files = await Promise.all(
|
||||||
|
note.media_attachments.map(async (file) => ({
|
||||||
|
apiId: file.id,
|
||||||
|
alt: file.description ?? undefined,
|
||||||
|
file: await fileFromUrl(file.url),
|
||||||
|
uploading: false,
|
||||||
|
updating: false,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const uploadFile = (file: File): Promise<void> => {
|
||||||
|
const index =
|
||||||
|
state.files.push({
|
||||||
|
file,
|
||||||
|
uploading: true,
|
||||||
|
updating: false,
|
||||||
|
}) - 1;
|
||||||
|
|
||||||
|
return client.value
|
||||||
|
.uploadMedia(file)
|
||||||
|
.then((media) => {
|
||||||
|
if (!state.files[index]) {
|
||||||
|
throw new Error("File not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
state.files[index].uploading = false;
|
||||||
|
state.files[index].apiId = (
|
||||||
|
media.data as z.infer<typeof Attachment>
|
||||||
|
).id;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
state.files.splice(index, 1);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const send = async (): Promise<void> => {
|
||||||
|
if (state.sending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.sending = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (state.relation?.type === "edit") {
|
||||||
|
const { data } = await client.value.editStatus(
|
||||||
|
state.relation.note.id,
|
||||||
|
{
|
||||||
|
status: state.content,
|
||||||
|
content_type: state.contentType,
|
||||||
|
sensitive: state.sensitive,
|
||||||
|
spoiler_text: state.sensitive
|
||||||
|
? state.contentWarning
|
||||||
|
: undefined,
|
||||||
|
media_ids: state.files
|
||||||
|
.map((f) => f.apiId)
|
||||||
|
.filter((f) => f !== undefined),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
useEvent("composer:send-edit", data);
|
||||||
|
play("publish");
|
||||||
|
useEvent("composer:close");
|
||||||
|
} else {
|
||||||
|
const { data } = await client.value.postStatus(state.content, {
|
||||||
|
content_type: state.contentType,
|
||||||
|
sensitive: state.sensitive,
|
||||||
|
spoiler_text: state.sensitive
|
||||||
|
? state.contentWarning
|
||||||
|
: undefined,
|
||||||
|
media_ids: state.files
|
||||||
|
.map((f) => f.apiId)
|
||||||
|
.filter((f) => f !== undefined),
|
||||||
|
quote_id:
|
||||||
|
state.relation?.type === "quote"
|
||||||
|
? state.relation.note.id
|
||||||
|
: undefined,
|
||||||
|
in_reply_to_id:
|
||||||
|
state.relation?.type === "reply"
|
||||||
|
? state.relation.note.id
|
||||||
|
: undefined,
|
||||||
|
visibility: state.visibility,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEvent("composer:send", data as z.infer<typeof Status>);
|
||||||
|
play("publish");
|
||||||
|
useEvent("composer:close");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.error((e as ResponseError).message);
|
||||||
|
} finally {
|
||||||
|
state.sending = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -3,358 +3,81 @@
|
||||||
<Note :note="relation.note" :hide-actions="true" :small-layout="true" />
|
<Note :note="relation.note" :hide-actions="true" :small-layout="true" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Input
|
<ContentWarning v-if="state.sensitive" v-model="state.contentWarning" />
|
||||||
v-model:model-value="state.contentWarning"
|
|
||||||
v-if="state.sensitive"
|
|
||||||
placeholder="Put your content warning here"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<EditorContent
|
<EditorContent @paste-files="uploadFiles" v-model:content="state.content" v-model:raw-content="state.rawContent" :placeholder="getRandomSplash()"
|
||||||
v-model:content="state.content"
|
|
||||||
:placeholder="chosenSplash"
|
|
||||||
class="*:!border-none *:!ring-0 *:!outline-none *:rounded-none p-0 *:max-h-[50dvh] *:overflow-y-auto *:min-h-48 *:!ring-offset-0 *:h-full"
|
class="*:!border-none *:!ring-0 *:!outline-none *:rounded-none p-0 *:max-h-[50dvh] *:overflow-y-auto *:min-h-48 *:!ring-offset-0 *:h-full"
|
||||||
:disabled="sending"
|
:disabled="state.sending" :mode="state.contentType === 'text/html' ? 'rich' : 'plain'" />
|
||||||
:mode="state.contentType === 'text/html' ? 'rich' : 'plain'"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="w-full flex flex-row gap-2 overflow-x-auto *:shrink-0 pb-2">
|
<div class="w-full flex flex-row gap-2 overflow-x-auto *:shrink-0 pb-2">
|
||||||
<input
|
<input type="file" ref="fileInput" @change="uploadFileFromEvent" class="hidden" multiple />
|
||||||
type="file"
|
|
||||||
ref="fileInput"
|
|
||||||
@change="uploadFileFromEvent"
|
|
||||||
class="hidden"
|
|
||||||
multiple
|
|
||||||
/>
|
|
||||||
<Files v-model:files="state.files" />
|
<Files v-model:files="state.files" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter class="items-center flex-row">
|
<DialogFooter class="items-center flex-row overflow-x-auto">
|
||||||
<Tooltip>
|
<ComposerButtons @submit="send" @pick-file="fileInput?.click()" v-model:content-type="state.contentType" v-model:sensitive="state.sensitive" v-model:visibility="state.visibility" :relation="state.relation" :sending="state.sending" :can-send="state.canSend" :raw-content="state.rawContent" />
|
||||||
<TooltipTrigger as="div">
|
|
||||||
<Button variant="ghost" size="icon">
|
|
||||||
<AtSign class="!size-5" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>{{ m.game_tough_seal_adore() }}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger as="div">
|
|
||||||
<Toggle
|
|
||||||
variant="default"
|
|
||||||
size="sm"
|
|
||||||
:model-value="state.contentType === 'text/html'"
|
|
||||||
@update:model-value="
|
|
||||||
(i) =>
|
|
||||||
(state.contentType = i ? 'text/html' : 'text/plain')
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<LetterText class="!size-5" />
|
|
||||||
</Toggle>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>{{ m.plane_born_koala_hope() }}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<Select v-model:model-value="state.visibility">
|
|
||||||
<SelectTrigger
|
|
||||||
:as-child="true"
|
|
||||||
:disabled="relation?.type === 'edit'"
|
|
||||||
:disable-default-classes="true"
|
|
||||||
:disable-select-icon="true"
|
|
||||||
>
|
|
||||||
<Button variant="ghost" size="icon">
|
|
||||||
<component
|
|
||||||
:is="visibilities[state.visibility].icon"
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem
|
|
||||||
v-for="(v, k) in visibilities"
|
|
||||||
:key="k"
|
|
||||||
@click="state.visibility = k"
|
|
||||||
:value="k"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="flex flex-row gap-4 items-center w-full justify-between"
|
|
||||||
>
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<span class="font-semibold">{{ v.name }}</span>
|
|
||||||
<span>{{ v.text }}</span>
|
|
||||||
</div>
|
|
||||||
<component :is="v.icon" class="!size-5" />
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger as="div">
|
|
||||||
<Button variant="ghost" size="icon">
|
|
||||||
<Smile class="!size-5" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>{{ m.blue_ornate_coyote_tickle() }}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger as="div">
|
|
||||||
<Button variant="ghost" size="icon" @click="fileInput?.click()">
|
|
||||||
<FilePlus2 class="!size-5" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>{{ m.top_patchy_earthworm_vent() }}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger as="div">
|
|
||||||
<Toggle variant="default" size="sm" v-model="state.sensitive">
|
|
||||||
<TriangleAlert class="!size-5" />
|
|
||||||
</Toggle>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>{{ m.frail_broad_mallard_dart() }}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
size="lg"
|
|
||||||
class="ml-auto"
|
|
||||||
:disabled="sending"
|
|
||||||
@click="submit"
|
|
||||||
>
|
|
||||||
<Loader v-if="sending" class="!size-5 animate-spin" />
|
|
||||||
{{
|
|
||||||
relation?.type === "edit"
|
|
||||||
? m.gaudy_strong_puma_slide()
|
|
||||||
: m.free_teal_bulldog_learn()
|
|
||||||
}}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { ResponseError } from "@versia/client";
|
|
||||||
import type { Attachment, Status, StatusSource } from "@versia/client/schemas";
|
|
||||||
import {
|
|
||||||
AtSign,
|
|
||||||
FilePlus2,
|
|
||||||
Globe,
|
|
||||||
LetterText,
|
|
||||||
Loader,
|
|
||||||
Lock,
|
|
||||||
LockOpen,
|
|
||||||
Smile,
|
|
||||||
TriangleAlert,
|
|
||||||
} from "lucide-vue-next";
|
|
||||||
import { toast } from "vue-sonner";
|
|
||||||
import type { z } from "zod";
|
|
||||||
import Note from "~/components/notes/note.vue";
|
import Note from "~/components/notes/note.vue";
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
} from "~/components/ui/select";
|
|
||||||
import * as m from "~/paraglide/messages.js";
|
|
||||||
import EditorContent from "../editor/content.vue";
|
import EditorContent from "../editor/content.vue";
|
||||||
import { Button } from "../ui/button";
|
|
||||||
import { DialogFooter } from "../ui/dialog";
|
import { DialogFooter } from "../ui/dialog";
|
||||||
import { Input } from "../ui/input";
|
import ComposerButtons from "./buttons.vue";
|
||||||
import { Toggle } from "../ui/toggle";
|
import {
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
type ComposerState,
|
||||||
|
getRandomSplash,
|
||||||
|
send,
|
||||||
|
state,
|
||||||
|
stateFromRelation,
|
||||||
|
uploadFile,
|
||||||
|
} from "./composer";
|
||||||
|
import ContentWarning from "./content-warning.vue";
|
||||||
import Files from "./files.vue";
|
import Files from "./files.vue";
|
||||||
|
|
||||||
const { Control_Enter, Command_Enter } = useMagicKeys();
|
const { Control_Enter, Command_Enter } = useMagicKeys();
|
||||||
const { play } = useAudio();
|
const fileInput = useTemplateRef<HTMLInputElement>("fileInput");
|
||||||
const fileInput = ref<HTMLInputElement | null>(null);
|
|
||||||
|
|
||||||
watch([Control_Enter, Command_Enter], () => {
|
watch([Control_Enter, Command_Enter], () => {
|
||||||
if (sending.value || !preferences.ctrl_enter_send.value) {
|
if (state.sending || !preferences.ctrl_enter_send.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
submit();
|
send();
|
||||||
});
|
});
|
||||||
|
|
||||||
const { relation } = defineProps<{
|
const props = defineProps<{
|
||||||
relation?: {
|
relation?: ComposerState["relation"];
|
||||||
type: "reply" | "quote" | "edit";
|
|
||||||
note: z.infer<typeof Status>;
|
|
||||||
source?: z.infer<typeof StatusSource>;
|
|
||||||
};
|
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const getMentions = () => {
|
watch(
|
||||||
if (!relation || relation.type !== "reply") {
|
props,
|
||||||
return "";
|
async (props) => {
|
||||||
}
|
if (props.relation) {
|
||||||
|
await stateFromRelation(
|
||||||
const note = relation.note.reblog || relation.note;
|
props.relation.type,
|
||||||
|
props.relation.note,
|
||||||
const peopleToMention = note.mentions
|
props.relation.source,
|
||||||
.concat(note.account)
|
);
|
||||||
// Deduplicate mentions
|
|
||||||
.filter((men, i, a) => a.indexOf(men) === i)
|
|
||||||
// Remove self
|
|
||||||
.filter((men) => men.id !== identity.value?.account.id);
|
|
||||||
|
|
||||||
if (peopleToMention.length === 0) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
const mentions = peopleToMention.map((me) => `@${me.acct}`).join(" ");
|
|
||||||
|
|
||||||
return `${mentions} `;
|
|
||||||
};
|
|
||||||
|
|
||||||
const state = reactive({
|
|
||||||
// If editing, use the original content
|
|
||||||
// If sending a reply, prefill with mentions
|
|
||||||
content: relation?.source?.text || getMentions(),
|
|
||||||
sensitive: relation?.type === "edit" ? relation.note.sensitive : false,
|
|
||||||
contentWarning: relation?.type === "edit" ? relation.note.spoiler_text : "",
|
|
||||||
contentType: "text/html" as "text/html" | "text/plain",
|
|
||||||
visibility:
|
|
||||||
relation?.type === "edit"
|
|
||||||
? relation.note.visibility
|
|
||||||
: preferences.default_visibility.value,
|
|
||||||
files: (relation?.type === "edit"
|
|
||||||
? relation.note.media_attachments.map((a) => ({
|
|
||||||
apiId: a.id,
|
|
||||||
file: new File([], a.url),
|
|
||||||
alt: a.description,
|
|
||||||
uploading: false,
|
|
||||||
updating: false,
|
|
||||||
}))
|
|
||||||
: []) as {
|
|
||||||
apiId?: string;
|
|
||||||
file: File;
|
|
||||||
alt?: string;
|
|
||||||
uploading: boolean;
|
|
||||||
updating: boolean;
|
|
||||||
}[],
|
|
||||||
});
|
|
||||||
const sending = ref(false);
|
|
||||||
|
|
||||||
const splashes = useConfig().COMPOSER_SPLASHES;
|
|
||||||
const chosenSplash = splashes[Math.floor(Math.random() * splashes.length)];
|
|
||||||
|
|
||||||
const submit = async () => {
|
|
||||||
sending.value = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (relation?.type === "edit") {
|
|
||||||
const { data } = await client.value.editStatus(relation.note.id, {
|
|
||||||
status: state.content,
|
|
||||||
content_type: state.contentType,
|
|
||||||
sensitive: state.sensitive,
|
|
||||||
spoiler_text: state.sensitive
|
|
||||||
? state.contentWarning
|
|
||||||
: undefined,
|
|
||||||
media_ids: state.files
|
|
||||||
.map((f) => f.apiId)
|
|
||||||
.filter((f) => f !== undefined),
|
|
||||||
});
|
|
||||||
|
|
||||||
useEvent("composer:send-edit", data);
|
|
||||||
play("publish");
|
|
||||||
useEvent("composer:close");
|
|
||||||
} else {
|
|
||||||
const { data } = await client.value.postStatus(state.content, {
|
|
||||||
content_type: state.contentType,
|
|
||||||
sensitive: state.sensitive,
|
|
||||||
spoiler_text: state.sensitive
|
|
||||||
? state.contentWarning
|
|
||||||
: undefined,
|
|
||||||
media_ids: state.files
|
|
||||||
.map((f) => f.apiId)
|
|
||||||
.filter((f) => f !== undefined),
|
|
||||||
quote_id:
|
|
||||||
relation?.type === "quote" ? relation.note.id : undefined,
|
|
||||||
in_reply_to_id:
|
|
||||||
relation?.type === "reply" ? relation.note.id : undefined,
|
|
||||||
visibility: state.visibility,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEvent("composer:send", data as z.infer<typeof Status>);
|
|
||||||
play("publish");
|
|
||||||
useEvent("composer:close");
|
|
||||||
}
|
}
|
||||||
} catch (_e) {
|
},
|
||||||
const e = _e as ResponseError;
|
{ immediate: true },
|
||||||
toast.error(e.message);
|
);
|
||||||
} finally {
|
|
||||||
sending.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const uploadFileFromEvent = (e: Event) => {
|
const uploadFileFromEvent = (e: Event) => {
|
||||||
const target = e.target as HTMLInputElement;
|
const target = e.target as HTMLInputElement;
|
||||||
const files = Array.from(target.files ?? []);
|
const files = Array.from(target.files ?? []);
|
||||||
|
|
||||||
uploadFiles(files);
|
for (const file of files) {
|
||||||
|
uploadFile(file);
|
||||||
|
}
|
||||||
|
|
||||||
target.value = "";
|
target.value = "";
|
||||||
};
|
};
|
||||||
|
|
||||||
const uploadFiles = (files: File[]) => {
|
const uploadFiles = (files: File[]) => {
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
state.files.push({
|
uploadFile(file);
|
||||||
file,
|
|
||||||
uploading: true,
|
|
||||||
updating: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
client.value
|
|
||||||
.uploadMedia(file)
|
|
||||||
.then((media) => {
|
|
||||||
const index = state.files.findIndex((f) => f.file === file);
|
|
||||||
|
|
||||||
if (!state.files[index]) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
state.files[index].apiId = (
|
|
||||||
media.data as z.infer<typeof Attachment>
|
|
||||||
).id;
|
|
||||||
state.files[index].uploading = false;
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
const index = state.files.findIndex((f) => f.file === file);
|
|
||||||
|
|
||||||
if (!state.files[index]) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
state.files.splice(index, 1);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const visibilities = {
|
|
||||||
public: {
|
|
||||||
icon: Globe,
|
|
||||||
name: m.lost_trick_dog_grace(),
|
|
||||||
text: m.last_mean_peacock_zip(),
|
|
||||||
},
|
|
||||||
unlisted: {
|
|
||||||
icon: LockOpen,
|
|
||||||
name: m.funny_slow_jannes_walk(),
|
|
||||||
text: m.grand_strong_gibbon_race(),
|
|
||||||
},
|
|
||||||
private: {
|
|
||||||
icon: Lock,
|
|
||||||
name: m.grassy_empty_raven_startle(),
|
|
||||||
text: m.white_teal_ostrich_yell(),
|
|
||||||
},
|
|
||||||
direct: {
|
|
||||||
icon: AtSign,
|
|
||||||
name: m.pretty_bold_baboon_wave(),
|
|
||||||
text: m.lucky_mean_robin_link(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
9
components/composer/content-warning.vue
Normal file
9
components/composer/content-warning.vue
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
<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>
|
||||||
|
|
@ -79,7 +79,7 @@ const relation = ref(
|
||||||
>
|
>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
:hide-close="true"
|
:hide-close="true"
|
||||||
class="sm:max-w-xl max-w-full w-[calc(100%-2*0.5rem)] grid-rows-[minmax(0,1fr)_auto] max-h-[90dvh] p-5 pt-6 top-2 sm:top-1/2 translate-y-0 sm:-translate-y-1/2 rounded"
|
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"
|
||||||
>
|
>
|
||||||
<DialogTitle class="sr-only">
|
<DialogTitle class="sr-only">
|
||||||
{{
|
{{
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,9 @@
|
||||||
:disabled="file.uploading || file.updating"
|
:disabled="file.uploading || file.updating"
|
||||||
class="block bg-card text-card-foreground shadow-sm h-28 overflow-hidden rounded relative min-w-28 *:disabled:opacity-50"
|
class="block bg-card text-card-foreground shadow-sm h-28 overflow-hidden rounded relative min-w-28 *:disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<Avatar class="h-28 w-full" shape="square">
|
<img :src="createObjectURL(file.file)" class="object-contain h-28 w-full" :alt="file.alt" />
|
||||||
<AvatarImage
|
|
||||||
class="!object-contain"
|
|
||||||
:src="createObjectURL(file.file)"
|
|
||||||
/>
|
|
||||||
</Avatar>
|
|
||||||
<Badge
|
<Badge
|
||||||
v-if="file.uploading && !file.updating"
|
v-if="!(file.uploading || file.updating)"
|
||||||
class="absolute bottom-1 right-1"
|
class="absolute bottom-1 right-1"
|
||||||
variant="default"
|
variant="default"
|
||||||
>{{ formatBytes(file.file.size) }}</Badge
|
>{{ formatBytes(file.file.size) }}</Badge
|
||||||
|
|
@ -22,29 +17,27 @@
|
||||||
<DropdownMenuContent class="min-w-48">
|
<DropdownMenuContent class="min-w-48">
|
||||||
<DropdownMenuLabel>{{ file.file.name }}</DropdownMenuLabel>
|
<DropdownMenuLabel>{{ file.file.name }}</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
<DropdownMenuItem @click="editName">
|
<DropdownMenuItem @click="editName">
|
||||||
<TextCursorInput />
|
<TextCursorInput />
|
||||||
<span>Rename</span>
|
Rename
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem @click="editCaption">
|
<DropdownMenuItem @click="editCaption">
|
||||||
<Captions />
|
<Captions />
|
||||||
<span>Add caption</span>
|
Add caption
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem @click="emit('remove')">
|
<DropdownMenuItem @click="emit('remove')">
|
||||||
<Delete />
|
<Delete />
|
||||||
<span>Remove</span>
|
Remove
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { Captions, Delete, Loader, TextCursorInput } from "lucide-vue-next";
|
import { Captions, Delete, TextCursorInput } from "lucide-vue-next";
|
||||||
import Spinner from "~/components/graphics/spinner.vue";
|
import Spinner from "~/components/graphics/spinner.vue";
|
||||||
import { confirmModalService } from "~/components/modals/composable.ts";
|
import { confirmModalService } from "~/components/modals/composable.ts";
|
||||||
import { Avatar, AvatarImage } from "~/components/ui/avatar";
|
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
|
|
@ -54,14 +47,9 @@ import {
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "~/components/ui/dropdown-menu";
|
} from "~/components/ui/dropdown-menu";
|
||||||
|
import type { ComposerState } from "./composer";
|
||||||
|
|
||||||
const file = defineModel<{
|
const file = defineModel<ComposerState["files"][number]>("file", {
|
||||||
apiId?: string;
|
|
||||||
file: File;
|
|
||||||
alt?: string;
|
|
||||||
uploading: boolean;
|
|
||||||
updating: boolean;
|
|
||||||
}>("file", {
|
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,13 @@
|
||||||
<template>
|
<template>
|
||||||
<FilePreview v-for="(file, index) in files" :key="file.apiId" :file="file" @update:file="files[index] = $event" @remove="files.splice(index, 1)" />
|
<FilePreview v-for="(file, index) in files" :key="file.apiId" :file="file" @update:file="files[index] = $event"
|
||||||
|
@remove="files.splice(index, 1)" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import type { ComposerState } from "./composer";
|
||||||
import FilePreview from "./file-preview.vue";
|
import FilePreview from "./file-preview.vue";
|
||||||
|
|
||||||
const files = defineModel<
|
const files = defineModel<ComposerState["files"]>("files", {
|
||||||
{
|
|
||||||
apiId?: string;
|
|
||||||
file: File;
|
|
||||||
alt?: string;
|
|
||||||
uploading: boolean;
|
|
||||||
updating: boolean;
|
|
||||||
}[]
|
|
||||||
>("files", {
|
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
33
components/composer/visibility-picker.vue
Normal file
33
components/composer/visibility-picker.vue
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
<template>
|
||||||
|
<Select v-model:model-value="visibility">
|
||||||
|
<SelectTrigger as-child disable-default-classes disable-select-icon>
|
||||||
|
<slot />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem v-for="(v, k) in visibilities" :key="k" @click="visibility = k" :value="k">
|
||||||
|
<div class="flex flex-row gap-3 items-center w-full justify-between">
|
||||||
|
<component :is="v.icon" class="size-4" />
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<span class="font-semibold">{{ v.name }}</span>
|
||||||
|
<span>{{ v.text }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
} from "~/components/ui/select";
|
||||||
|
import { type ComposerState, visibilities } from "./composer";
|
||||||
|
|
||||||
|
const visibility = defineModel<ComposerState["visibility"]>("visibility", {
|
||||||
|
required: true,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
@ -17,6 +17,7 @@ import { Emoji } from "./emoji.ts";
|
||||||
import suggestion from "./suggestion.ts";
|
import suggestion from "./suggestion.ts";
|
||||||
|
|
||||||
const content = defineModel<string>("content");
|
const content = defineModel<string>("content");
|
||||||
|
const rawContent = defineModel<string>("rawContent");
|
||||||
const {
|
const {
|
||||||
placeholder,
|
placeholder,
|
||||||
disabled,
|
disabled,
|
||||||
|
|
@ -27,6 +28,10 @@ const {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
pasteFiles: [files: File[]];
|
||||||
|
}>();
|
||||||
|
|
||||||
const editor = new Editor({
|
const editor = new Editor({
|
||||||
extensions: [
|
extensions: [
|
||||||
StarterKit,
|
StarterKit,
|
||||||
|
|
@ -49,6 +54,15 @@ const editor = new Editor({
|
||||||
content: content.value,
|
content: content.value,
|
||||||
onUpdate: ({ editor }) => {
|
onUpdate: ({ editor }) => {
|
||||||
content.value = mode === "rich" ? editor.getHTML() : editor.getText();
|
content.value = mode === "rich" ? editor.getHTML() : editor.getText();
|
||||||
|
rawContent.value = editor.getText();
|
||||||
|
},
|
||||||
|
onPaste: (event) => {
|
||||||
|
// If pasting files, prevent the default behavior
|
||||||
|
if (event.clipboardData && event.clipboardData.files.length > 0) {
|
||||||
|
event.preventDefault();
|
||||||
|
const files = Array.from(event.clipboardData.files);
|
||||||
|
emit("pasteFiles", files);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
editable: !disabled,
|
editable: !disabled,
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,11 @@ const props = withDefaults(
|
||||||
SelectTriggerProps & {
|
SelectTriggerProps & {
|
||||||
class?: HTMLAttributes["class"];
|
class?: HTMLAttributes["class"];
|
||||||
size?: "sm" | "default";
|
size?: "sm" | "default";
|
||||||
|
disableSelectIcon?: boolean;
|
||||||
|
disableDefaultClasses?: boolean;
|
||||||
}
|
}
|
||||||
>(),
|
>(),
|
||||||
{ size: "default" },
|
{ size: "default", disableSelectIcon: false, disableDefaultClasses: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
const delegatedProps = reactiveOmit(props, "class", "size");
|
const delegatedProps = reactiveOmit(props, "class", "size");
|
||||||
|
|
@ -30,12 +32,12 @@ const forwardedProps = useForwardProps(delegatedProps);
|
||||||
:data-size="size"
|
:data-size="size"
|
||||||
v-bind="forwardedProps"
|
v-bind="forwardedProps"
|
||||||
:class="cn(
|
:class="cn(
|
||||||
`border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4`,
|
!disableDefaultClasses && `border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4`,
|
||||||
props.class,
|
props.class,
|
||||||
)"
|
)"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
<SelectIcon as-child>
|
<SelectIcon as-child v-if="!props.disableSelectIcon">
|
||||||
<ChevronDown class="size-4 opacity-50" />
|
<ChevronDown class="size-4 opacity-50" />
|
||||||
</SelectIcon>
|
</SelectIcon>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue