mirror of
https://github.com/versia-pub/frontend.git
synced 2026-03-13 03:29:16 +01:00
chore: ⬆️ Upgrade to Nuxt 4
Some checks failed
Some checks failed
This commit is contained in:
parent
8debe97f63
commit
7f7cf20311
386 changed files with 2376 additions and 2332 deletions
15
app/components/notes/action-button.vue
Normal file
15
app/components/notes/action-button.vue
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<template>
|
||||
<Button variant="ghost" class="max-w-14 w-full" size="sm">
|
||||
<component :is="icon" class="size-4" />
|
||||
<slot />
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { FunctionalComponent } from "vue";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
const { icon } = defineProps<{
|
||||
icon: FunctionalComponent;
|
||||
}>();
|
||||
</script>
|
||||
167
app/components/notes/actions.vue
Normal file
167
app/components/notes/actions.vue
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
<template>
|
||||
<div class="flex flex-row w-full max-w-sm items-stretch justify-between">
|
||||
<ActionButton :icon="Reply" @click="emit('reply')" :title="m.drab_tense_turtle_comfort()" :disabled="!identity">
|
||||
{{ numberFormat(replyCount) }}
|
||||
</ActionButton>
|
||||
<ActionButton :icon="Heart" @click="liked ? unlike() : like()" :title="liked ? m.vexed_fluffy_clownfish_dance() : m.royal_close_samuel_scold()" :disabled="!identity" :class="liked && '*:fill-red-600 *:text-red-600'">
|
||||
{{ numberFormat(likeCount) }}
|
||||
</ActionButton>
|
||||
<ActionButton :icon="Repeat" @click="reblogged ? unreblog() : reblog()" :title="reblogged ? m.lime_neat_ox_stab() : m.aware_helpful_marlin_drop()" :disabled="!identity" :class="reblogged && '*:text-green-600'">
|
||||
{{ numberFormat(reblogCount) }}
|
||||
</ActionButton>
|
||||
<ActionButton :icon="Quote" @click="emit('quote')" :title="m.true_shy_jackal_drip()" :disabled="!identity" />
|
||||
<Picker @pick="react">
|
||||
<ActionButton :icon="Smile" :title="m.bald_cool_kangaroo_jump()" :disabled="!identity" />
|
||||
</Picker>
|
||||
<Menu :api-note-string="apiNoteString" :url="url" :remote-url="remoteUrl" :is-remote="isRemote" :author-id="authorId" @edit="emit('edit')" :note-id="noteId" @delete="emit('delete')">
|
||||
<ActionButton :icon="Ellipsis" :title="m.busy_merry_cowfish_absorb()" />
|
||||
</Menu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { CustomEmoji, Status } from "@versia/client/schemas";
|
||||
import { Ellipsis, Heart, Quote, Repeat, Reply, Smile } from "lucide-vue-next";
|
||||
import { toast } from "vue-sonner";
|
||||
import type { z } from "zod";
|
||||
import * as m from "~~/paraglide/messages.js";
|
||||
import { getLocale } from "~~/paraglide/runtime";
|
||||
import { confirmModalService } from "../modals/composable";
|
||||
import ActionButton from "./action-button.vue";
|
||||
import Menu from "./menu.vue";
|
||||
import type { UnicodeEmoji } from "./reactions/picker/emoji";
|
||||
import Picker from "./reactions/picker/index.vue";
|
||||
|
||||
const { noteId } = defineProps<{
|
||||
replyCount: number;
|
||||
likeCount: number;
|
||||
reblogCount: number;
|
||||
apiNoteString: string;
|
||||
noteId: string;
|
||||
isRemote: boolean;
|
||||
url: string;
|
||||
remoteUrl?: string;
|
||||
authorId: string;
|
||||
liked: boolean;
|
||||
reblogged: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
edit: [];
|
||||
reply: [];
|
||||
quote: [];
|
||||
delete: [];
|
||||
react: [];
|
||||
}>();
|
||||
const { play } = useAudio();
|
||||
|
||||
const like = async () => {
|
||||
if (preferences.confirm_actions.value.includes("like")) {
|
||||
const confirmation = await confirmModalService.confirm({
|
||||
title: m.slimy_least_ray_aid(),
|
||||
message: m.stale_new_ray_jolt(),
|
||||
confirmText: m.royal_close_samuel_scold(),
|
||||
inputType: "none",
|
||||
});
|
||||
|
||||
if (!confirmation.confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
play("like");
|
||||
const id = toast.loading(m.slimy_candid_tiger_read());
|
||||
const { data } = await client.value.favouriteStatus(noteId);
|
||||
toast.dismiss(id);
|
||||
toast.success(m.mealy_slow_buzzard_commend());
|
||||
useEvent("note:edit", data);
|
||||
};
|
||||
|
||||
const unlike = async () => {
|
||||
if (preferences.confirm_actions.value.includes("like")) {
|
||||
const confirmation = await confirmModalService.confirm({
|
||||
title: m.odd_strong_halibut_prosper(),
|
||||
message: m.slow_blue_parrot_savor(),
|
||||
confirmText: m.vexed_fluffy_clownfish_dance(),
|
||||
inputType: "none",
|
||||
});
|
||||
|
||||
if (!confirmation.confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const id = toast.loading(m.busy_active_leopard_strive());
|
||||
const { data } = await client.value.unfavouriteStatus(noteId);
|
||||
toast.dismiss(id);
|
||||
toast.success(m.fresh_direct_bear_affirm());
|
||||
useEvent("note:edit", data);
|
||||
};
|
||||
|
||||
const reblog = async () => {
|
||||
if (preferences.confirm_actions.value.includes("reblog")) {
|
||||
const confirmation = await confirmModalService.confirm({
|
||||
title: m.best_mellow_llama_surge(),
|
||||
message: m.salty_plain_mallard_gaze(),
|
||||
confirmText: m.aware_helpful_marlin_drop(),
|
||||
inputType: "none",
|
||||
});
|
||||
|
||||
if (!confirmation.confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const id = toast.loading(m.late_sunny_cobra_scold());
|
||||
const { data } = await client.value.reblogStatus(noteId);
|
||||
toast.dismiss(id);
|
||||
toast.success(m.weird_moving_hawk_lift());
|
||||
useEvent(
|
||||
"note:edit",
|
||||
(data.reblog as z.infer<typeof Status> | null) || data,
|
||||
);
|
||||
};
|
||||
|
||||
const unreblog = async () => {
|
||||
if (preferences.confirm_actions.value.includes("reblog")) {
|
||||
const confirmation = await confirmModalService.confirm({
|
||||
title: m.main_fancy_octopus_loop(),
|
||||
message: m.odd_alive_swan_express(),
|
||||
confirmText: m.lime_neat_ox_stab(),
|
||||
inputType: "none",
|
||||
});
|
||||
|
||||
if (!confirmation.confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const id = toast.loading(m.white_sharp_gorilla_embrace());
|
||||
const { data } = await client.value.unreblogStatus(noteId);
|
||||
toast.dismiss(id);
|
||||
toast.success(m.royal_polite_moose_catch());
|
||||
useEvent("note:edit", data);
|
||||
};
|
||||
|
||||
const react = async (emoji: z.infer<typeof CustomEmoji> | UnicodeEmoji) => {
|
||||
const id = toast.loading(m.gray_stale_antelope_roam());
|
||||
const text = (emoji as UnicodeEmoji).hexcode
|
||||
? (emoji as UnicodeEmoji).unicode
|
||||
: `:${(emoji as z.infer<typeof CustomEmoji>).shortcode}:`;
|
||||
|
||||
const { data } = await client.value.createEmojiReaction(noteId, text);
|
||||
|
||||
toast.dismiss(id);
|
||||
toast.success(m.main_least_turtle_fall());
|
||||
useEvent("note:edit", data);
|
||||
};
|
||||
|
||||
const numberFormat = (number = 0) =>
|
||||
number !== 0
|
||||
? new Intl.NumberFormat(getLocale(), {
|
||||
notation: "compact",
|
||||
compactDisplay: "short",
|
||||
maximumFractionDigits: 1,
|
||||
}).format(number)
|
||||
: undefined;
|
||||
</script>
|
||||
19
app/components/notes/attachment.vue
Normal file
19
app/components/notes/attachment.vue
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<template>
|
||||
<ImageAttachment v-if="attachment.type === 'image'" :attachment="attachment" />
|
||||
<VideoAttachment v-else-if="attachment.type === 'video' || attachment.type === 'gifv'" :attachment="attachment" />
|
||||
<AudioAttachment v-else-if="attachment.type === 'audio'" :attachment="attachment" />
|
||||
<FileAttachment v-else :attachment="attachment" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Attachment } from "@versia/client/schemas";
|
||||
import type { z } from "zod";
|
||||
import AudioAttachment from "./attachments/audio.vue";
|
||||
import FileAttachment from "./attachments/file.vue";
|
||||
import ImageAttachment from "./attachments/image.vue";
|
||||
import VideoAttachment from "./attachments/video.vue";
|
||||
|
||||
defineProps<{
|
||||
attachment: z.infer<typeof Attachment>;
|
||||
}>();
|
||||
</script>
|
||||
16
app/components/notes/attachments.vue
Normal file
16
app/components/notes/attachments.vue
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<template>
|
||||
<!-- [&:has(>:last-child:nth-child(1))] means "when this element has 1 child" -->
|
||||
<div class="grid gap-4 grid-cols-2 *:max-h-56 [&:has(>:last-child:nth-child(1))]:grid-cols-1 sm:[&:has(>:last-child:nth-child(1))>*]:max-h-72">
|
||||
<Attachment v-for="attachment in attachments" :key="attachment.id" :attachment="attachment" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Attachment as AttachmentType } from "@versia/client/schemas";
|
||||
import type { z } from "zod";
|
||||
import Attachment from "./attachment.vue";
|
||||
|
||||
defineProps<{
|
||||
attachments: z.infer<typeof AttachmentType>[];
|
||||
}>();
|
||||
</script>
|
||||
15
app/components/notes/attachments/audio.vue
Normal file
15
app/components/notes/attachments/audio.vue
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<template>
|
||||
<Base :attachment="attachment">
|
||||
<audio :src="attachment.url" :alt="attachment.description ?? undefined" controls />
|
||||
</Base>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Attachment } from "@versia/client/schemas";
|
||||
import type { z } from "zod";
|
||||
import Base from "./base.vue";
|
||||
|
||||
const { attachment } = defineProps<{
|
||||
attachment: z.infer<typeof Attachment>;
|
||||
}>();
|
||||
</script>
|
||||
74
app/components/notes/attachments/base.vue
Normal file
74
app/components/notes/attachments/base.vue
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
<template>
|
||||
<Dialog>
|
||||
<Card class="w-full h-full overflow-hidden relative p-0 *:first:w-full *:first:h-full *:first:object-contain *:first:bg-muted/20">
|
||||
<DialogTrigger v-if="lightbox" :as-child="true">
|
||||
<slot />
|
||||
</DialogTrigger>
|
||||
<slot v-else />
|
||||
<!-- Alt text viewer -->
|
||||
<Popover v-if="attachment.description">
|
||||
<div class="absolute top-0 right-0 p-2">
|
||||
<PopoverTrigger :as-child="true">
|
||||
<Button variant="outline" size="icon" title="View alt text">
|
||||
<Captions />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
<PopoverContent>
|
||||
<p class="text-sm">{{ attachment.description }}</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</Card>
|
||||
<DialogContent :hide-close="true"
|
||||
class="duration-200 bg-transparent border-none overflow-hidden !animate-none gap-6 w-screen h-screen !max-w-none">
|
||||
<div class="grid grid-rows-[auto_1fr_auto]">
|
||||
<div class="flex flex-row gap-2 w-full">
|
||||
<DialogTitle class="sr-only">{{ attachment.type }}</DialogTitle>
|
||||
<Button as="a" :href="attachment?.url" target="_blank" :download="true" variant="outline" size="icon"
|
||||
class="ml-auto">
|
||||
<Download />
|
||||
</Button>
|
||||
<DialogClose :as-child="true">
|
||||
<Button variant="outline" size="icon">
|
||||
<X />
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
<div class="flex items-center justify-center overflow-hidden *:max-h-[80vh] *:max-w-[80vw] *:w-full *:h-full *:object-contain">
|
||||
<slot />
|
||||
</div>
|
||||
<DialogDescription class="flex items-center justify-center">
|
||||
<Card v-if="attachment.description" class="max-w-md max-h-48 overflow-auto text-sm">
|
||||
<p>{{ attachment.description }}</p>
|
||||
</Card>
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Attachment } from "@versia/client/schemas";
|
||||
import { Captions, Download, File, X } from "lucide-vue-next";
|
||||
import type { z } from "zod";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card } from "~/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "~/components/ui/dialog";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "~/components/ui/popover";
|
||||
|
||||
const { attachment, lightbox = false } = defineProps<{
|
||||
attachment: z.infer<typeof Attachment>;
|
||||
lightbox?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
19
app/components/notes/attachments/file.vue
Normal file
19
app/components/notes/attachments/file.vue
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<template>
|
||||
<Base :attachment="attachment" lightbox>
|
||||
<div class="flex flex-col items-center justify-center min-h-48 text-sm gap-2">
|
||||
<File class="size-12" />
|
||||
<span>File attachment</span>
|
||||
</div>
|
||||
</Base>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Attachment } from "@versia/client/schemas";
|
||||
import { File } from "lucide-vue-next";
|
||||
import type { z } from "zod";
|
||||
import Base from "./base.vue";
|
||||
|
||||
const { attachment } = defineProps<{
|
||||
attachment: z.infer<typeof Attachment>;
|
||||
}>();
|
||||
</script>
|
||||
15
app/components/notes/attachments/image.vue
Normal file
15
app/components/notes/attachments/image.vue
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<template>
|
||||
<Base :attachment="attachment" lightbox>
|
||||
<img :src="attachment.url" :alt="attachment.description ?? undefined" />
|
||||
</Base>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Attachment } from "@versia/client/schemas";
|
||||
import type { z } from "zod";
|
||||
import Base from "./base.vue";
|
||||
|
||||
const { attachment } = defineProps<{
|
||||
attachment: z.infer<typeof Attachment>;
|
||||
}>();
|
||||
</script>
|
||||
15
app/components/notes/attachments/video.vue
Normal file
15
app/components/notes/attachments/video.vue
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<template>
|
||||
<Base :attachment="attachment">
|
||||
<video :src="attachment.url" :alt="attachment.description ?? undefined" controls />
|
||||
</Base>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Attachment } from "@versia/client/schemas";
|
||||
import type { z } from "zod";
|
||||
import Base from "./base.vue";
|
||||
|
||||
const { attachment } = defineProps<{
|
||||
attachment: z.infer<typeof Attachment>;
|
||||
}>();
|
||||
</script>
|
||||
23
app/components/notes/content-warning.vue
Normal file
23
app/components/notes/content-warning.vue
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<template>
|
||||
<Alert layout="button">
|
||||
<TriangleAlert />
|
||||
<AlertTitle>{{ contentWarning || m.sour_seemly_bird_hike() }}</AlertTitle>
|
||||
<Button @click="blurred = !blurred" variant="outline" size="sm">{{ blurred ? m.bald_direct_turtle_win() :
|
||||
m.known_flaky_cockroach_dash() }}</Button>
|
||||
</Alert>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { TriangleAlert } from "lucide-vue-next";
|
||||
import * as m from "~~/paraglide/messages.js";
|
||||
import { Alert, AlertDescription, AlertTitle } from "../ui/alert";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
const { contentWarning } = defineProps<{
|
||||
contentWarning?: string;
|
||||
}>();
|
||||
|
||||
const blurred = defineModel<boolean>({
|
||||
default: true,
|
||||
});
|
||||
</script>
|
||||
37
app/components/notes/content.vue
Normal file
37
app/components/notes/content.vue
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<template>
|
||||
<ContentWarning v-if="(sensitive || contentWarning) && preferences.show_content_warning" :content-warning="contentWarning" v-model="blurred" />
|
||||
|
||||
<OverflowGuard v-if="content" :character-count="characterCount" :class="(blurred && preferences.show_content_warning) && 'blur-md'">
|
||||
<Prose v-html="content" v-render-emojis="emojis"></Prose>
|
||||
</OverflowGuard>
|
||||
|
||||
<Attachments v-if="attachments.length > 0" :attachments="attachments" :class="(blurred && preferences.show_content_warning) && 'blur-xl'" />
|
||||
|
||||
<div v-if="quote" class="mt-4 rounded border overflow-hidden">
|
||||
<Note :note="quote" :hide-actions="true" :small-layout="true" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Attachment, CustomEmoji, Status } from "@versia/client/schemas";
|
||||
import type { z } from "zod";
|
||||
import Attachments from "./attachments.vue";
|
||||
import ContentWarning from "./content-warning.vue";
|
||||
import Note from "./note.vue";
|
||||
import OverflowGuard from "./overflow-guard.vue";
|
||||
import Prose from "./prose.vue";
|
||||
|
||||
const { content, plainContent, sensitive, contentWarning } = defineProps<{
|
||||
plainContent?: string;
|
||||
content: string;
|
||||
quote?: NonNullable<z.infer<typeof Status.shape.quote>>;
|
||||
emojis: z.infer<typeof CustomEmoji>[];
|
||||
attachments: z.infer<typeof Attachment>[];
|
||||
sensitive: boolean;
|
||||
contentWarning?: string;
|
||||
}>();
|
||||
|
||||
const blurred = ref(sensitive || !!contentWarning);
|
||||
|
||||
const characterCount = plainContent?.length;
|
||||
</script>
|
||||
113
app/components/notes/header.vue
Normal file
113
app/components/notes/header.vue
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
<template>
|
||||
<div class="rounded grid grid-cols-[auto_1fr_auto] items-center gap-3">
|
||||
<HoverCard v-model:open="popupOpen" @update:open="() => {
|
||||
if (!preferences.popup_avatar_hover) {
|
||||
popupOpen = false;
|
||||
}
|
||||
}" :open-delay="2000">
|
||||
<HoverCardTrigger :as-child="true">
|
||||
<NuxtLink :href="urlAsPath" :class="cn('relative size-12', smallLayout && 'size-8')">
|
||||
<Avatar :class="cn('size-12 border border-card', smallLayout && 'size-8')" :src="author.avatar"
|
||||
:name="author.display_name" />
|
||||
<Avatar v-if="cornerAvatar" class="size-6 border absolute -bottom-1 -right-1" :src="cornerAvatar" />
|
||||
</NuxtLink>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent class="w-96">
|
||||
<SmallCard :account="author" />
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
<Col
|
||||
:class="smallLayout && 'text-sm'">
|
||||
<Text class="font-semibold" v-render-emojis="author.emojis">{{
|
||||
author.display_name
|
||||
}}</Text>
|
||||
<div class="-mt-1">
|
||||
<Address as="span" :username="username" :domain="instance" />
|
||||
·
|
||||
<Text as="span" muted class="ml-auto tracking-normal" :title="fullTime">{{ timeAgo }}</Text>
|
||||
</div>
|
||||
</Col>
|
||||
<div v-if="!smallLayout">
|
||||
<NuxtLink :href="noteUrlAsPath" class="text-xs text-muted-foreground"
|
||||
:title="visibilities[visibility].text">
|
||||
<component :is="visibilities[visibility].icon" class="size-4" />
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Account, Status } from "@versia/client/schemas";
|
||||
import type {
|
||||
UseTimeAgoMessages,
|
||||
UseTimeAgoUnitNamesDefault,
|
||||
} from "@vueuse/core";
|
||||
import { AtSign, Globe, Lock, LockOpen } from "lucide-vue-next";
|
||||
import type { z } from "zod";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getLocale } from "~~/paraglide/runtime";
|
||||
import Address from "../profiles/address.vue";
|
||||
import Avatar from "../profiles/avatar.vue";
|
||||
import SmallCard from "../profiles/small-card.vue";
|
||||
import Col from "../typography/layout/col.vue";
|
||||
import Text from "../typography/text.vue";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "../ui/hover-card";
|
||||
|
||||
const { createdAt, noteUrl, author, authorUrl } = defineProps<{
|
||||
cornerAvatar?: string;
|
||||
visibility: z.infer<typeof Status.shape.visibility>;
|
||||
noteUrl: string;
|
||||
createdAt: Date;
|
||||
smallLayout?: boolean;
|
||||
author: z.infer<typeof Account>;
|
||||
authorUrl: string;
|
||||
}>();
|
||||
|
||||
const [username, instance] = author.acct.split("@");
|
||||
const digitRegex = /\d/;
|
||||
const urlAsPath = new URL(authorUrl).pathname;
|
||||
const noteUrlAsPath = new URL(noteUrl).pathname;
|
||||
const timeAgo = useTimeAgo(createdAt, {
|
||||
messages: {
|
||||
justNow: "now",
|
||||
past: (n) => (n.match(digitRegex) ? `${n}` : n),
|
||||
future: (n) => (n.match(digitRegex) ? `in ${n}` : n),
|
||||
month: (n) => `${n}mo`,
|
||||
year: (n) => `${n}y`,
|
||||
day: (n) => `${n}d`,
|
||||
week: (n) => `${n}w`,
|
||||
hour: (n) => `${n}h`,
|
||||
minute: (n) => `${n}m`,
|
||||
second: (n) => `${n}s`,
|
||||
invalid: "",
|
||||
} as UseTimeAgoMessages<UseTimeAgoUnitNamesDefault>,
|
||||
});
|
||||
const fullTime = new Intl.DateTimeFormat(getLocale(), {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(createdAt);
|
||||
const popupOpen = ref(false);
|
||||
|
||||
const visibilities = {
|
||||
public: {
|
||||
icon: Globe,
|
||||
text: "This note is public: it can be seen by anyone.",
|
||||
},
|
||||
unlisted: {
|
||||
icon: LockOpen,
|
||||
text: "This note is unlisted: it can be seen by anyone with the link.",
|
||||
},
|
||||
private: {
|
||||
icon: Lock,
|
||||
text: "This note is private: it can only be seen by followers.",
|
||||
},
|
||||
direct: {
|
||||
icon: AtSign,
|
||||
text: "This note is direct: it can only be seen by mentioned users.",
|
||||
},
|
||||
};
|
||||
</script>
|
||||
138
app/components/notes/menu.vue
Normal file
138
app/components/notes/menu.vue
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
<script setup lang="tsx">
|
||||
import {
|
||||
Ban,
|
||||
Code,
|
||||
Delete,
|
||||
ExternalLink,
|
||||
Flag,
|
||||
Hash,
|
||||
Link,
|
||||
Pencil,
|
||||
Trash,
|
||||
} from "lucide-vue-next";
|
||||
import { toast } from "vue-sonner";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { confirmModalService } from "~/components/modals/composable.ts";
|
||||
import * as m from "~~/paraglide/messages.js";
|
||||
|
||||
const { authorId, noteId } = defineProps<{
|
||||
apiNoteString: string;
|
||||
isRemote: boolean;
|
||||
url: string;
|
||||
remoteUrl?: string;
|
||||
authorId: string;
|
||||
noteId: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
edit: [];
|
||||
delete: [];
|
||||
}>();
|
||||
|
||||
const { copy } = useClipboard();
|
||||
const loggedIn = !!identity.value;
|
||||
const authorIsMe = loggedIn && authorId === identity.value?.account.id;
|
||||
|
||||
const copyText = (text: string) => {
|
||||
copy(text);
|
||||
toast.success(m.flat_nice_worm_dream());
|
||||
};
|
||||
|
||||
const blockUser = async (userId: string) => {
|
||||
const id = toast.loading(m.top_cute_bison_nudge());
|
||||
await client.value.blockAccount(userId);
|
||||
toast.dismiss(id);
|
||||
|
||||
toast.success(m.main_weary_racoon_peek());
|
||||
};
|
||||
|
||||
const _delete = async () => {
|
||||
if (preferences.confirm_actions.value.includes("delete")) {
|
||||
const confirmation = await confirmModalService.confirm({
|
||||
title: m.calm_icy_weasel_twirl(),
|
||||
message: m.gray_fun_toucan_slide(),
|
||||
confirmText: m.royal_best_tern_transform(),
|
||||
inputType: "none",
|
||||
});
|
||||
|
||||
if (!confirmation.confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const id = toast.loading(m.new_funny_fox_boil());
|
||||
await client.value.deleteStatus(noteId);
|
||||
toast.dismiss(id);
|
||||
|
||||
toast.success(m.green_tasty_bumblebee_beam());
|
||||
emit("delete");
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<slot />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent class="min-w-56">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem v-if="authorIsMe" as="button" @click="emit('edit')">
|
||||
<Pencil />
|
||||
{{ m.front_lime_grizzly_persist() }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem as="button" @click="copyText(apiNoteString)">
|
||||
<Code />
|
||||
{{ m.yummy_moving_scallop_sail() }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem as="button" @click="copyText(noteId)">
|
||||
<Hash />
|
||||
{{ m.sunny_zany_jellyfish_pop() }}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem as="button" @click="copyText(url)">
|
||||
<Link />
|
||||
{{ m.ago_new_pelican_drip() }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem as="button" v-if="isRemote && remoteUrl" @click="copyText(remoteUrl)">
|
||||
<Link />
|
||||
{{ m.solid_witty_zebra_walk() }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem as="a" v-if="isRemote" target="_blank" rel="noopener noreferrer" :href="remoteUrl">
|
||||
<ExternalLink />
|
||||
{{ m.active_trite_lark_inspire() }}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator v-if="authorIsMe" />
|
||||
<DropdownMenuGroup v-if="authorIsMe">
|
||||
<DropdownMenuItem as="button" :disabled="true">
|
||||
<Delete />
|
||||
{{ m.real_green_clownfish_pet() }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem as="button" @click="_delete">
|
||||
<Trash />
|
||||
{{ m.tense_quick_cod_favor() }}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator v-if="loggedIn && !authorIsMe" />
|
||||
<DropdownMenuGroup v-if="loggedIn && !authorIsMe">
|
||||
<DropdownMenuItem as="button" :disabled="true">
|
||||
<Flag />
|
||||
{{ m.great_few_jaguar_rise() }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem as="button" @click="blockUser(authorId)">
|
||||
<Ban />
|
||||
{{ m.misty_soft_sparrow_vent() }}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
110
app/components/notes/note.vue
Normal file
110
app/components/notes/note.vue
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
<template>
|
||||
<Card as="article" class="relative gap-3 items-stretch">
|
||||
<CardHeader as="header" class="space-y-2">
|
||||
<ReblogHeader
|
||||
v-if="note.reblog"
|
||||
:avatar="note.account.avatar"
|
||||
:display-name="note.account.display_name"
|
||||
:url="reblogAccountUrl"
|
||||
:emojis="note.account.emojis"
|
||||
/>
|
||||
<Header
|
||||
:author="noteToUse.account"
|
||||
:author-url="accountUrl"
|
||||
:corner-avatar="note.reblog ? note.account.avatar : undefined"
|
||||
:note-url="url"
|
||||
:visibility="noteToUse.visibility"
|
||||
:created-at="new Date(noteToUse.created_at)"
|
||||
:small-layout="smallLayout"
|
||||
class="z-[1]"
|
||||
/>
|
||||
<div
|
||||
v-if="topAvatarBar"
|
||||
:class="
|
||||
cn(
|
||||
'shrink-0 bg-border w-0.5 absolute top-0 h-7 left-[3rem]'
|
||||
)
|
||||
"
|
||||
></div>
|
||||
<div
|
||||
v-if="bottomAvatarBar"
|
||||
:class="
|
||||
cn(
|
||||
'shrink-0 bg-border w-0.5 absolute bottom-0 h-[calc(100%-1.5rem)] left-[3rem]'
|
||||
)
|
||||
"
|
||||
></div>
|
||||
</CardHeader>
|
||||
<!-- Simply offset by the size of avatar + 0.75rem (the gap) -->
|
||||
<CardContent
|
||||
:class="
|
||||
['space-y-4', contentUnderUsername && (smallLayout ? 'ml-11' : 'ml-[4.25rem]')]
|
||||
"
|
||||
>
|
||||
<Content
|
||||
:content="noteToUse.content"
|
||||
:quote="note.quote ?? undefined"
|
||||
:attachments="noteToUse.media_attachments"
|
||||
:plain-content="noteToUse.text ?? undefined"
|
||||
:emojis="noteToUse.emojis"
|
||||
:sensitive="noteToUse.sensitive"
|
||||
:content-warning="noteToUse.spoiler_text"
|
||||
/>
|
||||
<Reactions v-if="noteToUse.reactions && noteToUse.reactions.length > 0" :reactions="noteToUse.reactions" :emojis="noteToUse.emojis" :status-id="noteToUse.id" />
|
||||
</CardContent>
|
||||
<CardFooter v-if="!hideActions">
|
||||
<Actions
|
||||
:reply-count="noteToUse.replies_count"
|
||||
:like-count="noteToUse.favourites_count"
|
||||
:url="url"
|
||||
:api-note-string="JSON.stringify(noteToUse, null, 4)"
|
||||
:reblog-count="noteToUse.reblogs_count"
|
||||
:remote-url="noteToUse.url ?? undefined"
|
||||
:is-remote="isRemote"
|
||||
:author-id="noteToUse.account.id"
|
||||
@edit="useEvent('composer:edit', noteToUse)"
|
||||
@reply="useEvent('composer:reply', noteToUse)"
|
||||
@quote="useEvent('composer:quote', noteToUse)"
|
||||
@delete="useEvent('note:delete', noteToUse)"
|
||||
:note-id="noteToUse.id"
|
||||
:liked="noteToUse.favourited ?? false"
|
||||
:reblogged="noteToUse.reblogged ?? false"
|
||||
/>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Status } from "@versia/client/schemas";
|
||||
import type { z } from "zod";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Card, CardContent, CardFooter, CardHeader } from "../ui/card";
|
||||
import Actions from "./actions.vue";
|
||||
import Content from "./content.vue";
|
||||
import Header from "./header.vue";
|
||||
import Reactions from "./reactions/index.vue";
|
||||
import ReblogHeader from "./reblog-header.vue";
|
||||
|
||||
type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
|
||||
|
||||
const { note } = defineProps<{
|
||||
note: PartialBy<z.infer<typeof Status>, "reblog" | "quote">;
|
||||
hideActions?: boolean;
|
||||
smallLayout?: boolean;
|
||||
contentUnderUsername?: boolean;
|
||||
topAvatarBar?: boolean;
|
||||
bottomAvatarBar?: boolean;
|
||||
}>();
|
||||
|
||||
// Notes can be reblogs, in which case the actual thing to render is inside the reblog property
|
||||
const noteToUse = computed(() =>
|
||||
note.reblog
|
||||
? (note.reblog as z.infer<typeof Status>)
|
||||
: (note as z.infer<typeof Status>),
|
||||
);
|
||||
|
||||
const url = wrapUrl(`/@${noteToUse.value.account.acct}/${noteToUse.value.id}`);
|
||||
const accountUrl = wrapUrl(`/@${noteToUse.value.account.acct}`);
|
||||
const reblogAccountUrl = wrapUrl(`/@${note.account.acct}`);
|
||||
const isRemote = noteToUse.value.account.acct.includes("@");
|
||||
</script>
|
||||
49
app/components/notes/overflow-guard.vue
Normal file
49
app/components/notes/overflow-guard.vue
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<template>
|
||||
<div ref="container" class="overflow-y-hidden relative duration-200" :style="{
|
||||
maxHeight: collapsed ? '18rem' : `${container?.scrollHeight}px`,
|
||||
}">
|
||||
<slot />
|
||||
<div v-if="isOverflowing && collapsed"
|
||||
class="absolute inset-x-0 bottom-0 h-36 bg-gradient-to-t from-black/5 to-transparent rounded-b"></div>
|
||||
<Button v-if="isOverflowing" @click="collapsed = !collapsed"
|
||||
class="absolute bottom-2 right-1/2 translate-x-1/2">{{
|
||||
collapsed
|
||||
? `${m.lazy_honest_mammoth_bump()}${formattedCharacterCount ? ` • ${m.dark_spare_goldfish_charm({
|
||||
count: formattedCharacterCount,
|
||||
})}` : ""}`
|
||||
: m.that_misty_mule_arrive()
|
||||
}}</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import * as m from "~~/paraglide/messages.js";
|
||||
import { getLocale } from "~~/paraglide/runtime";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
const { characterCount = 0 } = defineProps<{
|
||||
characterCount?: number;
|
||||
}>();
|
||||
|
||||
const container = useTemplateRef<HTMLDivElement>("container");
|
||||
const collapsed = ref(true);
|
||||
|
||||
// max-h-72 is 18rem
|
||||
const remToPx = (rem: number) =>
|
||||
rem *
|
||||
Number.parseFloat(
|
||||
getComputedStyle(document.documentElement).fontSize || "16px",
|
||||
);
|
||||
|
||||
const isOverflowing = computed(() => {
|
||||
if (!container.value) {
|
||||
return false;
|
||||
}
|
||||
return container.value.scrollHeight > remToPx(18);
|
||||
});
|
||||
|
||||
const formattedCharacterCount =
|
||||
characterCount > 0
|
||||
? new Intl.NumberFormat(getLocale()).format(characterCount)
|
||||
: undefined;
|
||||
</script>
|
||||
12
app/components/notes/prose.vue
Normal file
12
app/components/notes/prose.vue
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<template>
|
||||
<div :class="[
|
||||
'prose prose-sm block relative dark:prose-invert duration-200 !max-w-full break-words prose-a:no-underline hover:prose-a:underline',
|
||||
$style.content,
|
||||
]">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
@import "~/styles/content.css";
|
||||
</style>
|
||||
17
app/components/notes/reactions/index.vue
Normal file
17
app/components/notes/reactions/index.vue
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<template>
|
||||
<div class="flex flex-row gap-2 flex-wrap">
|
||||
<Reaction v-for="reaction in reactions" :key="reaction.name" :reaction="reaction" :emoji="emojis.find(e => `:${e.shortcode}:` === reaction.name)" :status-id="statusId" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { CustomEmoji, NoteReaction } from "@versia/client/schemas";
|
||||
import type { z } from "zod";
|
||||
import Reaction from "./reaction.vue";
|
||||
|
||||
const { statusId, reactions, emojis } = defineProps<{
|
||||
statusId: string;
|
||||
reactions: z.infer<typeof NoteReaction>[];
|
||||
emojis: z.infer<typeof CustomEmoji>[];
|
||||
}>();
|
||||
</script>
|
||||
15
app/components/notes/reactions/picker/category-header.vue
Normal file
15
app/components/notes/reactions/picker/category-header.vue
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<template>
|
||||
<div class="sticky top-2 z-10 flex items-center justify-center p-2">
|
||||
<Badge variant="secondary">
|
||||
{{ categoryName }}
|
||||
</Badge>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
|
||||
const { categoryName } = defineProps<{
|
||||
categoryName: string;
|
||||
}>();
|
||||
</script>
|
||||
21
app/components/notes/reactions/picker/display.vue
Normal file
21
app/components/notes/reactions/picker/display.vue
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<template>
|
||||
<div class="p-2 text-sm font-semibold border-0 rounded-none text-center flex flex-row items-center gap-2 truncate">
|
||||
<img v-if="(emoji as InferredEmoji)?.url" :src="(emoji as InferredEmoji)?.url"
|
||||
:alt="(emoji as InferredEmoji)?.shortcode" class="h-8 align-middle inline not-prose" />
|
||||
<span v-else-if="(emoji as UnicodeEmoji)?.unicode" class="text-2xl align-middle inline not-prose">
|
||||
{{ (emoji as UnicodeEmoji)?.unicode }}
|
||||
</span>
|
||||
{{ (emoji as InferredEmoji)?.shortcode || (emoji as UnicodeEmoji)?.shortcode }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { CustomEmoji } from "@versia/client/schemas";
|
||||
import type { z } from "zod";
|
||||
import type { UnicodeEmoji } from "./emoji.ts";
|
||||
|
||||
type InferredEmoji = z.infer<typeof CustomEmoji>;
|
||||
const { emoji } = defineProps<{
|
||||
emoji: z.infer<typeof CustomEmoji> | UnicodeEmoji | null;
|
||||
}>();
|
||||
</script>
|
||||
140
app/components/notes/reactions/picker/emoji.ts
Normal file
140
app/components/notes/reactions/picker/emoji.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
/**
|
||||
* Adapted from Cinny's code
|
||||
* @see https://github.com/cinnyapp/cinny/blob/e6f4eeca8edc85ab64179e545b4e2e8c15763633/src/app/plugins/emoji.ts
|
||||
*/
|
||||
|
||||
import type { CompactEmoji } from "emojibase";
|
||||
import emojisData from "emojibase-data/en/compact.json";
|
||||
import joypixels from "emojibase-data/en/shortcodes/joypixels.json";
|
||||
|
||||
export type UnicodeEmoji = CompactEmoji & {
|
||||
shortcode: string;
|
||||
};
|
||||
|
||||
export enum EmojiGroupId {
|
||||
People = "People",
|
||||
Nature = "Nature",
|
||||
Food = "Food",
|
||||
Activity = "Activity",
|
||||
Travel = "Travel",
|
||||
Object = "Object",
|
||||
Symbol = "Symbol",
|
||||
Flag = "Flag",
|
||||
}
|
||||
|
||||
export type UnicodeEmojiGroup = {
|
||||
id: EmojiGroupId;
|
||||
order: number;
|
||||
emojis: UnicodeEmoji[];
|
||||
};
|
||||
|
||||
export const getShortcodesFor = (
|
||||
hexcode: string,
|
||||
): string[] | string | undefined => joypixels[hexcode];
|
||||
|
||||
export const getShortcodeFor = (hexcode: string): string | undefined => {
|
||||
const shortcode = joypixels[hexcode];
|
||||
return Array.isArray(shortcode) ? shortcode[0] : shortcode;
|
||||
};
|
||||
|
||||
export const emojiGroups: UnicodeEmojiGroup[] = [
|
||||
{
|
||||
id: EmojiGroupId.People,
|
||||
order: 0,
|
||||
emojis: [],
|
||||
},
|
||||
{
|
||||
id: EmojiGroupId.Nature,
|
||||
order: 1,
|
||||
emojis: [],
|
||||
},
|
||||
{
|
||||
id: EmojiGroupId.Food,
|
||||
order: 2,
|
||||
emojis: [],
|
||||
},
|
||||
{
|
||||
id: EmojiGroupId.Activity,
|
||||
order: 3,
|
||||
emojis: [],
|
||||
},
|
||||
{
|
||||
id: EmojiGroupId.Travel,
|
||||
order: 4,
|
||||
emojis: [],
|
||||
},
|
||||
{
|
||||
id: EmojiGroupId.Object,
|
||||
order: 5,
|
||||
emojis: [],
|
||||
},
|
||||
{
|
||||
id: EmojiGroupId.Symbol,
|
||||
order: 6,
|
||||
emojis: [],
|
||||
},
|
||||
{
|
||||
id: EmojiGroupId.Flag,
|
||||
order: 7,
|
||||
emojis: [],
|
||||
},
|
||||
];
|
||||
|
||||
export const emojis: UnicodeEmoji[] = [];
|
||||
|
||||
function addEmojiToGroup(groupIndex: number, emoji: UnicodeEmoji) {
|
||||
emojiGroups[groupIndex]?.emojis.push(emoji);
|
||||
}
|
||||
|
||||
function getGroupIndex(emoji: UnicodeEmoji): number | undefined {
|
||||
switch (emoji.group) {
|
||||
case 0:
|
||||
case 1:
|
||||
return 0; // People
|
||||
case 3:
|
||||
return 1; // Nature
|
||||
case 4:
|
||||
return 2; // Food
|
||||
case 6:
|
||||
return 3; // Activity
|
||||
case 5:
|
||||
return 4; // Travel
|
||||
case 7:
|
||||
return 5; // Object
|
||||
case 8:
|
||||
case undefined:
|
||||
return 6; // Symbol
|
||||
case 9:
|
||||
return 7; // Flag
|
||||
default:
|
||||
return undefined; // Unknown group
|
||||
}
|
||||
}
|
||||
|
||||
for (const emoji of emojisData) {
|
||||
const myShortCodes = getShortcodesFor(emoji.hexcode);
|
||||
|
||||
if (!myShortCodes) {
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(myShortCodes) && myShortCodes.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const em: UnicodeEmoji = {
|
||||
...emoji,
|
||||
shortcode: Array.isArray(myShortCodes)
|
||||
? (myShortCodes[0] as string)
|
||||
: myShortCodes,
|
||||
shortcodes: Array.isArray(myShortCodes)
|
||||
? myShortCodes
|
||||
: emoji.shortcodes,
|
||||
};
|
||||
|
||||
const groupIndex = getGroupIndex(em);
|
||||
|
||||
if (groupIndex !== undefined) {
|
||||
addEmojiToGroup(groupIndex, em);
|
||||
emojis.push(em);
|
||||
}
|
||||
}
|
||||
27
app/components/notes/reactions/picker/emoji.vue
Normal file
27
app/components/notes/reactions/picker/emoji.vue
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<template>
|
||||
<Button @focus="() => emit('select', emoji)" @mouseenter="() => emit('select', emoji)" @click="() => emit('pick', emoji)" size="icon" variant="ghost"
|
||||
class="size-12">
|
||||
<img v-if="(emoji as InferredEmoji).url" :src="(emoji as InferredEmoji).url"
|
||||
:alt="(emoji as InferredEmoji).shortcode" class="h-8 align-middle inline not-prose" />
|
||||
<span v-else-if="(emoji as UnicodeEmoji).unicode" class="text-2xl align-middle inline not-prose">
|
||||
{{ (emoji as UnicodeEmoji).unicode }}
|
||||
</span>
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { CustomEmoji } from "@versia/client/schemas";
|
||||
import type { z } from "zod";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import type { UnicodeEmoji } from "./emoji";
|
||||
|
||||
const { emoji } = defineProps<{
|
||||
emoji: z.infer<typeof CustomEmoji> | UnicodeEmoji;
|
||||
}>();
|
||||
type InferredEmoji = z.infer<typeof CustomEmoji>;
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [emoji: z.infer<typeof CustomEmoji> | UnicodeEmoji];
|
||||
pick: [emoji: z.infer<typeof CustomEmoji> | UnicodeEmoji];
|
||||
}>();
|
||||
</script>
|
||||
138
app/components/notes/reactions/picker/index.vue
Normal file
138
app/components/notes/reactions/picker/index.vue
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
<template>
|
||||
<Popover v-model:open="open">
|
||||
<PopoverTrigger as-child>
|
||||
<slot />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="p-0 w-fit">
|
||||
<div class="grid-cols-[minmax(0,1fr)_auto] gap-0 grid divide-x *:h-112 *:overflow-y-auto"
|
||||
orientation="vertical">
|
||||
<div class="grid grid-rows-[auto_minmax(0,1fr)_auto] gap-0" ref="emojiContainer">
|
||||
<div class="p-2">
|
||||
<Input placeholder="Search" v-model="filter" />
|
||||
</div>
|
||||
<VList :data="virtualizedItems" #default="{ item }" class="relative" :style="{
|
||||
width: `calc(var(--spacing) * ((12 * ${EMOJI_PER_ROW}) + (${EMOJI_PER_ROW} - 1)) + var(--spacing) * 4)`,
|
||||
}">
|
||||
<CategoryHeader :key="item.headerId" v-if="item.type === 'header'" :category-name="item.name" />
|
||||
<div v-else-if="item.type === 'emoji-row'" :key="item.rowId" class="flex gap-1 p-2">
|
||||
<Emoji v-for="emoji in item.emojis" :key="getEmojiKey(emoji)" :emoji="emoji"
|
||||
@select="(e) => selectedEmoji = e" @pick="e => {
|
||||
emit('pick', e); open = false;
|
||||
}" />
|
||||
</div>
|
||||
</VList>
|
||||
<EmojiDisplay :emoji="selectedEmoji" :style="{
|
||||
width: `calc(var(--spacing) * ((12 * ${EMOJI_PER_ROW}) + (${EMOJI_PER_ROW} - 1)) + var(--spacing) * 4)`,
|
||||
}" />
|
||||
</div>
|
||||
<Sidebar :categories="categories" @select="scrollToCategory" />
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { CustomEmoji } from "@versia/client/schemas";
|
||||
import { VList } from "virtua/vue";
|
||||
import type { z } from "zod";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "~/components/ui/popover";
|
||||
import CategoryHeader from "./category-header.vue";
|
||||
import EmojiDisplay from "./display.vue";
|
||||
import { type EmojiGroupId, emojiGroups, type UnicodeEmoji } from "./emoji.ts";
|
||||
import Emoji from "./emoji.vue";
|
||||
import Sidebar from "./sidebar.vue";
|
||||
import { EMOJI_PER_ROW, getVirtualizedItems } from "./virtual.ts";
|
||||
|
||||
const emit = defineEmits<{
|
||||
pick: [emoji: z.infer<typeof CustomEmoji> | UnicodeEmoji];
|
||||
}>();
|
||||
|
||||
const open = ref(false);
|
||||
const selectedEmoji = ref<z.infer<typeof CustomEmoji> | UnicodeEmoji | null>(
|
||||
null,
|
||||
);
|
||||
const emojiContainer = useTemplateRef<HTMLDivElement>("emojiContainer");
|
||||
const filter = ref("");
|
||||
|
||||
const customEmojis = computed(() => identity.value?.emojis ?? []);
|
||||
|
||||
const customEmojiCategories = computed(() => {
|
||||
const categories: Record<string, z.infer<typeof CustomEmoji>[]> = {};
|
||||
|
||||
for (const emoji of customEmojis.value) {
|
||||
const categoryName = emoji.category || "Uncategorized";
|
||||
|
||||
if (!categories[categoryName]) {
|
||||
categories[categoryName] = [];
|
||||
}
|
||||
|
||||
categories[categoryName]?.push(emoji);
|
||||
}
|
||||
|
||||
return categories;
|
||||
});
|
||||
|
||||
const categories = computed(() => {
|
||||
const customCategories = Object.entries(customEmojiCategories.value).map(
|
||||
([name, emojis]) => ({
|
||||
name,
|
||||
src: (emojis[0]?.url as string) || "",
|
||||
}),
|
||||
);
|
||||
|
||||
const groupCategories = emojiGroups.map((group) => ({
|
||||
name: group.id,
|
||||
groupId: group.id,
|
||||
}));
|
||||
|
||||
return [...customCategories, ...groupCategories];
|
||||
});
|
||||
|
||||
const virtualizedItems = computed(() =>
|
||||
getVirtualizedItems(customEmojiCategories.value, filter.value),
|
||||
);
|
||||
|
||||
const getEmojiKey = (emoji: z.infer<typeof CustomEmoji> | UnicodeEmoji) => {
|
||||
if ("url" in emoji) {
|
||||
return `custom-${emoji.shortcode}`;
|
||||
}
|
||||
return `unicode-${emoji.shortcode}`;
|
||||
};
|
||||
|
||||
const scrollToCategory = (category: {
|
||||
name: string;
|
||||
groupId?: EmojiGroupId;
|
||||
src?: string;
|
||||
}) => {
|
||||
const categoryId = category.groupId || `custom-${category.name}`;
|
||||
const headerIndex = virtualizedItems.value.findIndex(
|
||||
(item) => item.type === "header" && item.categoryId === categoryId,
|
||||
);
|
||||
|
||||
const child = emojiContainer.value?.children[1];
|
||||
|
||||
if (headerIndex !== -1 && child) {
|
||||
// Estimate scroll position based on item heights
|
||||
// Headers are approximately 38px, emoji rows are approximately 64px
|
||||
let scrollTop = 0;
|
||||
for (let i = 0; i < headerIndex; i++) {
|
||||
const item = virtualizedItems.value[i];
|
||||
if (item?.type === "header") {
|
||||
scrollTop += 38;
|
||||
} else if (item?.type === "emoji-row") {
|
||||
scrollTop += 64;
|
||||
}
|
||||
}
|
||||
|
||||
child.scrollTo({
|
||||
top: scrollTop,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
};
|
||||
</script>
|
||||
47
app/components/notes/reactions/picker/sidebar.vue
Normal file
47
app/components/notes/reactions/picker/sidebar.vue
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<template>
|
||||
<div class="grid gap-1 bg-transparent p-2">
|
||||
<Button v-for="category in categories" :key="category.name" size="icon" variant="ghost" @click="() => emit('select', category)">
|
||||
<component v-if="category.groupId" :is="emojiGroupIconMap[category.groupId]" class="size-6 text-primary" />
|
||||
<img v-else-if="category.src" :src="category.src" class="size-6 align-middle inline not-prose" role="presentation" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
Box,
|
||||
CarFront,
|
||||
Flag,
|
||||
Leaf,
|
||||
Percent,
|
||||
Pizza,
|
||||
Smile,
|
||||
Volleyball,
|
||||
} from "lucide-vue-next";
|
||||
import type { FunctionalComponent } from "vue";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { EmojiGroupId } from "./emoji";
|
||||
|
||||
const { categories } = defineProps<{
|
||||
categories: {
|
||||
name: string;
|
||||
groupId?: EmojiGroupId;
|
||||
src?: string;
|
||||
}[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [category: { name: string; groupId?: EmojiGroupId; src?: string }];
|
||||
}>();
|
||||
|
||||
const emojiGroupIconMap: Record<EmojiGroupId, FunctionalComponent> = {
|
||||
[EmojiGroupId.People]: Smile,
|
||||
[EmojiGroupId.Nature]: Leaf,
|
||||
[EmojiGroupId.Food]: Pizza,
|
||||
[EmojiGroupId.Activity]: Volleyball,
|
||||
[EmojiGroupId.Travel]: CarFront,
|
||||
[EmojiGroupId.Object]: Box,
|
||||
[EmojiGroupId.Symbol]: Percent,
|
||||
[EmojiGroupId.Flag]: Flag,
|
||||
};
|
||||
</script>
|
||||
117
app/components/notes/reactions/picker/virtual.ts
Normal file
117
app/components/notes/reactions/picker/virtual.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import type { CustomEmoji } from "@versia/client/schemas";
|
||||
import { go } from "fuzzysort";
|
||||
import { nanoid } from "nanoid";
|
||||
import type { z } from "zod";
|
||||
import { emojiGroups, type UnicodeEmoji } from "./emoji";
|
||||
|
||||
export const EMOJI_PER_ROW = 7;
|
||||
export type VirtualizedItem =
|
||||
| { headerId: string; type: "header"; name: string; categoryId: string }
|
||||
| {
|
||||
rowId: string;
|
||||
type: "emoji-row";
|
||||
emojis: (z.infer<typeof CustomEmoji> | UnicodeEmoji)[];
|
||||
};
|
||||
|
||||
export const getVirtualizedItems = (
|
||||
customCategories: Record<string, z.infer<typeof CustomEmoji>[]>,
|
||||
searchQuery?: string,
|
||||
): VirtualizedItem[] => {
|
||||
const items: VirtualizedItem[] = [];
|
||||
|
||||
// Add custom emoji categories first
|
||||
for (const [categoryName, categoryEmojis] of Object.entries(
|
||||
customCategories,
|
||||
)) {
|
||||
// Add category header
|
||||
items.push({
|
||||
headerId: nanoid(),
|
||||
type: "header",
|
||||
name: categoryName,
|
||||
categoryId: `custom-${categoryName}`,
|
||||
});
|
||||
|
||||
// Add emoji rows for this category
|
||||
for (let i = 0; i < categoryEmojis.length; i += EMOJI_PER_ROW) {
|
||||
items.push({
|
||||
rowId: nanoid(),
|
||||
type: "emoji-row",
|
||||
emojis: categoryEmojis.slice(i, i + EMOJI_PER_ROW),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add unicode emoji groups
|
||||
for (const group of emojiGroups) {
|
||||
if (group.emojis.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add group header
|
||||
items.push({
|
||||
headerId: nanoid(),
|
||||
type: "header",
|
||||
name: group.id,
|
||||
categoryId: group.id,
|
||||
});
|
||||
|
||||
// Add emoji rows for this group
|
||||
for (let i = 0; i < group.emojis.length; i += EMOJI_PER_ROW) {
|
||||
items.push({
|
||||
rowId: nanoid(),
|
||||
type: "emoji-row",
|
||||
emojis: group.emojis.slice(i, i + EMOJI_PER_ROW),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// If search query is provided, add extra category for search results
|
||||
// with emojis that contain the search query in their shortcode
|
||||
// ordered with fuzzysort
|
||||
if (searchQuery) {
|
||||
const customEmojiMatches = Object.values(customCategories)
|
||||
.flat()
|
||||
.filter((emoji) =>
|
||||
emoji.shortcode
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.toLowerCase()),
|
||||
);
|
||||
const unicodeEmojiMatches = emojiGroups
|
||||
.flatMap((group) => group.emojis)
|
||||
.filter((emoji) =>
|
||||
emoji.shortcode
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.toLowerCase()),
|
||||
);
|
||||
|
||||
const results = go(
|
||||
searchQuery,
|
||||
[...customEmojiMatches, ...unicodeEmojiMatches],
|
||||
{
|
||||
key: "shortcode",
|
||||
limit: 20,
|
||||
},
|
||||
);
|
||||
|
||||
items.splice(0, 0, {
|
||||
headerId: nanoid(),
|
||||
type: "header",
|
||||
name: "Search Results",
|
||||
categoryId: "search-results",
|
||||
});
|
||||
|
||||
for (let i = 0; i < results.length; i += EMOJI_PER_ROW) {
|
||||
const emojis = results
|
||||
.slice(i, i + EMOJI_PER_ROW)
|
||||
.map((result) => result.obj);
|
||||
|
||||
items.splice(1 + i / EMOJI_PER_ROW, 0, {
|
||||
rowId: nanoid(),
|
||||
type: "emoji-row",
|
||||
emojis,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
};
|
||||
101
app/components/notes/reactions/reaction.vue
Normal file
101
app/components/notes/reactions/reaction.vue
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
<template>
|
||||
<HoverCard @update:open="(open) => open && accounts === null && refreshReactions()">
|
||||
<HoverCardTrigger as-child>
|
||||
<Button @click="reaction.me ? !reaction.remote && unreact() : !reaction.remote && react()" :variant="reaction.me ? 'secondary' : reaction.remote ? 'ghost' : 'outline'" size="sm" class="gap-2">
|
||||
<img v-if="emoji" :src="emoji.url" :alt="emoji.shortcode"
|
||||
class="h-[1lh] align-middle inline not-prose" />
|
||||
<span v-else>
|
||||
{{ reaction.name }}
|
||||
</span>
|
||||
{{ formatNumber(reaction.count) }}
|
||||
</Button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent class="p-3">
|
||||
<Spinner v-if="accounts === null" class="border-0" />
|
||||
<ul v-else class="flex flex-col gap-4">
|
||||
<li
|
||||
v-for="account in accounts">
|
||||
<NuxtLink :to="`/@${account.acct}`" class="flex items-center gap-2">
|
||||
<Avatar class="size-6" :key="account.id" :src="account.avatar"
|
||||
:name="account.display_name || account.username" />
|
||||
<span class="text-sm font-semibold line-clamp-1">
|
||||
{{ account.display_name || account.username }}
|
||||
</span>
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type {
|
||||
Account,
|
||||
CustomEmoji,
|
||||
NoteReaction,
|
||||
} from "@versia/client/schemas";
|
||||
import { toast } from "vue-sonner";
|
||||
import type { z } from "zod";
|
||||
import Spinner from "~/components/graphics/spinner.vue";
|
||||
import Avatar from "~/components/profiles/avatar.vue";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "~/components/ui/hover-card";
|
||||
import * as m from "~~/paraglide/messages.js";
|
||||
import { getLocale } from "~~/paraglide/runtime.js";
|
||||
|
||||
const { reaction, emoji, statusId } = defineProps<{
|
||||
statusId: string;
|
||||
reaction: z.infer<typeof NoteReaction>;
|
||||
emoji?: z.infer<typeof CustomEmoji>;
|
||||
}>();
|
||||
|
||||
const formatNumber = (number: number) =>
|
||||
new Intl.NumberFormat(getLocale(), {
|
||||
notation: "compact",
|
||||
compactDisplay: "short",
|
||||
maximumFractionDigits: 1,
|
||||
}).format(number);
|
||||
|
||||
const accounts = ref<z.infer<typeof Account>[] | null>(null);
|
||||
|
||||
const refreshReactions = async () => {
|
||||
const { data } = await client.value.getStatusReactions(statusId);
|
||||
const accountIds =
|
||||
data.find((r) => r.name === reaction.name)?.account_ids.slice(0, 10) ??
|
||||
[];
|
||||
|
||||
const { data: accountsData } = await client.value.getAccounts(accountIds);
|
||||
|
||||
accounts.value = accountsData;
|
||||
};
|
||||
|
||||
const react = async () => {
|
||||
const id = toast.loading(m.gray_stale_antelope_roam());
|
||||
|
||||
const { data } = await client.value.createEmojiReaction(
|
||||
statusId,
|
||||
reaction.name,
|
||||
);
|
||||
|
||||
toast.dismiss(id);
|
||||
toast.success(m.main_least_turtle_fall());
|
||||
useEvent("note:edit", data);
|
||||
};
|
||||
|
||||
const unreact = async () => {
|
||||
const id = toast.loading(m.many_weary_bat_intend());
|
||||
|
||||
const { data } = await client.value.deleteEmojiReaction(
|
||||
statusId,
|
||||
reaction.name,
|
||||
);
|
||||
|
||||
toast.dismiss(id);
|
||||
toast.success(m.aware_even_oryx_race());
|
||||
useEvent("note:edit", data);
|
||||
};
|
||||
</script>
|
||||
28
app/components/notes/reblog-header.vue
Normal file
28
app/components/notes/reblog-header.vue
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<template>
|
||||
<NuxtLink :href="urlAsPath">
|
||||
<Card class="flex-row px-2 py-1 items-center gap-2 hover:bg-muted duration-100 text-sm">
|
||||
<Repeat class="size-4 text-primary" />
|
||||
<Avatar class="size-6 border" :src="avatar" :name="displayName" />
|
||||
<span class="font-semibold" v-render-emojis="emojis">{{ displayName }}</span>
|
||||
{{ m.large_vivid_horse_catch() }}
|
||||
</Card>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { CustomEmoji } from "@versia/client/schemas";
|
||||
import { Repeat } from "lucide-vue-next";
|
||||
import type { z } from "zod";
|
||||
import * as m from "~~/paraglide/messages.js";
|
||||
import Avatar from "../profiles/avatar.vue";
|
||||
import { Card } from "../ui/card";
|
||||
|
||||
const { url } = defineProps<{
|
||||
avatar: string;
|
||||
displayName: string;
|
||||
emojis: z.infer<typeof CustomEmoji>[];
|
||||
url: string;
|
||||
}>();
|
||||
|
||||
const urlAsPath = new URL(url).pathname;
|
||||
</script>
|
||||
29
app/components/notes/thread.vue
Normal file
29
app/components/notes/thread.vue
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<template>
|
||||
<div>
|
||||
<Note
|
||||
v-if="parent"
|
||||
:note="parent"
|
||||
:hide-actions="true"
|
||||
:content-under-username="true"
|
||||
:bottom-avatar-bar="true"
|
||||
class="border-b-0 rounded-b-none"
|
||||
/>
|
||||
<Note
|
||||
:note="note"
|
||||
:class="parent && 'border-t-0 rounded-t-none'"
|
||||
:top-avatar-bar="!!parent"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Status } from "@versia/client/schemas";
|
||||
import type { z } from "zod";
|
||||
import Note from "./note.vue";
|
||||
|
||||
const { note } = defineProps<{
|
||||
note: z.infer<typeof Status>;
|
||||
}>();
|
||||
|
||||
const parent = useNote(client, note.in_reply_to_id);
|
||||
</script>
|
||||
Loading…
Add table
Add a link
Reference in a new issue