refactor: ♻️ Rewrite entire preferences panel and emoji manager from scratch

This commit is contained in:
Jesse Wierzbinski 2025-04-30 01:44:16 +02:00
parent 0443a37508
commit 17bb75733c
No known key found for this signature in database
51 changed files with 1982 additions and 17 deletions

View file

@ -8,7 +8,8 @@
"rules": { "rules": {
"all": true, "all": true,
"suspicious": { "suspicious": {
"noConsole": "off" "noConsole": "off",
"noExplicitAny": "off"
}, },
"performance": { "performance": {
"noBarrelFile": "off" "noBarrelFile": "off"

View file

@ -8,6 +8,7 @@
"@nuxtjs/color-mode": "3.5.2", "@nuxtjs/color-mode": "3.5.2",
"@tailwindcss/typography": "^0.5.16", "@tailwindcss/typography": "^0.5.16",
"@tailwindcss/vite": "^4.1.4", "@tailwindcss/vite": "^4.1.4",
"@tanstack/vue-table": "^8.21.3",
"@tiptap/extension-highlight": "^2.11.7", "@tiptap/extension-highlight": "^2.11.7",
"@tiptap/extension-image": "^2.11.7", "@tiptap/extension-image": "^2.11.7",
"@tiptap/extension-link": "^2.11.7", "@tiptap/extension-link": "^2.11.7",
@ -47,6 +48,7 @@
"tw-animate-css": "^1.2.8", "tw-animate-css": "^1.2.8",
"vaul-vue": "^0.4.1", "vaul-vue": "^0.4.1",
"vee-validate": "^4.15.0", "vee-validate": "^4.15.0",
"virtua": "^0.40.4",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-router": "^4.5.1", "vue-router": "^4.5.1",
"vue-sonner": "^1.3.2", "vue-sonner": "^1.3.2",
@ -620,8 +622,12 @@
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.4", "", { "dependencies": { "@tailwindcss/node": "4.1.4", "@tailwindcss/oxide": "4.1.4", "tailwindcss": "4.1.4" }, "peerDependencies": { "vite": "^5.2.0 || ^6" } }, "sha512-4UQeMrONbvrsXKXXp/uxmdEN5JIJ9RkH7YVzs6AMxC/KC1+Np7WZBaNIco7TEjlkthqxZbt8pU/ipD+hKjm80A=="], "@tailwindcss/vite": ["@tailwindcss/vite@4.1.4", "", { "dependencies": { "@tailwindcss/node": "4.1.4", "@tailwindcss/oxide": "4.1.4", "tailwindcss": "4.1.4" }, "peerDependencies": { "vite": "^5.2.0 || ^6" } }, "sha512-4UQeMrONbvrsXKXXp/uxmdEN5JIJ9RkH7YVzs6AMxC/KC1+Np7WZBaNIco7TEjlkthqxZbt8pU/ipD+hKjm80A=="],
"@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="],
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.5", "", {}, "sha512-gMLNylxhJdUlfRR1G3U9rtuwUh2IjdrrniJIDcekVJN3/3i+bluvdMi3+eodnxzJq5nKnxnigo9h0lIpaqV6HQ=="], "@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.5", "", {}, "sha512-gMLNylxhJdUlfRR1G3U9rtuwUh2IjdrrniJIDcekVJN3/3i+bluvdMi3+eodnxzJq5nKnxnigo9h0lIpaqV6HQ=="],
"@tanstack/vue-table": ["@tanstack/vue-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "vue": ">=3.2" } }, "sha512-rusRyd77c5tDPloPskctMyPLFEQUeBzxdQ+2Eow4F7gDPlPOB1UnnhzfpdvqZ8ZyX2rRNGmqNnQWm87OI2OQPw=="],
"@tanstack/vue-virtual": ["@tanstack/vue-virtual@3.13.5", "", { "dependencies": { "@tanstack/virtual-core": "3.13.5" }, "peerDependencies": { "vue": "^2.7.0 || ^3.0.0" } }, "sha512-1hhUA6CUjmKc5JDyKLcYOV6mI631FaKKxXh77Ja4UtIy6EOofYaLPk8vVgvK6vLMUSfHR2vI3ZpPY9ibyX60SA=="], "@tanstack/vue-virtual": ["@tanstack/vue-virtual@3.13.5", "", { "dependencies": { "@tanstack/virtual-core": "3.13.5" }, "peerDependencies": { "vue": "^2.7.0 || ^3.0.0" } }, "sha512-1hhUA6CUjmKc5JDyKLcYOV6mI631FaKKxXh77Ja4UtIy6EOofYaLPk8vVgvK6vLMUSfHR2vI3ZpPY9ibyX60SA=="],
"@tiptap/core": ["@tiptap/core@2.11.7", "", { "peerDependencies": { "@tiptap/pm": "^2.7.0" } }, "sha512-zN+NFFxLsxNEL8Qioc+DL6b8+Tt2bmRbXH22Gk6F6nD30x83eaUSFlSv3wqvgyCq3I1i1NO394So+Agmayx6rQ=="], "@tiptap/core": ["@tiptap/core@2.11.7", "", { "peerDependencies": { "@tiptap/pm": "^2.7.0" } }, "sha512-zN+NFFxLsxNEL8Qioc+DL6b8+Tt2bmRbXH22Gk6F6nD30x83eaUSFlSv3wqvgyCq3I1i1NO394So+Agmayx6rQ=="],
@ -2064,6 +2070,8 @@
"videojs-vtt.js": ["videojs-vtt.js@0.15.5", "", { "dependencies": { "global": "^4.3.1" } }, "sha512-yZbBxvA7QMYn15Lr/ZfhhLPrNpI/RmCSCqgIff57GC2gIrV5YfyzLfLyZMj0NnZSAz8syB4N0nHXpZg9MyrMOQ=="], "videojs-vtt.js": ["videojs-vtt.js@0.15.5", "", { "dependencies": { "global": "^4.3.1" } }, "sha512-yZbBxvA7QMYn15Lr/ZfhhLPrNpI/RmCSCqgIff57GC2gIrV5YfyzLfLyZMj0NnZSAz8syB4N0nHXpZg9MyrMOQ=="],
"virtua": ["virtua@0.40.4", "", { "peerDependencies": { "react": ">=16.14.0", "react-dom": ">=16.14.0", "solid-js": ">=1.0", "svelte": ">=5.0", "vue": ">=3.2" }, "optionalPeers": ["react", "react-dom", "solid-js", "svelte", "vue"] }, "sha512-eV55eOm2b5Lzc9upivqIcAFPgfBrfcVrppW9T4vhTH+QAbaxfw5ypq25apkG83T5FuiFEoQYnefix1fQyx/GXQ=="],
"vite": ["vite@6.2.6", "", { "dependencies": { "esbuild": "^0.25.0", "postcss": "^8.5.3", "rollup": "^4.30.1" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-9xpjNl3kR4rVDZgPNdTL0/c6ao4km69a/2ihNQbcANz8RuCOK3hQBmLSJf3bRKVQjVMda+YvizNE8AwvogcPbw=="], "vite": ["vite@6.2.6", "", { "dependencies": { "esbuild": "^0.25.0", "postcss": "^8.5.3", "rollup": "^4.30.1" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-9xpjNl3kR4rVDZgPNdTL0/c6ao4km69a/2ihNQbcANz8RuCOK3hQBmLSJf3bRKVQjVMda+YvizNE8AwvogcPbw=="],
"vite-dev-rpc": ["vite-dev-rpc@1.0.7", "", { "dependencies": { "birpc": "^2.0.19", "vite-hot-client": "^2.0.4" }, "peerDependencies": { "vite": "^2.9.0 || ^3.0.0-0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.1" } }, "sha512-FxSTEofDbUi2XXujCA+hdzCDkXFG1PXktMjSk1efq9Qb5lOYaaM9zNSvKvPPF7645Bak79kSp1PTooMW2wktcA=="], "vite-dev-rpc": ["vite-dev-rpc@1.0.7", "", { "dependencies": { "birpc": "^2.0.19", "vite-hot-client": "^2.0.4" }, "peerDependencies": { "vite": "^2.9.0 || ^3.0.0-0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.1" } }, "sha512-FxSTEofDbUi2XXujCA+hdzCDkXFG1PXktMjSk1efq9Qb5lOYaaM9zNSvKvPPF7645Bak79kSp1PTooMW2wktcA=="],

View file

@ -0,0 +1,45 @@
<template>
<section class="space-y-2">
<CardTitle class="text-xs">
{{ name }}
</CardTitle>
<Card class="p-0 gap-0">
<div v-for="preference of preferences" :key="preference">
<TextPreferenceVue v-if="(prefs[preference] instanceof TextPreference)" :pref="(prefs[preference] as TextPreference)" />
<BooleanPreferenceVue v-else-if="(prefs[preference] instanceof BooleanPreference)" :pref="(prefs[preference] as BooleanPreference)" />
<SelectPreferenceVue v-else-if="(prefs[preference] instanceof SelectPreference)" :pref="(prefs[preference] as SelectPreference<string>)" />
<NumberPreferenceVue v-else-if="(prefs[preference] instanceof NumberPreference)" :pref="(prefs[preference] as NumberPreference)" />
<MultiSelectPreferenceVue v-else-if="(prefs[preference] instanceof MultiSelectPreference)" :pref="(prefs[preference] as MultiSelectPreference<string>)" />
<CodePreferenceVue v-else-if="(prefs[preference] instanceof CodePreference)" :pref="(prefs[preference] as CodePreference)" />
<UrlPreferenceVue v-else-if="(prefs[preference] instanceof UrlPreference)" :pref="(prefs[preference] as UrlPreference)" />
</div>
</Card>
</section>
</template>
<script lang="ts" setup>
import { Card, CardTitle } from "../ui/card";
// biome-ignore lint/style/useImportType: <explanation>
import { preferences as prefs } from "./preferences.ts";
import {
BooleanPreference,
CodePreference,
MultiSelectPreference,
NumberPreference,
SelectPreference,
TextPreference,
UrlPreference,
} from "./types.ts";
import BooleanPreferenceVue from "./types/boolean.vue";
import CodePreferenceVue from "./types/code.vue";
import MultiSelectPreferenceVue from "./types/multiselect.vue";
import NumberPreferenceVue from "./types/number.vue";
import SelectPreferenceVue from "./types/select.vue";
import TextPreferenceVue from "./types/text.vue";
import UrlPreferenceVue from "./types/url.vue";
const { preferences = [], name } = defineProps<{
preferences: (keyof typeof prefs)[];
name: string;
}>();
</script>

View file

@ -0,0 +1,148 @@
<script setup lang="ts">
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
InfoIcon,
PaletteIcon,
SettingsIcon,
ShieldCheckIcon,
SmileIcon,
TerminalSquareIcon,
UserIcon,
} from "lucide-vue-next";
import pkg from "~/package.json";
import Avatar from "../profiles/avatar.vue";
import TinyCard from "../profiles/tiny-card.vue";
import { Separator } from "../ui/separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
import Category from "./category.vue";
import Emojis from "./emojis/index.vue";
import Page from "./page.vue";
import { preferences } from "./preferences";
import Stats from "./stats.vue";
const pages = Object.values(preferences)
.map((p) => p.options.category)
.filter((c) => c !== undefined)
.map((c) => c.split("/")[0] as string)
.concat(["Account", "Emojis", "Roles", "Developer", "About"])
// Remove duplicates
.filter((c, i, a) => a.indexOf(c) === i);
const extraPages = ["Account", "Emojis", "Roles", "Developer", "About"];
const icons: Record<string, Component> = {
Account: UserIcon,
Appearance: PaletteIcon,
Emojis: SmileIcon,
Behaviour: SettingsIcon,
Roles: ShieldCheckIcon,
Developer: TerminalSquareIcon,
About: InfoIcon,
};
// For each page, map the associated categories
const categories = Object.fromEntries(
pages.map((page) => {
const categories = Object.values(preferences)
.map((p) => p.options.category)
.filter((c) => c !== undefined)
.filter((c) => c.split("/")[0] === page)
.map((c) => c.split("/")[1] as string)
// Remove duplicates
.filter((c, i, a) => a.indexOf(c) === i);
return [page, categories];
}),
);
const { account: author1 } = useAccountFromAcct(
client,
"jessew@vs.cpluspatch.com",
);
const { account: author2 } = useAccountFromAcct(
client,
"aprl@social.lysand.org",
);
const { account: author3 } = useAccountFromAcct(
client,
"lina@social.lysand.org",
);
</script>
<template>
<Dialog open v-if="identity">
<DialogContent class="md:max-w-5xl w-full h-full p-0 md:max-h-[70dvh]">
<Tabs class="md:grid-cols-[auto_minmax(0,1fr)] !grid gap-2 *:p-4 overflow-hidden *:overflow-y-auto *:h-full" orientation="vertical"
:default-value="pages[0]">
<DialogHeader class="gap-6 grid grid-rows-[auto_minmax(0,1fr)] border-b md:border-b-0 md:border-r min-w-60 text-left">
<div class="grid gap-3 items-center grid-cols-[auto_minmax(0,1fr)]">
<Avatar :name="identity.account.display_name || identity.account.username"
:src="identity.account.avatar" />
<DialogTitle>Preferences</DialogTitle>
</div>
<DialogDescription class="sr-only">
Make changes to your preferences here.
</DialogDescription>
<TabsList class="md:grid md:grid-cols-1 w-full h-fit *:justify-start !justify-start">
<TabsTrigger v-for="page in pages" :key="page" :value="page">
<component :is="icons[page]" class="size-4 mr-2" />
{{ page }}
</TabsTrigger>
</TabsList>
</DialogHeader>
<TabsContent v-for="page in pages.filter(p => !extraPages.includes(p))" :key="page" :value="page"
as-child>
<Page :title="page">
<Category v-for="category in categories[page]" :key="category"
:preferences="Object.entries(preferences).filter(([, p]) => p.options.category === `${page}/${category}`).map(([k,]) => k as keyof typeof preferences)"
:name="category" />
</Page>
</TabsContent>
<TabsContent value="Emojis" as-child>
<Page title="Emojis">
<Emojis />
</Page>
</TabsContent>
<TabsContent value="About" as-child>
<Page title="About">
<section class="space-y-4">
<p class="leading-7 text-sm max-w-xl">
{{ pkg.description }}
</p>
<Stats />
</section>
<Separator />
<section class="space-y-2">
<h3 class="text-lg font-semibold tracking-tight">Developers</h3>
<div class="grid lg:grid-cols-3 md:grid-cols-2 grid-cols-1 gap-4">
<TinyCard v-if="author1" :account="author1" domain="vs.cpluspatch.com" />
<TinyCard v-if="author2" :account="author2" domain="social.lysand.org" />
<TinyCard v-if="author3" :account="author3" domain="social.lysand.org" />
</div>
</section>
<Separator />
<section class="space-y-2">
<h3 class="text-lg font-semibold tracking-tight">Dependencies</h3>
<ul class="grid lg:grid-cols-2 gap-2 grid-cols-1 items-center justify-center list-disc ml-6">
<li v-for="[dep, version] in Object.entries(pkg.dependencies)" :key="dep">
<code
class="rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-xs font-semibold">
{{ dep }}@{{ version }}
</code>
</li>
</ul>
</section>
</Page>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
</template>

View file

@ -0,0 +1,72 @@
<template>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<slot />
</DropdownMenuTrigger>
<DropdownMenuContent class="min-w-48">
<DropdownMenuItem @click="deleteAll" :disabled="!canEdit">
<Delete />
{{ m.tense_quick_cod_favor() }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template>
<script lang="ts" setup>
import { type Emoji, RolePermission } from "@versia/client/types";
import { Delete } from "lucide-vue-next";
import { toast } from "vue-sonner";
import { confirmModalService } from "~/components/modals/composable";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import * as m from "~/paraglide/messages.js";
const { emojis } = defineProps<{
emojis: Emoji[];
}>();
const permissions = usePermissions();
const canEdit =
(!emojis.some((e) => e.global) &&
permissions.value.includes(RolePermission.ManageOwnEmojis)) ||
permissions.value.includes(RolePermission.ManageEmojis);
const deleteAll = async () => {
if (!identity.value) {
return;
}
const { confirmed } = await confirmModalService.confirm({
title: m.tense_quick_cod_favor(),
message: m.next_hour_jurgen_sprout({
amount: emojis.length,
}),
confirmText: m.tense_quick_cod_favor(),
});
if (confirmed) {
const id = toast.loading(
m.equal_only_crow_file({
amount: emojis.length,
}),
);
try {
await Promise.all(
emojis.map((emoji) => client.value.deleteEmoji(emoji.id)),
);
toast.dismiss(id);
toast.success("Emojis deleted");
identity.value.emojis = identity.value.emojis.filter(
(e) => !emojis.some((emoji) => e.id === emoji.id),
);
} catch {
toast.dismiss(id);
}
}
};
</script>

View file

@ -0,0 +1,107 @@
<template>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="icon" title="Open menu" class="size-8 p-0">
<MoreHorizontal class="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent class="min-w-48">
<DropdownMenuItem @click="editName">
<TextCursorInput />
{{ m.cuddly_such_swallow_hush() }}
</DropdownMenuItem>
<!-- <DropdownMenuItem @click="editCaption">
<Captions />
<span>Add caption</span>
</DropdownMenuItem>
<DropdownMenuSeparator /> -->
<DropdownMenuItem @click="_delete">
<Delete />
{{ m.tense_quick_cod_favor() }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template>
<script lang="ts" setup>
import { type Emoji, RolePermission } from "@versia/client/types";
import { Delete, MoreHorizontal, TextCursorInput } from "lucide-vue-next";
import { toast } from "vue-sonner";
import { confirmModalService } from "~/components/modals/composable";
import { Button } from "~/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import * as m from "~/paraglide/messages.js";
const { emoji } = defineProps<{
emoji: Emoji;
}>();
const permissions = usePermissions();
const canEdit =
(!emoji.global &&
permissions.value.includes(RolePermission.ManageOwnEmojis)) ||
permissions.value.includes(RolePermission.ManageEmojis);
const editName = async () => {
if (!identity.value) {
return;
}
const result = await confirmModalService.confirm({
title: m.slimy_awful_florian_sail(),
defaultValue: emoji.shortcode,
confirmText: m.teary_antsy_panda_aid(),
inputType: "text",
});
if (result.confirmed) {
const id = toast.loading(m.teary_tame_gull_bless());
try {
const { data } = await client.value.updateEmoji(emoji.id, {
shortcode: result.value,
});
toast.dismiss(id);
toast.success(m.gaudy_lime_bison_adore());
identity.value.emojis = identity.value.emojis.map((e) =>
e.id === emoji.id ? data : e,
);
} catch {
toast.dismiss(id);
}
}
};
const _delete = async () => {
if (!identity.value) {
return;
}
const { confirmed } = await confirmModalService.confirm({
title: m.tense_quick_cod_favor(),
message: m.honest_factual_carp_aspire(),
confirmText: m.tense_quick_cod_favor(),
});
if (confirmed) {
const id = toast.loading(m.weary_away_liger_zip());
try {
await client.value.deleteEmoji(emoji.id);
toast.dismiss(id);
toast.success(m.crisp_whole_canary_tear());
identity.value.emojis = identity.value.emojis.filter(
(e) => e.id !== emoji.id,
);
} catch {
toast.dismiss(id);
}
}
};
</script>

View file

@ -0,0 +1,51 @@
<template>
<div v-if="emojis.length > 0" class="grow">
<Table :emojis="emojis" :can-upload="canUpload" />
</div>
</template>
<script lang="ts" setup>
import { type Emoji, RolePermission } from "@versia/client/types";
import * as m from "~/paraglide/messages.js";
import Table from "./table.vue";
const permissions = usePermissions();
const canUpload = computed(
() =>
permissions.value.includes(RolePermission.ManageOwnEmojis) ||
permissions.value.includes(RolePermission.ManageEmojis),
);
const emojis = computed(
() =>
identity.value?.emojis?.filter((emoji) =>
emoji.shortcode.toLowerCase().includes(search.value.toLowerCase()),
) ?? [],
);
const search = ref("");
/**
* Sort emojis by category
*/
const categories = computed(() => {
const categories = new Map<string, Emoji[]>();
for (const emoji of emojis.value) {
if (!emoji.category) {
if (!categories.has(m.lucky_ago_rat_pinch())) {
categories.set(m.lucky_ago_rat_pinch(), []);
}
categories.get(m.lucky_ago_rat_pinch())?.push(emoji);
continue;
}
if (!categories.has(emoji.category)) {
categories.set(emoji.category, []);
}
categories.get(emoji.category)?.push(emoji);
}
return categories;
});
</script>

View file

@ -0,0 +1,347 @@
<script setup lang="tsx">
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type {
ColumnDef,
ColumnFiltersState,
ExpandedState,
SortingState,
Updater,
VisibilityState,
} from "@tanstack/vue-table";
import {
FlexRender,
getCoreRowModel,
getExpandedRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useVueTable,
} from "@tanstack/vue-table";
import type { Emoji } from "@versia/client/types";
import {
ArrowUpDown,
ChevronDown,
Ellipsis,
Globe,
Home,
Plus,
} from "lucide-vue-next";
import { ref } from "vue";
import BatchDropdown from "./batch-dropdown.vue";
import Dropdown from "./dropdown.vue";
import Uploader from "./uploader.vue";
// No destructuring props to avoid reactivity issues
const props = defineProps<{
emojis: Emoji[];
canUpload: boolean;
}>();
const emojisRef = computed(() => props.emojis);
const valueUpdater = <T extends Updater<any>>(updaterOrValue: T, ref: Ref) => {
ref.value =
typeof updaterOrValue === "function"
? updaterOrValue(ref.value)
: updaterOrValue;
};
const columns: ColumnDef<Emoji>[] = [
{
id: "select",
header: ({ table }) => (
<Checkbox
modelValue={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onUpdate:modelValue={(value) =>
table.toggleAllPageRowsSelected(!!value)
}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
modelValue={row.getIsSelected()}
onUpdate:modelValue={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "url",
header: "Image",
cell: ({ row }) => (
<img
src={row.getValue("url")}
alt={`:${row.getValue("shortcode")}:`}
title={row.getValue("shortcode")}
class="h-[1lh] align-middle inline not-prose hover:scale-110 transition-transform duration-75 ease-in-out"
/>
),
},
{
accessorKey: "shortcode",
header: ({ column }) => {
return (
<Button
variant="link"
class="!p-0 !h-auto"
// @ts-expect-error types don't include onClick
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Shortcode
<ArrowUpDown class="ml-2 size-4" />
</Button>
);
},
cell: ({ row }) => (
<div class="font-mono">{row.getValue("shortcode")}</div>
),
},
{
accessorKey: "category",
header: ({ column }) => {
return (
<Button
variant="link"
class="!p-0 !h-auto"
// @ts-expect-error types don't include onClick
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Category
<ArrowUpDown class="ml-2 size-4" />
</Button>
);
},
cell: ({ row }) => (
<div class="font-mono">
{row.getValue("category") ?? "Uncategorized"}
</div>
),
},
{
accessorKey: "global",
header: ({ column }) => {
return (
<Button
variant="link"
class="!p-0 !h-auto"
// @ts-expect-error types don't include onClick
onClick={() => {
const filter = column.getFilterValue();
if (filter === undefined) {
column.setFilterValue(false);
} else if (filter === false) {
column.setFilterValue(true);
} else {
column.setFilterValue(undefined);
}
}}
>
Uploader
{column.getFilterValue() === undefined ? (
<Ellipsis class="ml-2 size-4" />
) : column.getFilterValue() ? (
<Globe class="ml-2 size-4" />
) : (
<Home class="ml-2 size-4" />
)}
</Button>
);
},
cell: ({ row }) => (
<div class="font-mono">
{row.getValue("global") ? "Admin" : "You"}
</div>
),
},
{
id: "actions",
enableHiding: false,
header: ({ table }) => {
const selected = table
.getFilteredSelectedRowModel()
.rows.map((r) => r.original);
return (
<div class="relative">
<BatchDropdown emojis={selected}>
<Button
variant="ghost"
size="icon"
// @ts-expect-error types don't include title
title="Open menu"
disabled={selected.length === 0}
>
<Ellipsis class="size-4" />
</Button>
</BatchDropdown>
</div>
);
},
cell: ({ row }) => {
const emoji = row.original;
return (
<div class="relative">
<Dropdown emoji={emoji} />
</div>
);
},
},
];
const sorting = ref<SortingState>([
{
id: "shortcode",
desc: false,
},
]);
const columnFilters = ref<ColumnFiltersState>([]);
const columnVisibility = ref<VisibilityState>({});
const rowSelection = ref({});
const expanded = ref<ExpandedState>({});
const table = useVueTable({
data: emojisRef,
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getExpandedRowModel: getExpandedRowModel(),
onSortingChange: (updaterOrValue) => valueUpdater(updaterOrValue, sorting),
onColumnFiltersChange: (updaterOrValue) =>
valueUpdater(updaterOrValue, columnFilters),
onColumnVisibilityChange: (updaterOrValue) =>
valueUpdater(updaterOrValue, columnVisibility),
onRowSelectionChange: (updaterOrValue) =>
valueUpdater(updaterOrValue, rowSelection),
onExpandedChange: (updaterOrValue) =>
valueUpdater(updaterOrValue, expanded),
state: {
get sorting() {
return sorting.value;
},
get columnFilters() {
return columnFilters.value;
},
get columnVisibility() {
return columnVisibility.value;
},
get rowSelection() {
return rowSelection.value;
},
get expanded() {
return expanded.value;
},
},
});
</script>
<template>
<div class="w-full">
<div class="flex gap-2 items-center py-4">
<Input class="max-w-52 mr-auto" placeholder="Filter emojis..."
:model-value="(table.getColumn('shortcode')?.getFilterValue() as string)"
@update:model-value="table.getColumn('shortcode')?.setFilterValue($event)" />
<Uploader v-if="props.canUpload">
<Button variant="outline" size="icon" title="Upload emoji">
<Plus class="size-4" />
</Button>
</Uploader>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="outline">
Columns
<ChevronDown class="ml-2 size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuCheckboxItem
v-for="column in table.getAllColumns().filter((column) => column.getCanHide())" :key="column.id"
class="capitalize" :model-value="column.getIsVisible()" @update:model-value="(value) => {
column.toggleVisibility(!!value)
}">
{{ column.id }}
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div class="rounded-md border">
<Table>
<TableHeader>
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
<TableHead v-for="header in headerGroup.headers" :key="header.id" class="">
<FlexRender v-if="!header.isPlaceholder" :render="header.column.columnDef.header"
:props="header.getContext()" />
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<template v-if="table.getRowModel().rows?.length">
<template v-for="row in table.getRowModel().rows" :key="row.id">
<TableRow :data-state="row.getIsSelected() && 'selected'">
<TableCell v-for="cell in row.getVisibleCells()" :key="cell.id">
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
</TableCell>
</TableRow>
<TableRow v-if="row.getIsExpanded()">
<TableCell :colspan="row.getAllCells().length">
{{ JSON.stringify(row.original) }}
</TableCell>
</TableRow>
</template>
</template>
<TableRow v-else>
<TableCell :colspan="columns.length" class="h-24 text-center">
No results.
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<div class="flex items-center justify-end space-x-2 py-4">
<div class="flex-1 text-sm text-muted-foreground">
{{ table.getFilteredSelectedRowModel().rows.length }} of
{{ table.getFilteredRowModel().rows.length }} row(s) selected.
</div>
<div class="space-x-2">
<Button variant="outline" size="sm" :disabled="!table.getCanPreviousPage()"
@click="table.previousPage()">
Previous
</Button>
<Button variant="outline" size="sm" :disabled="!table.getCanNextPage()" @click="table.nextPage()">
Next
</Button>
</div>
</div>
</div>
</template>

View file

@ -0,0 +1,303 @@
<template>
<Dialog v-model:open="open">
<DialogTrigger>
<slot />
</DialogTrigger>
<DialogContent>
<DialogTitle>
{{ m.whole_icy_puffin_smile() }}
</DialogTitle>
<DialogDescription class="sr-only">
{{ m.frail_great_marten_pet() }}
</DialogDescription>
<form class="p-4 grid gap-6" @submit="submit">
<div
v-if="values.image"
class="flex items-center justify-around *:size-20 *:p-2 *:rounded *:border *:shadow"
>
<div class="bg-background">
<img
class="h-full object-cover"
:src="createObjectURL(values.image as File)"
:alt="values.alt"
/>
</div>
<div class="bg-zinc-700">
<img
class="h-full object-cover"
:src="createObjectURL(values.image as File)"
:alt="values.alt"
/>
</div>
<div class="bg-zinc-400">
<img
class="h-full object-cover"
:src="createObjectURL(values.image as File)"
:alt="values.alt"
/>
</div>
<div class="bg-foreground">
<img
class="h-full object-cover"
:src="createObjectURL(values.image as File)"
:alt="values.alt"
/>
</div>
</div>
<FormField v-slot="{ handleChange, handleBlur }" name="image">
<FormItem>
<FormLabel>
{{ m.active_direct_bear_compose() }}
</FormLabel>
<FormControl>
<Input
type="file"
accept="image/*"
@change="(e: any) => {
handleChange(e);
if (!values.shortcode) {
setFieldValue('shortcode', e.target.files[0].name.replace(/\.[^/.]+$/, ''));
}
}"
@blur="handleBlur"
:disabled="isSubmitting"
/>
</FormControl>
<FormDescription>
{{ m.lime_late_millipede_urge() }}
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="shortcode">
<FormItem>
<FormLabel>
{{ m.happy_mild_fox_gleam() }}
</FormLabel>
<FormControl>
<Input
v-bind="componentField"
:disabled="isSubmitting"
/>
</FormControl>
<FormDescription>
{{ m.glad_day_kestrel_amaze() }}
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="category">
<FormItem>
<FormLabel>
{{ m.short_cute_jackdaw_comfort() }}
</FormLabel>
<FormControl>
<Input
v-bind="componentField"
:disabled="isSubmitting"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="alt">
<FormItem>
<FormLabel>
{{ m.watery_left_shrimp_bless() }}
</FormLabel>
<FormControl>
<Textarea
rows="2"
v-bind="componentField"
:disabled="isSubmitting"
/>
</FormControl>
<FormDescription>
{{ m.weird_fun_jurgen_arise() }}
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField
v-slot="{ componentField, value, handleChange }"
v-if="hasEmojiAdmin"
name="global"
:as="Card"
>
<FormItem
class="grid grid-cols-[1fr_auto] items-center gap-2"
>
<CardHeader class="space-y-0.5 p-0">
<FormLabel :as="CardTitle">
{{ m.pink_sharp_carp_work() }}
</FormLabel>
<CardDescription>
{{ m.dark_pretty_hyena_link() }}
</CardDescription>
</CardHeader>
<FormControl>
<Switch
:checked="value"
@update:checked="handleChange"
v-bind="componentField"
:disabled="isSubmitting"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<DialogFooter>
<DialogClose :as-child="true">
<Button variant="outline" :disabled="isSubmitting">
{{ m.soft_bold_ant_attend() }}
</Button>
</DialogClose>
<Button
type="submit"
variant="default"
:disabled="isSubmitting"
>
{{ m.flat_safe_haddock_gaze() }}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>
<script lang="ts" setup>
import { toTypedSchema } from "@vee-validate/zod";
import { RolePermission } from "@versia/client/types";
import { useForm } from "vee-validate";
import { toast } from "vue-sonner";
import { z } from "zod";
import { Button } from "~/components/ui/button";
import {
Card,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "~/components/ui/form";
import { Input } from "~/components/ui/input";
import { Switch } from "~/components/ui/switch";
import { Textarea } from "~/components/ui/textarea";
import * as m from "~/paraglide/messages.js";
const open = ref(false);
const permissions = usePermissions();
const hasEmojiAdmin = permissions.value.includes(RolePermission.ManageEmojis);
const createObjectURL = URL.createObjectURL;
const formSchema = toTypedSchema(
z.object({
image: z
.instanceof(File, {
message: m.sound_topical_gopher_offer(),
})
.refine(
(v) =>
v.size <=
(identity.value?.instance.configuration.emojis
.emoji_size_limit ?? Number.POSITIVE_INFINITY),
m.orange_weird_parakeet_hug({
count:
identity.value?.instance.configuration.emojis
.emoji_size_limit ?? Number.POSITIVE_INFINITY,
}),
),
shortcode: z
.string()
.min(1)
.max(
identity.value?.instance.configuration.emojis
.max_emoji_shortcode_characters ?? Number.POSITIVE_INFINITY,
m.solid_inclusive_owl_hug({
count:
identity.value?.instance.configuration.emojis
.max_emoji_shortcode_characters ??
Number.POSITIVE_INFINITY,
}),
)
.regex(emojiValidator),
global: z.boolean().default(false),
category: z
.string()
.max(
64,
m.home_cool_orangutan_hug({
count: 64,
}),
)
.optional(),
alt: z
.string()
.max(
identity.value?.instance.configuration.emojis
.max_emoji_description_characters ??
Number.POSITIVE_INFINITY,
m.key_ago_hound_emerge({
count:
identity.value?.instance.configuration.emojis
.max_emoji_description_characters ??
Number.POSITIVE_INFINITY,
}),
)
.optional(),
}),
);
const { isSubmitting, handleSubmit, values, setFieldValue } = useForm({
validationSchema: formSchema,
});
const submit = handleSubmit(async (values) => {
if (!identity.value) {
return;
}
const id = toast.loading(m.factual_gray_mouse_believe());
try {
const { data } = await client.value.uploadEmoji(
values.shortcode,
values.image,
{
alt: values.alt,
category: values.category,
global: values.global,
},
);
toast.dismiss(id);
toast.success(m.cool_trite_gull_quiz());
identity.value.emojis = [...identity.value.emojis, data];
open.value = false;
} catch {
toast.dismiss(id);
}
});
</script>

View file

@ -0,0 +1,7 @@
<template>
<Dialog />
</template>
<script lang="ts" setup>
import Dialog from "./dialog.vue";
</script>

View file

@ -0,0 +1,15 @@
<template>
<section class="gap-4 flex flex-col">
<h2 class="text-xl font-bold tracking-tight">
{{ title }}
</h2>
<slot />
</section>
</template>
<script lang="ts" setup>
const { title } = defineProps<{
title: string;
}>();
</script>

View file

@ -0,0 +1,150 @@
import * as m from "~/paraglide/messages.js";
import {
BooleanPreference,
CodePreference,
MultiSelectPreference,
NumberPreference,
SelectPreference,
UrlPreference,
} from "./types";
export const preferences = {
render_mfm: new BooleanPreference({
name: m.quaint_clear_boar_attend(),
description: m.aloof_helpful_larva_spur(),
defaultValue: true,
category: "Behaviour/Notes",
}),
default_visibility: new SelectPreference<
"public" | "unlisted" | "private" | "direct"
>({
name: m.loud_tense_kitten_exhale(),
description: m.vivid_last_crocodile_offer(),
defaultValue: "public",
options: {
public: m.lost_trick_dog_grace(),
unlisted: m.funny_slow_jannes_walk(),
private: m.grassy_empty_raven_startle(),
direct: m.pretty_bold_baboon_wave(),
},
category: "Behaviour/Posting",
}),
language: new SelectPreference<"en" | "fr">({
name: m.pretty_born_jackal_dial(),
description: m.tired_happy_lobster_pet(),
defaultValue: "en",
options: {
en: m.keen_aware_goldfish_thrive(
{},
{
locale: "en",
},
),
fr: m.vivid_mellow_sawfish_approve(
{},
{
locale: "fr",
},
),
},
category: "Behaviour/Globals",
}),
border_radius: new NumberPreference({
name: "Border radius",
description:
"Global border radius that all elements inheritt from (rem units).",
defaultValue: 0.625,
step: 0.025,
min: 0,
max: 2,
category: "Appearance/Globals",
}),
custom_css: new CodePreference({
name: m.smart_awake_dachshund_view(),
description: m.loved_topical_rat_coax(),
defaultValue: "",
language: "css",
category: "Appearance/Globals",
}),
color_theme: new SelectPreference<"dark" | "light" | "system">({
name: m.hour_elegant_mink_grip(),
defaultValue: "system",
options: {
dark: m.wise_neat_ox_buzz(),
light: m.each_strong_snail_aid(),
system: m.helpful_raw_seal_nurture(),
},
category: "Appearance/Globals",
}),
custom_emojis: new BooleanPreference({
name: m.loud_raw_sheep_imagine(),
description: m.inclusive_pink_tuna_enjoy(),
defaultValue: true,
category: "Behaviour/Notes",
}),
show_content_warning: new BooleanPreference({
name: m.fair_swift_elephant_hunt(),
description: m.gray_minor_bee_endure(),
defaultValue: true,
category: "Behaviour/Notes",
}),
popup_avatar_hover: new BooleanPreference({
name: m.north_nimble_turkey_transform(),
description: m.bold_moving_fly_savor(),
defaultValue: false,
category: "Behaviour/Timelines",
}),
infinite_scroll: new BooleanPreference({
name: m.sleek_this_earthworm_hug(),
description: m.plane_dark_salmon_pout(),
defaultValue: true,
category: "Behaviour/Timelines",
}),
confirm_actions: new MultiSelectPreference<
"delete" | "follow" | "like" | "reblog"
>({
name: "Confirm actions",
description: "Confirm actions before performing them.",
defaultValue: ["delete"],
options: {
delete: m.trite_salty_eel_race(),
follow: m.jolly_empty_bullock_mend(),
like: m.patchy_basic_alligator_inspire(),
reblog: m.honest_great_rooster_taste(),
},
category: "Behaviour/Notes",
}),
ctrl_enter_send: new BooleanPreference({
name: m.equal_blue_zebra_launch(),
description: m.heavy_pink_meerkat_affirm(),
defaultValue: true,
category: "Behaviour/Posting",
}),
emoji_theme: new SelectPreference<
"native" | "twemoji" | "noto" | "fluent" | "fluent-flat"
>({
name: m.weak_bad_martin_glow(),
description: m.warm_round_dove_skip(),
defaultValue: "native",
options: {
native: m.slimy_sound_termite_hug(),
twemoji: m.new_brave_maggot_relish(),
noto: m.shy_clear_spider_cook(),
fluent: m.many_tasty_midge_zoom(),
"fluent-flat": m.less_early_lionfish_honor(),
},
category: "Appearance/Globals",
}),
background_url: new UrlPreference({
name: m.stock_large_marten_comfort(),
description: m.mean_weird_donkey_stab(),
defaultValue: "",
category: "Appearance/Globals",
}),
display_notifications_sidebar: new BooleanPreference({
name: m.tired_jumpy_rook_slurp(),
description: m.wide_new_robin_empower(),
defaultValue: true,
category: "Appearance/Globals",
}),
} as const;

View file

@ -0,0 +1,38 @@
<template>
<Card class="grid gap-3 text-sm max-w-sm p-4">
<dl class="grid gap-3">
<div v-for="[key, value] of data" :key="key" class="flex flex-row items-baseline justify-between gap-4 truncate">
<dt class="text-muted-foreground">
{{ key }}
</dt>
<dd class="font-mono" v-if="typeof value === 'string'">{{ value }}</dd>
<dd class="font-mono" v-else>
<component :is="value" />
</dd>
</div>
</dl>
</Card>
</template>
<script lang="tsx" setup>
import type { VNode } from "vue";
import pkg from "~/package.json";
import { Card } from "../ui/card";
const data: [string, string | VNode][] = [
["Version", pkg.version],
["Licence", pkg.license],
["Author", pkg.author.name],
[
"Repository",
// biome-ignore lint/correctness/useJsxKeyInIterable: <explanation>
<a
href={pkg.repository.url.replace("git+", "")}
target="_blank"
rel="noopener noreferrer"
>
{pkg.repository.url.replace("git+", "").replace("https://", "")}
</a>,
],
];
</script>

View file

@ -0,0 +1,71 @@
export interface PreferenceOptions<ValueType> {
name: string;
description?: string;
category?: string;
defaultValue: ValueType;
}
export abstract class Preference<ValueType> {
public abstract options: PreferenceOptions<ValueType>;
}
export class TextPreference extends Preference<string> {
constructor(public options: PreferenceOptions<string>) {
super();
}
}
export class NumberPreference extends Preference<number> {
constructor(
public options: PreferenceOptions<number> & {
integer?: boolean;
step?: number;
min?: number;
max?: number;
},
) {
super();
}
}
export class BooleanPreference extends Preference<boolean> {
constructor(public options: PreferenceOptions<boolean>) {
super();
}
}
export class SelectPreference<T extends string> extends Preference<T> {
constructor(
public options: PreferenceOptions<T> & {
options: Record<T, string>;
},
) {
super();
}
}
export class CodePreference extends Preference<string> {
constructor(
public options: PreferenceOptions<string> & {
language?: "css";
},
) {
super();
}
}
export class MultiSelectPreference<T extends string> extends Preference<T[]> {
constructor(
public options: PreferenceOptions<T[]> & {
options: Record<T, string>;
},
) {
super();
}
}
export class UrlPreference extends Preference<string> {
constructor(public options: PreferenceOptions<string>) {
super();
}
}

View file

@ -0,0 +1,37 @@
<template>
<div class="grid grid-cols-[minmax(0,1fr)_auto] gap-2 hover:bg-muted/40 duration-75 p-4">
<div class="flex flex-col gap-1">
<h3 class="text-sm font-semibold tracking-tight">{{ pref.options.name }}</h3>
<small v-if="pref.options.description" class="text-xs font-medium leading-none text-muted-foreground">{{
pref.options.description }}</small>
</div>
<div class="flex items-center justify-end">
<slot :value="value" :set-value="setValue" />
</div>
<slot name="extra" :value="value" :set-value="setValue" />
</div>
</template>
<script lang="ts" setup>
import type { Preference } from "../types";
const { pref } = defineProps<{
pref: Preference<any>;
}>();
const value = ref<any>(pref.options.defaultValue);
const setValue = (newValue: MaybeRef<any>) => {
value.value = toValue(newValue);
};
defineSlots<{
default(props: {
value: any;
setValue: (value: MaybeRef<any>) => void;
}): any;
extra(props: {
value: any;
setValue: (value: MaybeRef<any>) => void;
}): any;
}>();
</script>

View file

@ -0,0 +1,15 @@
<template>
<Base :pref="pref" v-slot="{ setValue, value }">
<Switch @update:model-value="setValue" :model-value="value" />
</Base>
</template>
<script lang="ts" setup>
import { Switch } from "~/components/ui/switch";
import type { BooleanPreference } from "../types";
import Base from "./base.vue";
const { pref } = defineProps<{
pref: BooleanPreference;
}>();
</script>

View file

@ -0,0 +1,34 @@
<template>
<Collapsible as-child>
<Base :pref="pref">
<template #default>
<CollapsibleTrigger as-child>
<Button variant="outline">
Open code
</Button>
</CollapsibleTrigger>
</template>
<template #extra="{ setValue, value }">
<CollapsibleContent class="col-span-2 mt-2">
<Textarea :rows="10" :model-value="value" @update:model-value="setValue" />
</CollapsibleContent>
</template>
</Base>
</Collapsible>
</template>
<script lang="ts" setup>
import { Button } from "~/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "~/components/ui/collapsible";
import { Textarea } from "~/components/ui/textarea";
import type { CodePreference } from "../types";
import Base from "./base.vue";
const { pref } = defineProps<{
pref: CodePreference;
}>();
</script>

View file

@ -0,0 +1,39 @@
<template>
<Base :pref="pref" v-slot="{ setValue, value }">
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="outline">
Pick
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent class="w-56">
<DropdownMenuCheckboxItem v-for="[option, title] in Object.entries(pref.options.options)" :key="option"
:model-value="value.includes(option)" @update:model-value="checked => {
if (checked) {
setValue([...value, option]);
} else {
setValue(value.filter((v: any) => v !== option));
}
}">
{{ title }}
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
</Base>
</template>
<script lang="ts" setup>
import { Button } from "~/components/ui/button";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import type { MultiSelectPreference } from "../types";
import Base from "./base.vue";
const { pref } = defineProps<{
pref: MultiSelectPreference<string>;
}>();
</script>

View file

@ -0,0 +1,27 @@
<template>
<Base :pref="pref" v-slot="{ setValue, value }">
<NumberField :model-value="value" @update:model-value="setValue" :min="pref.options.min" :max="pref.options.max" :step="pref.options.integer ? 1 : pref.options.step">
<NumberFieldContent>
<NumberFieldDecrement />
<NumberFieldInput />
<NumberFieldIncrement />
</NumberFieldContent>
</NumberField>
</Base>
</template>
<script lang="ts" setup>
import {
NumberField,
NumberFieldContent,
NumberFieldDecrement,
NumberFieldIncrement,
NumberFieldInput,
} from "~/components/ui/number-field";
import type { NumberPreference } from "../types";
import Base from "./base.vue";
const { pref } = defineProps<{
pref: NumberPreference;
}>();
</script>

View file

@ -0,0 +1,33 @@
<template>
<Base :pref="pref" v-slot="{ setValue, value }">
<Select :model-value="value" @update:model-value="setValue">
<SelectTrigger>
<SelectValue placeholder="Select an option" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem v-for="[val, title] in Object.entries(pref.options.options)" :value="val">
{{ title }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</Base>
</template>
<script lang="ts" setup>
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import type { SelectPreference } from "../types";
import Base from "./base.vue";
const { pref } = defineProps<{
pref: SelectPreference<string>;
}>();
</script>

View file

@ -0,0 +1,15 @@
<template>
<Base :pref="pref" v-slot="{ setValue, value }">
<Input placeholder="Content here..." :model-value="value" @update:model-value="setValue" />
</Base>
</template>
<script lang="ts" setup>
import { Input } from "~/components/ui/input";
import type { TextPreference } from "../types";
import Base from "./base.vue";
const { pref } = defineProps<{
pref: TextPreference;
}>();
</script>

View file

@ -0,0 +1,34 @@
<template>
<Collapsible as-child>
<Base :pref="pref">
<template #default>
<CollapsibleTrigger as-child>
<Button variant="outline">
Edit URL
</Button>
</CollapsibleTrigger>
</template>
<template #extra="{ setValue, value }">
<CollapsibleContent class="col-span-2 mt-2">
<UrlInput placeholder="Type URL or domain here..." :model-value="value" @update:model-value="setValue" />
</CollapsibleContent>
</template>
</Base>
</Collapsible>
</template>
<script lang="ts" setup>
import { Button } from "~/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "~/components/ui/collapsible";
import { Input, UrlInput } from "~/components/ui/input";
import type { TextPreference } from "../types";
import Base from "./base.vue";
const { pref } = defineProps<{
pref: TextPreference;
}>();
</script>

View file

@ -35,5 +35,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
<Check class="size-3.5" /> <Check class="size-3.5" />
</slot> </slot>
</CheckboxIndicator> </CheckboxIndicator>
<!-- Fixes an issue where empty buttons behave weirdly in tanstack table layouts -->
<Check class="size-3.5 opacity-0" />
</CheckboxRoot> </CheckboxRoot>
</template> </template>

View file

@ -37,7 +37,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
v-bind="forwarded" v-bind="forwarded"
:class=" :class="
cn( cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg', 'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] max-h-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
props.class, props.class,
)" )"
> >

View file

@ -0,0 +1,25 @@
<script setup lang="ts">
import { cn } from "@/lib/utils";
import type { NumberFieldRootEmits, NumberFieldRootProps } from "reka-ui";
import { NumberFieldRoot, useForwardPropsEmits } from "reka-ui";
import { type HTMLAttributes, computed } from "vue";
const props = defineProps<
NumberFieldRootProps & { class?: HTMLAttributes["class"] }
>();
const emits = defineEmits<NumberFieldRootEmits>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<NumberFieldRoot v-bind="forwarded" :class="cn('grid gap-1.5', props.class)">
<slot />
</NumberFieldRoot>
</template>

View file

@ -0,0 +1,14 @@
<script setup lang="ts">
import { cn } from "@/lib/utils";
import type { HTMLAttributes } from "vue";
const props = defineProps<{
class?: HTMLAttributes["class"];
}>();
</script>
<template>
<div :class="cn('relative [&>[data-slot=input]]:has-[[data-slot=increment]]:pr-5 [&>[data-slot=input]]:has-[[data-slot=decrement]]:pl-5', props.class)">
<slot />
</div>
</template>

View file

@ -0,0 +1,27 @@
<script setup lang="ts">
import { cn } from "@/lib/utils";
import { Minus } from "lucide-vue-next";
import type { NumberFieldDecrementProps } from "reka-ui";
import { NumberFieldDecrement, useForwardProps } from "reka-ui";
import { type HTMLAttributes, computed } from "vue";
const props = defineProps<
NumberFieldDecrementProps & { class?: HTMLAttributes["class"] }
>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const forwarded = useForwardProps(delegatedProps);
</script>
<template>
<NumberFieldDecrement data-slot="decrement" v-bind="forwarded" :class="cn('absolute top-1/2 -translate-y-1/2 left-0 p-3 disabled:cursor-not-allowed disabled:opacity-20', props.class)">
<slot>
<Minus class="h-4 w-4" />
</slot>
</NumberFieldDecrement>
</template>

View file

@ -0,0 +1,27 @@
<script setup lang="ts">
import { cn } from "@/lib/utils";
import { Plus } from "lucide-vue-next";
import type { NumberFieldIncrementProps } from "reka-ui";
import { NumberFieldIncrement, useForwardProps } from "reka-ui";
import { type HTMLAttributes, computed } from "vue";
const props = defineProps<
NumberFieldIncrementProps & { class?: HTMLAttributes["class"] }
>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const forwarded = useForwardProps(delegatedProps);
</script>
<template>
<NumberFieldIncrement data-slot="increment" v-bind="forwarded" :class="cn('absolute top-1/2 -translate-y-1/2 right-0 disabled:cursor-not-allowed disabled:opacity-20 p-3', props.class)">
<slot>
<Plus class="h-4 w-4" />
</slot>
</NumberFieldIncrement>
</template>

View file

@ -0,0 +1,16 @@
<script setup lang="ts">
import { cn } from "@/lib/utils";
import { NumberFieldInput } from "reka-ui";
import type { HTMLAttributes } from "vue";
const props = defineProps<{
class?: HTMLAttributes["class"];
}>();
</script>
<template>
<NumberFieldInput
data-slot="input"
:class="cn('flex h-9 w-full rounded-md border border-input bg-transparent py-1 text-sm text-center shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50', props.class)"
/>
</template>

View file

@ -0,0 +1,5 @@
export { default as NumberField } from "./NumberField.vue";
export { default as NumberFieldContent } from "./NumberFieldContent.vue";
export { default as NumberFieldDecrement } from "./NumberFieldDecrement.vue";
export { default as NumberFieldIncrement } from "./NumberFieldIncrement.vue";
export { default as NumberFieldInput } from "./NumberFieldInput.vue";

View file

@ -29,7 +29,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
data-slot="switch" data-slot="switch"
v-bind="forwarded" v-bind="forwarded"
:class="cn( :class="cn(
'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50', 'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] hover:cursor-pointer disabled:cursor-not-allowed disabled:opacity-50',
props.class, props.class,
)" )"
> >

View file

@ -0,0 +1,16 @@
<script setup lang="ts">
import { cn } from "@/lib/utils";
import type { HTMLAttributes } from "vue";
const props = defineProps<{
class?: HTMLAttributes["class"];
}>();
</script>
<template>
<div data-slot="table-container" class="relative w-full overflow-auto">
<table data-slot="table" :class="cn('w-full caption-bottom text-sm', props.class)">
<slot />
</table>
</div>
</template>

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
import { cn } from "@/lib/utils";
import type { HTMLAttributes } from "vue";
const props = defineProps<{
class?: HTMLAttributes["class"];
}>();
</script>
<template>
<tbody
data-slot="table-body"
:class="cn('[&_tr:last-child]:border-0', props.class)"
>
<slot />
</tbody>
</template>

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
import { cn } from "@/lib/utils";
import type { HTMLAttributes } from "vue";
const props = defineProps<{
class?: HTMLAttributes["class"];
}>();
</script>
<template>
<caption
data-slot="table-caption"
:class="cn('text-muted-foreground mt-4 text-sm', props.class)"
>
<slot />
</caption>
</template>

View file

@ -0,0 +1,22 @@
<script setup lang="ts">
import { cn } from "@/lib/utils";
import type { HTMLAttributes } from "vue";
const props = defineProps<{
class?: HTMLAttributes["class"];
}>();
</script>
<template>
<td
data-slot="table-cell"
:class="
cn(
'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
props.class,
)
"
>
<slot />
</td>
</template>

View file

@ -0,0 +1,40 @@
<script setup lang="ts">
import { cn } from "@/lib/utils";
import { type HTMLAttributes, computed } from "vue";
import TableCell from "./TableCell.vue";
import TableRow from "./TableRow.vue";
const props = withDefaults(
defineProps<{
class?: HTMLAttributes["class"];
colspan?: number;
}>(),
{
colspan: 1,
},
);
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
</script>
<template>
<TableRow>
<TableCell
:class="
cn(
'p-4 whitespace-nowrap align-middle text-sm text-foreground',
props.class,
)
"
v-bind="delegatedProps"
>
<div class="flex items-center justify-center py-10">
<slot />
</div>
</TableCell>
</TableRow>
</template>

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
import { cn } from "@/lib/utils";
import type { HTMLAttributes } from "vue";
const props = defineProps<{
class?: HTMLAttributes["class"];
}>();
</script>
<template>
<tfoot
data-slot="table-footer"
:class="cn('bg-muted/50 border-t font-medium [&>tr]:last:border-b-0', props.class)"
>
<slot />
</tfoot>
</template>

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
import { cn } from "@/lib/utils";
import type { HTMLAttributes } from "vue";
const props = defineProps<{
class?: HTMLAttributes["class"];
}>();
</script>
<template>
<th
data-slot="table-head"
:class="cn('text-muted-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]', props.class)"
>
<slot />
</th>
</template>

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
import { cn } from "@/lib/utils";
import type { HTMLAttributes } from "vue";
const props = defineProps<{
class?: HTMLAttributes["class"];
}>();
</script>
<template>
<thead
data-slot="table-header"
:class="cn('[&_tr]:border-b', props.class)"
>
<slot />
</thead>
</template>

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
import { cn } from "@/lib/utils";
import type { HTMLAttributes } from "vue";
const props = defineProps<{
class?: HTMLAttributes["class"];
}>();
</script>
<template>
<tr
data-slot="table-row"
:class="cn('hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors', props.class)"
>
<slot />
</tr>
</template>

View file

@ -0,0 +1,9 @@
export { default as Table } from "./Table.vue";
export { default as TableBody } from "./TableBody.vue";
export { default as TableCaption } from "./TableCaption.vue";
export { default as TableCell } from "./TableCell.vue";
export { default as TableEmpty } from "./TableEmpty.vue";
export { default as TableFooter } from "./TableFooter.vue";
export { default as TableHead } from "./TableHead.vue";
export { default as TableHeader } from "./TableHeader.vue";
export { default as TableRow } from "./TableRow.vue";

View file

@ -0,0 +1,12 @@
import type { Updater } from "@tanstack/vue-table";
import type { Ref } from "vue";
export function valueUpdater<T extends Updater<any>>(
updaterOrValue: T,
ref: Ref,
) {
ref.value =
typeof updaterOrValue === "function"
? updaterOrValue(ref.value)
: updaterOrValue;
}

View file

@ -19,7 +19,7 @@ const delegatedProps = computed(() => {
data-slot="tabs-list" data-slot="tabs-list"
v-bind="delegatedProps" v-bind="delegatedProps"
:class="cn( :class="cn(
'bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]', 'bg-muted text-muted-foreground inline-flex w-fit items-center justify-center rounded-lg p-1 overflow-x-auto',
props.class, props.class,
)" )"
> >

View file

@ -21,7 +21,7 @@ const forwardedProps = useForwardProps(delegatedProps);
data-slot="tabs-trigger" data-slot="tabs-trigger"
v-bind="forwardedProps" v-bind="forwardedProps"
:class="cn( :class="cn(
`data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4`, `data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4`,
props.class, props.class,
)" )"
> >

View file

@ -8,6 +8,7 @@
</AppSidebar> </AppSidebar>
</SidebarProvider> </SidebarProvider>
<MobileNavbar v-if="identity" /> <MobileNavbar v-if="identity" />
<Preferences />
<ComposerDialog /> <ComposerDialog />
</template> </template>
@ -15,6 +16,7 @@
import ComposerDialog from "~/components/composer/dialog.vue"; import ComposerDialog from "~/components/composer/dialog.vue";
import AuthRequired from "~/components/errors/AuthRequired.vue"; import AuthRequired from "~/components/errors/AuthRequired.vue";
import MobileNavbar from "~/components/navigation/mobile-navbar.vue"; import MobileNavbar from "~/components/navigation/mobile-navbar.vue";
import Preferences from "~/components/preferences2/index.vue";
import AppSidebar from "~/components/sidebars/sidebar.vue"; import AppSidebar from "~/components/sidebars/sidebar.vue";
import { SidebarProvider } from "~/components/ui/sidebar"; import { SidebarProvider } from "~/components/ui/sidebar";
import { SettingIds } from "~/settings"; import { SettingIds } from "~/settings";

View file

@ -275,7 +275,6 @@
"wise_neat_ox_buzz": "Dark as the depths", "wise_neat_ox_buzz": "Dark as the depths",
"each_strong_snail_aid": "Bright as sail canvas", "each_strong_snail_aid": "Bright as sail canvas",
"helpful_raw_seal_nurture": "As the sea decides", "helpful_raw_seal_nurture": "As the sea decides",
"male_stout_florian_feast": "Ship's appearance.",
"hour_elegant_mink_grip": "Ship's look", "hour_elegant_mink_grip": "Ship's look",
"loud_raw_sheep_imagine": "Render deck decorations", "loud_raw_sheep_imagine": "Render deck decorations",
"inclusive_pink_tuna_enjoy": "Render deck decorations. Requires resetting yer sails to apply.", "inclusive_pink_tuna_enjoy": "Render deck decorations. Requires resetting yer sails to apply.",

View file

@ -275,8 +275,7 @@
"wise_neat_ox_buzz": "Dark", "wise_neat_ox_buzz": "Dark",
"each_strong_snail_aid": "Light", "each_strong_snail_aid": "Light",
"helpful_raw_seal_nurture": "System", "helpful_raw_seal_nurture": "System",
"male_stout_florian_feast": "UI theme.", "hour_elegant_mink_grip": "Color theme",
"hour_elegant_mink_grip": "Theme",
"loud_raw_sheep_imagine": "Render custom emojis", "loud_raw_sheep_imagine": "Render custom emojis",
"inclusive_pink_tuna_enjoy": "Render custom emojis. Requires a page reload to apply.", "inclusive_pink_tuna_enjoy": "Render custom emojis. Requires a page reload to apply.",
"fair_swift_elephant_hunt": "Blur sensitive content", "fair_swift_elephant_hunt": "Blur sensitive content",
@ -352,5 +351,7 @@
"sunny_small_warbler_express": "URL is valid", "sunny_small_warbler_express": "URL is valid",
"teal_late_grebe_blend": "URL is invalid", "teal_late_grebe_blend": "URL is invalid",
"sharp_alive_anteater_fade": "Which instance?", "sharp_alive_anteater_fade": "Which instance?",
"noble_misty_rook_slide": "Put your instance's domain name here." "noble_misty_rook_slide": "Put your instance's domain name here.",
"next_hour_jurgen_sprout": "Are you sure you want to delete {amount} emojis?",
"equal_only_crow_file": "Deleting {amount} emojis..."
} }

View file

@ -257,8 +257,7 @@
"wise_neat_ox_buzz": "Sombre", "wise_neat_ox_buzz": "Sombre",
"each_strong_snail_aid": "Clair", "each_strong_snail_aid": "Clair",
"helpful_raw_seal_nurture": "Système", "helpful_raw_seal_nurture": "Système",
"male_stout_florian_feast": "Thème de l'interface.", "hour_elegant_mink_grip": "Thème de couleurs",
"hour_elegant_mink_grip": "Thème",
"loud_raw_sheep_imagine": "Afficher les émojis personnalisés", "loud_raw_sheep_imagine": "Afficher les émojis personnalisés",
"inclusive_pink_tuna_enjoy": "Afficher les émojis personnalisés. Nécessite un rechargement de la page.", "inclusive_pink_tuna_enjoy": "Afficher les émojis personnalisés. Nécessite un rechargement de la page.",
"fair_swift_elephant_hunt": "Flouter les contenus sensibles", "fair_swift_elephant_hunt": "Flouter les contenus sensibles",
@ -333,5 +332,6 @@
"sunny_small_warbler_express": "L'URL est valide", "sunny_small_warbler_express": "L'URL est valide",
"teal_late_grebe_blend": "L'URL n'est pas valide", "teal_late_grebe_blend": "L'URL n'est pas valide",
"sharp_alive_anteater_fade": "Quelle instance ?", "sharp_alive_anteater_fade": "Quelle instance ?",
"noble_misty_rook_slide": "Mettez le nom de domaine de votre instance ici." "noble_misty_rook_slide": "Mettez le nom de domaine de votre instance ici.",
"next_hour_jurgen_sprout": ""
} }

View file

@ -10,14 +10,14 @@
packageJson = builtins.fromJSON (builtins.readFile ../package.json); packageJson = builtins.fromJSON (builtins.readFile ../package.json);
in in
stdenv.mkDerivation (finalAttrs: { stdenv.mkDerivation (finalAttrs: {
pname = packageJson.name; pname = "versia-fe";
version = packageJson.version; version = packageJson.version;
src = ../.; src = ../.;
pnpmDeps = pnpm.fetchDeps { pnpmDeps = pnpm.fetchDeps {
inherit (finalAttrs) pname version src; inherit (finalAttrs) pname version src;
hash = "sha256-Z8eZiCJ3wfk/RyMnqmbk9UhJbnfYHv1k9tusNwoOgB0="; hash = "sha256-JGZTMusNZf3PQqGcAhsO2J1q6Tj55BgNcgxAUqMN6S0=";
}; };
nativeBuildInputs = [ nativeBuildInputs = [

View file

@ -1,8 +1,8 @@
{ {
"name": "versia-fe", "name": "@versia/frontend",
"version": "0.1.0", "version": "0.8.0-alpha",
"private": true, "private": true,
"description": " Versia Server frontend, designed with Nuxt.", "description": "Beautiful, powerful and responsive web client for Versia Server.",
"type": "module", "type": "module",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"author": { "author": {
@ -36,6 +36,7 @@
"@nuxtjs/color-mode": "3.5.2", "@nuxtjs/color-mode": "3.5.2",
"@tailwindcss/typography": "^0.5.16", "@tailwindcss/typography": "^0.5.16",
"@tailwindcss/vite": "^4.1.4", "@tailwindcss/vite": "^4.1.4",
"@tanstack/vue-table": "^8.21.3",
"@tiptap/extension-highlight": "^2.11.7", "@tiptap/extension-highlight": "^2.11.7",
"@tiptap/extension-image": "^2.11.7", "@tiptap/extension-image": "^2.11.7",
"@tiptap/extension-link": "^2.11.7", "@tiptap/extension-link": "^2.11.7",
@ -75,6 +76,7 @@
"tw-animate-css": "^1.2.8", "tw-animate-css": "^1.2.8",
"vaul-vue": "^0.4.1", "vaul-vue": "^0.4.1",
"vee-validate": "^4.15.0", "vee-validate": "^4.15.0",
"virtua": "^0.40.4",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-router": "^4.5.1", "vue-router": "^4.5.1",
"vue-sonner": "^1.3.2", "vue-sonner": "^1.3.2",

View file

@ -20,6 +20,9 @@ importers:
'@tailwindcss/vite': '@tailwindcss/vite':
specifier: ^4.1.4 specifier: ^4.1.4
version: 4.1.4(vite@6.2.6(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.39.0)(yaml@2.7.1)) version: 4.1.4(vite@6.2.6(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.39.0)(yaml@2.7.1))
'@tanstack/vue-table':
specifier: ^8.21.3
version: 8.21.3(vue@3.5.13(typescript@5.8.3))
'@tiptap/extension-highlight': '@tiptap/extension-highlight':
specifier: ^2.11.7 specifier: ^2.11.7
version: 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7)) version: 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))
@ -137,6 +140,9 @@ importers:
vee-validate: vee-validate:
specifier: ^4.15.0 specifier: ^4.15.0
version: 4.15.0(vue@3.5.13(typescript@5.8.3)) version: 4.15.0(vue@3.5.13(typescript@5.8.3))
virtua:
specifier: ^0.40.4
version: 0.40.4(vue@3.5.13(typescript@5.8.3))
vue: vue:
specifier: ^3.5.13 specifier: ^3.5.13
version: 3.5.13(typescript@5.8.3) version: 3.5.13(typescript@5.8.3)
@ -1627,9 +1633,19 @@ packages:
peerDependencies: peerDependencies:
vite: ^5.2.0 || ^6 vite: ^5.2.0 || ^6
'@tanstack/table-core@8.21.3':
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
engines: {node: '>=12'}
'@tanstack/virtual-core@3.13.6': '@tanstack/virtual-core@3.13.6':
resolution: {integrity: sha512-cnQUeWnhNP8tJ4WsGcYiX24Gjkc9ALstLbHcBj1t3E7EimN6n6kHH+DPV4PpDnuw00NApQp+ViojMj1GRdwYQg==} resolution: {integrity: sha512-cnQUeWnhNP8tJ4WsGcYiX24Gjkc9ALstLbHcBj1t3E7EimN6n6kHH+DPV4PpDnuw00NApQp+ViojMj1GRdwYQg==}
'@tanstack/vue-table@8.21.3':
resolution: {integrity: sha512-rusRyd77c5tDPloPskctMyPLFEQUeBzxdQ+2Eow4F7gDPlPOB1UnnhzfpdvqZ8ZyX2rRNGmqNnQWm87OI2OQPw==}
engines: {node: '>=12'}
peerDependencies:
vue: '>=3.2'
'@tanstack/vue-virtual@3.13.6': '@tanstack/vue-virtual@3.13.6':
resolution: {integrity: sha512-GYdZ3SJBQPzgxhuCE2fvpiH46qzHiVx5XzBSdtESgiqh4poj8UgckjGWYEhxaBbcVt1oLzh1m3Ql4TyH32TOzQ==} resolution: {integrity: sha512-GYdZ3SJBQPzgxhuCE2fvpiH46qzHiVx5XzBSdtESgiqh4poj8UgckjGWYEhxaBbcVt1oLzh1m3Ql4TyH32TOzQ==}
peerDependencies: peerDependencies:
@ -4843,6 +4859,26 @@ packages:
videojs-vtt.js@0.15.5: videojs-vtt.js@0.15.5:
resolution: {integrity: sha512-yZbBxvA7QMYn15Lr/ZfhhLPrNpI/RmCSCqgIff57GC2gIrV5YfyzLfLyZMj0NnZSAz8syB4N0nHXpZg9MyrMOQ==} resolution: {integrity: sha512-yZbBxvA7QMYn15Lr/ZfhhLPrNpI/RmCSCqgIff57GC2gIrV5YfyzLfLyZMj0NnZSAz8syB4N0nHXpZg9MyrMOQ==}
virtua@0.40.4:
resolution: {integrity: sha512-eV55eOm2b5Lzc9upivqIcAFPgfBrfcVrppW9T4vhTH+QAbaxfw5ypq25apkG83T5FuiFEoQYnefix1fQyx/GXQ==}
peerDependencies:
react: '>=16.14.0'
react-dom: '>=16.14.0'
solid-js: '>=1.0'
svelte: '>=5.0'
vue: '>=3.2'
peerDependenciesMeta:
react:
optional: true
react-dom:
optional: true
solid-js:
optional: true
svelte:
optional: true
vue:
optional: true
vite-dev-rpc@1.0.7: vite-dev-rpc@1.0.7:
resolution: {integrity: sha512-FxSTEofDbUi2XXujCA+hdzCDkXFG1PXktMjSk1efq9Qb5lOYaaM9zNSvKvPPF7645Bak79kSp1PTooMW2wktcA==} resolution: {integrity: sha512-FxSTEofDbUi2XXujCA+hdzCDkXFG1PXktMjSk1efq9Qb5lOYaaM9zNSvKvPPF7645Bak79kSp1PTooMW2wktcA==}
peerDependencies: peerDependencies:
@ -6847,8 +6883,15 @@ snapshots:
tailwindcss: 4.1.4 tailwindcss: 4.1.4
vite: 6.2.6(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.39.0)(yaml@2.7.1) vite: 6.2.6(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.39.0)(yaml@2.7.1)
'@tanstack/table-core@8.21.3': {}
'@tanstack/virtual-core@3.13.6': {} '@tanstack/virtual-core@3.13.6': {}
'@tanstack/vue-table@8.21.3(vue@3.5.13(typescript@5.8.3))':
dependencies:
'@tanstack/table-core': 8.21.3
vue: 3.5.13(typescript@5.8.3)
'@tanstack/vue-virtual@3.13.6(vue@3.5.13(typescript@5.8.3))': '@tanstack/vue-virtual@3.13.6(vue@3.5.13(typescript@5.8.3))':
dependencies: dependencies:
'@tanstack/virtual-core': 3.13.6 '@tanstack/virtual-core': 3.13.6
@ -10583,6 +10626,10 @@ snapshots:
dependencies: dependencies:
global: 4.4.0 global: 4.4.0
virtua@0.40.4(vue@3.5.13(typescript@5.8.3)):
optionalDependencies:
vue: 3.5.13(typescript@5.8.3)
vite-dev-rpc@1.0.7(vite@6.2.6(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.39.0)(yaml@2.7.1)): vite-dev-rpc@1.0.7(vite@6.2.6(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.39.0)(yaml@2.7.1)):
dependencies: dependencies:
birpc: 2.3.0 birpc: 2.3.0