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,73 @@
<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 CustomEmoji, RolePermission } from "@versia/client/schemas";
import { Delete } from "lucide-vue-next";
import { toast } from "vue-sonner";
import type { z } from "zod";
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: z.infer<typeof CustomEmoji>[];
}>();
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,108 @@
<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 CustomEmoji, RolePermission } from "@versia/client/schemas";
import { Delete, MoreHorizontal, TextCursorInput } from "lucide-vue-next";
import { toast } from "vue-sonner";
import type { z } from "zod";
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: z.infer<typeof CustomEmoji>;
}>();
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,19 @@
<template>
<div v-if="emojis.length > 0" class="grow">
<Table :emojis="emojis" :can-upload="canUpload" />
</div>
</template>
<script lang="ts" setup>
import { RolePermission } from "@versia/client/schemas";
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 ?? []);
</script>

View file

@ -0,0 +1,362 @@
<script setup lang="tsx">
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 { CustomEmoji } from "@versia/client/schemas";
import {
ArrowDownAZ,
ArrowUpAz,
ArrowUpDown,
ChevronDown,
Ellipsis,
Globe,
Home,
Plus,
} from "lucide-vue-next";
import { ref } from "vue";
import type { z } from "zod";
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 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: z.infer<typeof CustomEmoji>[];
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<z.infer<typeof CustomEmoji>>[] = [
{
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
{column.getIsSorted() === false ? (
<ArrowUpDown class="ml-2 size-4" />
) : column.getIsSorted() === "asc" ? (
<ArrowDownAZ class="ml-2 size-4" />
) : (
<ArrowUpAz 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
{column.getIsSorted() === false ? (
<ArrowUpDown class="ml-2 size-4" />
) : column.getIsSorted() === "asc" ? (
<ArrowDownAZ class="ml-2 size-4" />
) : (
<ArrowUpAz 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,283 @@
<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="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="{ value, handleChange }"
v-if="hasEmojiAdmin"
name="global"
as-child
>
<FormSwitch :title="m.pink_sharp_carp_work()" :description="m.dark_pretty_hyena_link()">
<Switch
:model-value="value"
@update:model-value="handleChange"
:disabled="isSubmitting"
/>
</FormSwitch>
</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/schemas";
import { useForm } from "vee-validate";
import { toast } from "vue-sonner";
import { z } from "zod";
import FormSwitch from "~/components/form/switch.vue";
import { Button } from "~/components/ui/button";
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_shortcode_characters ?? Number.POSITIVE_INFINITY,
m.solid_inclusive_owl_hug({
count:
identity.value?.instance.configuration.emojis
.max_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_description_characters ?? Number.POSITIVE_INFINITY,
m.key_ago_hound_emerge({
count:
identity.value?.instance.configuration.emojis
.max_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>