refactor: ♻️ Finish rewrite and delete old settings backend

This commit is contained in:
Jesse Wierzbinski 2025-05-01 01:45:46 +02:00
parent 3ce71dd4df
commit 34ce25cc1d
No known key found for this signature in database
50 changed files with 472 additions and 1538 deletions

View file

@ -5,9 +5,9 @@
<FormLabel class="font-semibold tracking-tight" :as="CardTitle">
{{ title }}
</FormLabel>
<CardDescription class="text-xs leading-none" v-if="description">
<FormDescription class="text-xs leading-none" v-if="description">
{{ description }}
</CardDescription>
</FormDescription>
</CardHeader>
<FormControl>
<slot />
@ -19,7 +19,13 @@
<script lang="ts" setup>
import { Card, CardDescription, CardHeader, CardTitle } from "../ui/card";
import { FormControl, FormItem, FormLabel, FormMessage } from "../ui/form";
import {
FormControl,
FormDescription,
FormItem,
FormLabel,
FormMessage,
} from "../ui/form";
const { title, description } = defineProps<{
title: string;

29
components/form/text.vue Normal file
View file

@ -0,0 +1,29 @@
<template>
<FormItem>
<FormLabel>
{{ title }}
</FormLabel>
<FormControl>
<slot />
</FormControl>
<FormDescription v-if="description">
{{ description }}
</FormDescription>
<FormMessage />
</FormItem>
</template>
<script lang="ts" setup>
import {
FormControl,
FormDescription,
FormItem,
FormLabel,
FormMessage,
} from "../ui/form";
const { title, description } = defineProps<{
title: string;
description?: string;
}>();
</script>

View file

@ -18,7 +18,7 @@
</template>
<script lang="ts" setup>
import { Card, CardTitle } from "../ui/card";
import { Card, CardTitle } from "../ui/card/index.ts";
// biome-ignore lint/style/useImportType: <explanation>
import { preferences as prefs } from "./preferences.ts";
import {

View file

@ -1,64 +0,0 @@
<template>
<Collapsible
:as="Card"
class="grid justify-normal items-center px-6 py-4 gap-4"
v-slot="{ open }"
>
<div class="grid grid-cols-[1fr_auto] items-center gap-4">
<CardHeader class="space-y-0.5 p-0">
<CardTitle class="text-base">
{{ setting.title() }}
</CardTitle>
<CardDescription>
{{ setting.description() }}
</CardDescription>
</CardHeader>
<CollapsibleTrigger :as-child="true">
<Button
variant="outline"
size="icon"
class="ml-auto [&_svg]:data-[state=open]:-rotate-180"
:title="open ? 'Collapse' : 'Expand'"
>
<ChevronDown class="duration-200" />
</Button>
</CollapsibleTrigger>
</div>
<CollapsibleContent :as-child="true">
<CardFooter class="p-1">
<Textarea
:rows="10"
:model-value="setting.value"
@update:model-value="
(v) => {
setting.value = String(v);
}
"
/>
</CardFooter>
</CollapsibleContent>
</Collapsible>
</template>
<script lang="ts" setup>
import { ChevronDown } from "lucide-vue-next";
import { Button } from "~/components/ui/button";
import {
Card,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "~/components/ui/collapsible";
import { Textarea } from "~/components/ui/textarea";
import type { CodeSetting } from "~/settings.ts";
defineModel<CodeSetting>("setting", {
required: true,
});
</script>

View file

@ -25,16 +25,17 @@ import Developer from "./developer.vue";
import Emojis from "./emojis/index.vue";
import Page from "./page.vue";
import { preferences } from "./preferences";
import Profile from "./profile.vue";
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"])
.concat(["Account", "Emojis", "Developer", "About"])
// Remove duplicates
.filter((c, i, a) => a.indexOf(c) === i);
const extraPages = ["Account", "Emojis", "Roles", "Developer", "About"];
const extraPages = ["Account", "Emojis", "Developer", "About"];
const icons: Record<string, Component> = {
Account: UserIcon,
@ -75,11 +76,17 @@ const { account: author3 } = useAccountFromAcct(
client,
"lina@social.lysand.org",
);
const open = ref(false);
useListen("preferences:open", () => {
open.value = true;
});
</script>
<template>
<Dialog open v-if="identity">
<DialogContent class="md:max-w-5xl w-full h-full p-0 md:max-h-[70dvh]">
<Dialog v-model:open="open" v-if="identity">
<DialogContent class="md:max-w-5xl w-full h-full p-0 md:max-h-[70dvh] overflow-hidden">
<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">
@ -111,6 +118,11 @@ const { account: author3 } = useAccountFromAcct(
<Emojis />
</Page>
</TabsContent>
<TabsContent value="Account" as-child>
<Page title="Account">
<Profile />
</Page>
</TabsContent>
<TabsContent value="Developer" as-child>
<Page title="Developer">
<Developer />

View file

@ -1,34 +0,0 @@
<template>
<Collapsible :default-open="true">
<div class="grid grid-cols-[1fr_auto] gap-4 items-baseline">
<h2 class="text-2xl font-semibold tracking-tight">
{{ name }}
</h2>
<CollapsibleTrigger :as-child="true">
<Button size="icon" variant="outline" class="[&_svg]:data-[state=open]:-rotate-180">
<ChevronDown class="duration-200" />
</Button>
</CollapsibleTrigger>
</div>
<CollapsibleContent class="grid grid-cols-1 md:grid-cols-2 2xl:grid-cols-4 gap-3 mt-4">
<Emoji v-for="emoji in emojis" :key="emoji.id" :emoji="emoji" />
</CollapsibleContent>
</Collapsible>
</template>
<script lang="ts" setup>
import type { Emoji as EmojiType } from "@versia/client/types";
import { ChevronDown } from "lucide-vue-next";
import { Button } from "~/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "~/components/ui/collapsible";
import Emoji from "./emoji.vue";
defineProps<{
emojis: EmojiType[];
name: string;
}>();
</script>

View file

@ -1,144 +0,0 @@
<template>
<DropdownMenu>
<Card
:class="
cn(
'grid hover:cursor-pointer gap-4 items-center p-4',
canEdit
? 'grid-cols-[auto_1fr_auto]'
: 'grid-cols-[auto_1fr]'
)
"
>
<Avatar shape="square">
<AvatarImage :src="emoji.url" />
</Avatar>
<CardHeader class="p-0 gap-0 overflow-hidden">
<CardTitle as="span" class="text-sm font-mono truncate">
{{ emoji.shortcode }}
</CardTitle>
<CardDescription>
{{
emoji.global
? m.real_tame_moose_greet()
: m.witty_heroic_trout_cry()
}}
</CardDescription>
</CardHeader>
<CardFooter class="p-0" v-if="canEdit">
<DropdownMenuTrigger :as-child="true">
<Button variant="ghost" size="icon">
<Ellipsis />
</Button>
</DropdownMenuTrigger>
</CardFooter>
</Card>
<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 { cn } from "@/lib/utils";
import { type Emoji, RolePermission } from "@versia/client/types";
import { Delete, Ellipsis, TextCursorInput } from "lucide-vue-next";
import { toast } from "vue-sonner";
import { confirmModalService } from "~/components/modals/composable";
import { Avatar, AvatarImage } from "~/components/ui/avatar";
import { Button } from "~/components/ui/button";
import {
Card,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "~/components/ui/card";
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

@ -10,7 +10,7 @@
<DialogDescription class="sr-only">
{{ m.frail_great_marten_pet() }}
</DialogDescription>
<form class="p-4 grid gap-6" @submit="submit">
<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"
@ -125,32 +125,18 @@
</FormField>
<FormField
v-slot="{ componentField, value, handleChange }"
v-slot="{ value, handleChange }"
v-if="hasEmojiAdmin"
name="global"
:as="Card"
as-child
>
<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>
<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>
@ -178,6 +164,7 @@ import { RolePermission } from "@versia/client/types";
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 {
Card,

View file

@ -0,0 +1,63 @@
import { toTypedSchema } from "@vee-validate/zod";
import { z } from "zod";
import * as m from "~/paraglide/messages.js";
const characterRegex = new RegExp(/^[a-z0-9_-]+$/);
export const formSchema = (identity: Identity) =>
toTypedSchema(
z.strictObject({
banner: z
.instanceof(File)
.refine(
(v) =>
v.size <=
(identity.instance.configuration.accounts
.header_size_limit ?? Number.POSITIVE_INFINITY),
m.civil_icy_ant_mend({
size: identity.instance.configuration.accounts
.header_size_limit,
}),
)
.optional(),
avatar: z
.instanceof(File)
.refine(
(v) =>
v.size <=
(identity.instance.configuration.accounts
.avatar_size_limit ?? Number.POSITIVE_INFINITY),
m.zippy_caring_raven_edit({
size: identity.instance.configuration.accounts
.avatar_size_limit,
}),
)
.or(z.string().url())
.optional(),
name: z
.string()
.max(
identity.instance.configuration.accounts
.max_displayname_characters,
),
username: z
.string()
.regex(characterRegex, m.still_upper_otter_dine())
.max(
identity.instance.configuration.accounts
.max_username_characters,
),
bio: z
.string()
.max(
identity.instance.configuration.accounts
.max_note_characters,
),
bot: z.boolean().default(false),
locked: z.boolean().default(false),
discoverable: z.boolean().default(true),
fields: z.array(
z.strictObject({ name: z.string(), value: z.string() }),
),
}),
);

View file

@ -0,0 +1,185 @@
<template>
<form v-if="identity" class="grid gap-6" @submit="save">
<Transition name="slide-up">
<Alert v-if="dirty" layout="button" class="absolute bottom-2 z-10 inset-x-2 w-[calc(100%-1rem)]">
<SaveOff class="size-4" />
<AlertTitle>Unsaved changes</AlertTitle>
<AlertDescription>
Click "apply" to save your changes.
</AlertDescription>
<Button variant="secondary" class="w-full" typ="submit" :disabled="submitting">Apply</Button>
</Alert>
</Transition>
<FormField v-slot="{ handleChange, handleBlur }" name="banner">
<TextInput :title="m.bright_late_osprey_renew()" :description="m.great_level_lamb_sway()">
<Input type="file" accept="image/*" @change="handleChange" @blur="handleBlur" />
</TextInput>
</FormField>
<FormField v-slot="{ setValue }" name="avatar">
<TextInput :title="m.safe_icy_bulldog_quell()">
<ImageUploader v-model:image="identity.account.avatar" @submit-file="(file) => setValue(file)"
@submit-url="(url) => setValue(url)" />
</TextInput>
</FormField>
<FormField v-slot="{ componentField }" name="name">
<TextInput :title="m.mild_known_mallard_jolt()" :description="m.lime_dry_skunk_loop()">
<Input v-bind="componentField" />
</TextInput>
</FormField>
<FormField v-slot="{ componentField }" name="username">
<TextInput :title="m.neat_silly_dog_prosper()" :description="m.petty_plane_tadpole_earn()">
<Input v-bind="componentField" />
</TextInput>
</FormField>
<FormField v-slot="{ componentField }" name="bio">
<TextInput :title="m.next_caring_ladybug_hack()" :description="m.stale_just_anaconda_earn()">
<Textarea rows="10" v-bind="componentField" />
</TextInput>
</FormField>
<FormField v-slot="{ value, handleChange }" name="fields">
<Fields :title="m.aqua_mealy_toucan_pride()" :value="value" @update:value="handleChange" />
</FormField>
<FormField v-slot="{ value, handleChange }" name="bot" as-child>
<SwitchInput :title="m.gaudy_each_opossum_play()" :description="m.grassy_acidic_gadfly_cure()">
<Switch :model-value="value" @update:model-value="handleChange" />
</SwitchInput>
</FormField>
<FormField v-slot="{ value, handleChange }" name="locked" as-child>
<SwitchInput :title="m.dirty_moving_shark_emerge()" :description="m.bright_fun_mouse_boil()">
<Switch :model-value="value" @update:model-value="handleChange" />
</SwitchInput>
</FormField>
<FormField v-slot="{ value, handleChange }" name="discoverable" as-child>
<SwitchInput :title="m.red_vivid_cuckoo_spark()" :description="m.plain_zany_donkey_dart()">
<Switch :model-value="value" @update:model-value="handleChange" />
</SwitchInput>
</FormField>
</form>
</template>
<script lang="ts" setup>
import { SaveOff } from "lucide-vue-next";
import { useForm } from "vee-validate";
import { toast } from "vue-sonner";
import SwitchInput from "~/components/form/switch.vue";
import TextInput from "~/components/form/text.vue";
import * as m from "~/paraglide/messages.js";
import { Alert, AlertDescription, AlertTitle } from "../ui/alert";
import { Button } from "../ui/button";
import { FormField } from "../ui/form";
import { Input } from "../ui/input";
import { Switch } from "../ui/switch";
import { Textarea } from "../ui/textarea";
import { formSchema } from "./profile";
import Fields from "./profile/fields.vue";
import ImageUploader from "./profile/image-uploader.vue";
const dirty = computed(() => form.meta.value.dirty);
const submitting = ref(false);
if (!identity.value) {
throw new Error("Identity not found.");
}
const account = computed(() => identity.value?.account as Identity["account"]);
const schema = formSchema(identity.value);
const form = useForm({
validationSchema: schema,
initialValues: {
bio: account.value.source?.note ?? "",
bot: account.value.bot ?? false,
locked: account.value.locked ?? false,
discoverable: account.value.discoverable ?? true,
username: account.value.username,
name: account.value.display_name,
fields:
account.value.source?.fields.map((f) => ({
name: f.name,
value: f.value,
})) ?? [],
},
});
const save = form.handleSubmit(async (values) => {
if (submitting.value) {
return;
}
submitting.value = true;
const id = toast.loading(m.jolly_noble_sloth_breathe());
const changedData = {
display_name:
values.name === account.value.display_name
? undefined
: values.name,
username:
values.username === account.value.username
? undefined
: values.username,
note:
values.bio === account.value.source?.note ? undefined : values.bio,
bot: values.bot === account.value.bot ? undefined : values.bot,
locked:
values.locked === account.value.locked ? undefined : values.locked,
discoverable:
values.discoverable === account.value.discoverable
? undefined
: values.discoverable,
// Can't compare two arrays directly in JS, so we need to check if all fields are the same
fields_attributes: values.fields.every((field) =>
account.value.source?.fields?.some(
(f) => f.name === field.name && f.value === field.value,
),
)
? undefined
: values.fields,
header: values.banner ? values.banner : undefined,
avatar: values.avatar ? values.avatar : undefined,
};
if (
Object.values(changedData).filter((v) => v !== undefined).length === 0
) {
toast.dismiss(id);
toast.error(m.tough_alive_niklas_promise());
return;
}
try {
const { data } = await client.value.updateCredentials(
Object.fromEntries(
Object.entries(changedData).filter(([, v]) => v !== undefined),
),
);
toast.dismiss(id);
toast.success(m.spry_honest_kestrel_arrive());
if (identity.value) {
identity.value.account = data;
}
form.resetForm({
values: {
...form.values,
...values,
},
});
} catch (e) {
toast.dismiss(id);
}
submitting.value = false;
});
</script>

View file

@ -1,419 +0,0 @@
<template>
<Card v-if="identity" class="w-full max-h-full block overflow-y-auto">
<form class="p-4 grid gap-6" ref="formRef" @submit="handleSubmit">
<FormField v-slot="{ handleChange, handleBlur }" name="banner">
<FormItem>
<FormLabel>
{{ m.bright_late_osprey_renew() }}
</FormLabel>
<FormControl>
<Input
type="file"
accept="image/*"
@change="handleChange"
@blur="handleBlur"
/>
</FormControl>
<FormDescription>
{{ m.great_level_lamb_sway() }}
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ setValue }" name="avatar">
<FormItem class="grid gap-1">
<FormLabel>
{{ m.safe_icy_bulldog_quell() }}
</FormLabel>
<FormControl>
<ImageUploader
v-model:image="identity.account.avatar"
@submit-file="(file) => setValue(file)"
@submit-url="(url) => setValue(url)"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="name">
<FormItem>
<FormLabel>
{{ m.mild_known_mallard_jolt() }}
</FormLabel>
<FormControl>
<Input v-bind="componentField" />
</FormControl>
<FormDescription>
{{ m.lime_dry_skunk_loop() }}
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="username">
<FormItem>
<FormLabel>
{{ m.neat_silly_dog_prosper() }}
</FormLabel>
<FormControl>
<Input v-bind="componentField" />
</FormControl>
<FormDescription>
{{ m.petty_plane_tadpole_earn() }}
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="bio">
<FormItem>
<FormLabel>
{{ m.next_caring_ladybug_hack() }}
</FormLabel>
<FormControl>
<Textarea rows="10" v-bind="componentField" />
</FormControl>
<FormDescription>
{{ m.stale_just_anaconda_earn() }}
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ value, handleChange }" name="fields">
<FormItem>
<FormLabel>
{{ m.aqua_mealy_toucan_pride() }}
</FormLabel>
<FormControl>
<div class="grid gap-4">
<div
v-for="(field, index) in value"
:key="index"
class="grid items-center grid-cols-[auto_repeat(3,minmax(0,1fr))] gap-2"
>
<Button
variant="destructive"
size="icon"
@click="
handleChange([
...value.slice(0, index),
...value.slice(index + 1),
])
"
>
<Trash />
</Button>
<Input
v-model="field.name"
placeholder="Name"
@update:model-value="
(e) => {
handleChange([
...value.slice(0, index),
{ name: e, value: field.value },
...value.slice(index + 1),
]);
}
"
/>
<Input
v-model="field.value"
placeholder="Value"
class="col-span-2"
@update:model-value="
(e) => {
handleChange([
...value.slice(0, index),
{ name: field.name, value: e },
...value.slice(index + 1),
]);
}
"
/>
</div>
<Button
type="button"
variant="secondary"
@click="
handleChange([
...value,
{ name: '', value: '' },
])
"
>
{{ m.front_north_eel_gulp() }}
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField
v-slot="{ componentField, value, handleChange }"
name="bot"
:as="Card"
class="block"
>
<FormItem
class="grid grid-cols-[1fr_auto] items-center gap-2"
>
<CardHeader class="space-y-0.5 p-0">
<FormLabel :as="CardTitle">
{{ m.gaudy_each_opossum_play() }}
</FormLabel>
<CardDescription>
{{ m.grassy_acidic_gadfly_cure() }}
</CardDescription>
</CardHeader>
<FormControl>
<Switch
:checked="value"
@update:checked="handleChange"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField
v-slot="{ componentField, value, handleChange }"
name="locked"
:as="Card"
class="block"
>
<FormItem
class="grid grid-cols-[1fr_auto] items-center gap-2"
>
<CardHeader class="space-y-0.5 p-0">
<FormLabel :as="CardTitle">
{{ m.dirty_moving_shark_emerge() }}
</FormLabel>
<CardDescription>
{{ m.bright_fun_mouse_boil() }}
</CardDescription>
</CardHeader>
<FormControl>
<Switch
:checked="value"
@update:checked="handleChange"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField
v-slot="{ componentField, value, handleChange }"
name="discoverable"
:as="Card"
class="block"
>
<FormItem
class="grid grid-cols-[1fr_auto] items-center gap-2"
>
<CardHeader class="space-y-0.5 p-0">
<FormLabel :as="CardTitle">
{{ m.red_vivid_cuckoo_spark() }}
</FormLabel>
<CardDescription>
{{ m.plain_zany_donkey_dart() }}
</CardDescription>
</CardHeader>
<FormControl>
<Switch
:checked="value"
@update:checked="handleChange"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</form>
</Card>
</template>
<script lang="ts" setup>
import { toTypedSchema } from "@vee-validate/zod";
import type { ResponseError } from "@versia/client";
import { Trash } from "lucide-vue-next";
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 {
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";
import ImageUploader from "./image-uploader.vue";
if (!identity.value) {
throw new Error("Identity not found.");
}
const account = ref(identity.value.account);
const formSchema = toTypedSchema(
z.object({
banner: z
.instanceof(File)
.refine(
(v) =>
v.size <=
(identity.value?.instance.configuration.accounts
.header_size_limit ?? Number.POSITIVE_INFINITY),
m.civil_icy_ant_mend({
size: identity.value?.instance.configuration.accounts
.header_size_limit,
}),
)
.optional(),
avatar: z
.instanceof(File)
.refine(
(v) =>
v.size <=
(identity.value?.instance.configuration.accounts
.avatar_size_limit ?? Number.POSITIVE_INFINITY),
m.zippy_caring_raven_edit({
size: identity.value?.instance.configuration.accounts
.avatar_size_limit,
}),
)
.or(z.string().url())
.optional(),
name: z
.string()
.max(
identity.value.instance.configuration.accounts
.max_displayname_characters,
),
username: z
.string()
.regex(/^[a-z0-9_-]+$/, m.still_upper_otter_dine())
.max(
identity.value.instance.configuration.accounts
.max_username_characters,
),
bio: z
.string()
.max(
identity.value.instance.configuration.accounts
.max_note_characters,
),
bot: z.boolean(),
locked: z.boolean(),
discoverable: z.boolean(),
fields: z.array(z.object({ name: z.string(), value: z.string() })),
}),
);
const form = useForm({
validationSchema: formSchema,
initialValues: {
bio: account.value.source?.note ?? "",
bot: account.value.bot ?? false,
locked: account.value.locked ?? false,
discoverable: account.value.discoverable ?? true,
username: account.value.username,
name: account.value.display_name,
fields: account.value.source?.fields ?? [],
},
});
const handleSubmit = form.handleSubmit(async (values) => {
const id = toast.loading(m.jolly_noble_sloth_breathe());
const changedData = {
display_name:
values.name === account.value.display_name
? undefined
: values.name,
username:
values.username === account.value.username
? undefined
: values.username,
note:
values.bio === account.value.source?.note ? undefined : values.bio,
bot: values.bot === account.value.bot ? undefined : values.bot,
locked:
values.locked === account.value.locked ? undefined : values.locked,
discoverable:
values.discoverable === account.value.discoverable
? undefined
: values.discoverable,
// Can't compare two arrays directly in JS, so we need to check if all fields are the same
fields_attributes: values.fields.every((field) =>
account.value.source?.fields?.some(
(f) => f.name === field.name && f.value === field.value,
),
)
? undefined
: values.fields,
header: values.banner ? values.banner : undefined,
avatar: values.avatar ? values.avatar : undefined,
};
if (
Object.values(changedData).filter((v) => v !== undefined).length === 0
) {
toast.dismiss(id);
toast.error(m.tough_alive_niklas_promise());
return;
}
try {
const { data } = await client.value.updateCredentials(
Object.fromEntries(
Object.entries(changedData).filter(([, v]) => v !== undefined),
),
);
toast.dismiss(id);
toast.success(m.spry_honest_kestrel_arrive());
if (identity.value) {
identity.value.account = data;
}
account.value = data;
form.resetForm({
values: {
...form.values,
...values,
},
});
} catch (e) {
const error = e as ResponseError<{ error: string }>;
toast.dismiss(id);
}
});
const formRef = ref<HTMLFormElement | null>(null);
defineExpose({
submitForm: () => handleSubmit(),
dirty: computed(() => form.meta.value.dirty),
});
</script>

View file

@ -0,0 +1,104 @@
<template>
<FormItem>
<FormLabel>
{{ title }}
<Button type="button" variant="secondary" size="icon" class="ml-auto" @click="addField()" :title="m.front_north_eel_gulp()">
<Plus />
</Button>
</FormLabel>
<FormControl>
<VueDraggable class="grid gap-4" v-model="list" :animation="200" handle=".drag-handle">
<div v-for="(field, index) in list" :key="field.id"
class="grid items-center grid-cols-[auto_repeat(3,minmax(0,1fr))_auto] gap-2">
<Button as="span" variant="ghost" size="icon" class="drag-handle cursor-grab">
<GripVertical />
</Button>
<Input :model-value="field.name" placeholder="Name" @update:model-value="
(e) => updateKey(index, String(e))
" />
<Input :model-value="field.value" placeholder="Value" class="col-span-2" @update:model-value="
(e) => updateValue(index, String(e))
" />
<Button type="button" variant="secondary" size="icon" @click="removeField(index)">
<Trash />
</Button>
</div>
</VueDraggable>
<FormMessage />
</FormControl>
</FormItem>
</template>
<script lang="ts" setup>
import { GripVertical, Plus, Trash } from "lucide-vue-next";
import { VueDraggable } from "vue-draggable-plus";
import { Button } from "~/components/ui/button";
import {
FormControl,
FormItem,
FormLabel,
FormMessage,
} from "~/components/ui/form";
import { Input } from "~/components/ui/input";
import * as m from "~/paraglide/messages.js";
const { title } = defineProps<{
title: string;
}>();
const value = defineModel<{ name: string; value: string }[]>("value", {
default: [],
});
const list = ref<
{
id: string;
name: string;
value: string;
}[]
>(
value.value.map((item, index) => ({
id: String(index),
name: item.name,
value: item.value,
})),
);
watch(
list,
(newList) => {
value.value = newList.map((item) => ({
name: item.name,
value: item.value,
}));
},
{
deep: true,
},
);
const updateKey = (index: number, key: string) => {
if (!list.value[index]) {
return;
}
list.value[index].name = key;
};
const updateValue = (index: number, val: string) => {
if (!list.value[index]) {
return;
}
list.value[index].value = val;
};
const removeField = (index: number) => {
list.value.splice(index, 1);
};
const addField = () => {
list.value.push({ name: "", value: "", id: String(list.value.length) });
};
</script>

View file

@ -21,10 +21,10 @@
<DialogDescription class="sr-only">
{{ m.suave_broad_albatross_drop() }}
</DialogDescription>
<form class="p-4 grid gap-6" @submit="submit">
<form class="grid gap-6" @submit="submit">
<Tabs
default-value="upload"
class="mt-2 data-[component=tabpanel]:*:mt-6"
class="mt-2 *:data-[slot=tabs-content]:mt-2"
>
<TabsList class="w-full *:w-full">
<TabsTrigger value="upload">
@ -222,7 +222,7 @@ const emailToGravatar = async (email: string) => {
const open = ref(false);
const gravatarUrl = ref<string | undefined>(undefined);
const { handleSubmit, isSubmitting, values } = useForm({
const { handleSubmit, isSubmitting } = useForm({
validationSchema: schema,
});

View file

@ -1,46 +0,0 @@
<template>
<Card class="grid grid-cols-[1fr_auto] items-center px-6 py-4 gap-2">
<CardHeader class="space-y-0.5 p-0">
<CardTitle class="text-base">
{{ setting.title() }}
</CardTitle>
<CardDescription>
{{ setting.description() }}
</CardDescription>
</CardHeader>
<CardFooter class="p-0">
<Select :model-value="setting.value" @update:model-value="v => { setting.value = v }">
<SelectTrigger>
<SelectValue placeholder="Select an option" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="option of setting.options" :value="option.value">
{{ option.label() }}
</SelectItem>
</SelectContent>
</Select>
</CardFooter>
</Card>
</template>
<script lang="ts" setup>
import {
Card,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import type { EnumSetting } from "~/settings.ts";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
defineModel<EnumSetting>("setting", {
required: true,
});
</script>

View file

@ -1,31 +0,0 @@
<template>
<Card class="grid grid-rows-[1fr_auto] xl:grid-rows-none xl:grid-cols-[1fr_auto] items-center px-6 py-4 gap-4">
<CardHeader class="space-y-0.5 p-0">
<CardTitle class="text-base">
{{ setting.title() }}
</CardTitle>
<CardDescription>
{{ setting.description() }}
</CardDescription>
</CardHeader>
<CardFooter class="p-0">
<Input :model-value="setting.value" @update:model-value="v => { setting.value = String(v) }" />
</CardFooter>
</Card>
</template>
<script lang="ts" setup>
import {
Card,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import type { StringSetting } from "~/settings.ts";
defineModel<StringSetting>("setting", {
required: true,
});
</script>

View file

@ -1,31 +0,0 @@
<template>
<Card class="grid grid-cols-[1fr_auto] items-center px-6 py-4 gap-2">
<CardHeader class="space-y-0.5 p-0">
<CardTitle class="text-base">
{{ setting.title() }}
</CardTitle>
<CardDescription>
{{ setting.description() }}
</CardDescription>
</CardHeader>
<CardFooter class="p-0">
<Switch :disabled="setting.notImplemented" :checked="setting.value" @update:checked="v => { setting.value = v }" />
</CardFooter>
</Card>
</template>
<script lang="ts" setup>
import {
Card,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { Switch } from "~/components/ui/switch";
import type { BooleanSetting } from "~/settings.ts";
defineModel<BooleanSetting>("setting", {
required: true,
});
</script>

View file

@ -1,290 +0,0 @@
<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/types";
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 {
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

@ -1,7 +1,7 @@
<template>
<Card
class="flex-row gap-2 p-2 truncate items-center"
:class="naked ? 'p-0 bg-transparent ring-0 border-none' : ''"
:class="naked ? 'p-0 bg-transparent ring-0 border-none shadow-none' : ''"
>
<Avatar :src="account.avatar" :name="account.display_name" class="size-10" />
<CardContent class="leading-tight">

View file

@ -1,5 +1,11 @@
<script setup lang="ts">
import { ChevronsUpDown, DownloadCloud, Pen, UserPlus } from "lucide-vue-next";
import {
ChevronsUpDown,
Cog,
DownloadCloud,
Pen,
UserPlus,
} from "lucide-vue-next";
import TinyCard from "~/components/profiles/tiny-card.vue";
import { Button } from "~/components/ui/button";
import {
@ -37,6 +43,10 @@ const { $pwa } = useNuxtApp();
{{ m.salty_aloof_turkey_nudge() }}
</span>
</Button>
<Button v-if="identity" size="lg" variant="secondary" @click="useEvent('preferences:open')">
<Cog />
Preferences
</Button>
<Button v-if="$pwa?.needRefresh" variant="destructive" size="lg"
class="w-full group-data-[collapsible=icon]:px-4" @click="$pwa?.updateServiceWorker(true)">
<DownloadCloud />

View file

@ -14,12 +14,6 @@
"
/>
</SidebarGroup>
<SidebarGroup v-if="identity" class="mt-auto">
<SidebarGroupLabel>{{
m.close_short_kitten_coax()
}}</SidebarGroupLabel>
<NavGroup :items="sidebarConfig.navMain" />
</SidebarGroup>
</SidebarContent>
<FooterActions />
<SidebarRail />
@ -38,6 +32,5 @@ import {
import * as m from "~/paraglide/messages.js";
import FooterActions from "./footer/footer-actions.vue";
import InstanceHeader from "./instance/instance-header.vue";
import NavGroup from "./navigation/nav-group.vue";
import NavItems from "./navigation/nav-items.vue";
</script>

View file

@ -1,44 +1,9 @@
import {
BedSingle,
Bell,
Globe,
House,
MapIcon,
Settings2,
} from "lucide-vue-next";
import { BedSingle, Bell, Globe, House, MapIcon } from "lucide-vue-next";
import * as m from "~/paraglide/messages.js";
import type { SidebarConfig } from "~/types/sidebar";
export const sidebarConfig: SidebarConfig = {
navMain: [
{
title: m.patchy_seemly_hound_grace(),
url: "/preferences",
icon: Settings2,
items: [
{
title: m.factual_arable_jurgen_endure(),
url: "/preferences/account",
},
{
title: m.tough_clean_wolf_gleam(),
url: "/preferences/appearance",
},
{
title: m.legal_best_tadpole_rise(),
url: "/preferences/behaviour",
},
{
title: m.novel_trite_sloth_adapt(),
url: "/preferences/emojis",
},
{
title: m.safe_green_mink_cook(),
url: "/preferences/roles",
},
],
},
],
navMain: [],
other: [
{
title: m.bland_chunky_sparrow_propel(),