chore: ⬆️ Upgrade to Nuxt 4
Some checks failed
CodeQL / Analyze (javascript) (push) Failing after 1s
Deploy to GitHub Pages / build (push) Failing after 1s
Deploy to GitHub Pages / deploy (push) Has been skipped
Docker / build (push) Failing after 1s
Mirror to Codeberg / Mirror (push) Failing after 1s

This commit is contained in:
Jesse Wierzbinski 2025-07-16 07:48:39 +02:00
parent 8debe97f63
commit 7f7cf20311
386 changed files with 2376 additions and 2332 deletions

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

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

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

View 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);
}
}

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

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

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

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

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