mirror of
https://github.com/versia-pub/frontend.git
synced 2026-03-13 03:29:16 +01:00
chore: ⬆️ Upgrade to Nuxt 4
Some checks failed
Some checks failed
This commit is contained in:
parent
8debe97f63
commit
7f7cf20311
386 changed files with 2376 additions and 2332 deletions
44
app/components/preferences/category.vue
Normal file
44
app/components/preferences/category.vue
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<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)" :name="preference" />
|
||||
<BooleanPreferenceVue v-else-if="(prefs[preference] instanceof BooleanPreference)" :pref="(prefs[preference] as BooleanPreference)" :name="preference" />
|
||||
<SelectPreferenceVue v-else-if="(prefs[preference] instanceof SelectPreference)" :pref="(prefs[preference] as SelectPreference<string>)" :name="preference" />
|
||||
<NumberPreferenceVue v-else-if="(prefs[preference] instanceof NumberPreference)" :pref="(prefs[preference] as NumberPreference)" :name="preference" />
|
||||
<MultiSelectPreferenceVue v-else-if="(prefs[preference] instanceof MultiSelectPreference)" :pref="(prefs[preference] as MultiSelectPreference<string>)" :name="preference" />
|
||||
<CodePreferenceVue v-else-if="(prefs[preference] instanceof CodePreference)" :pref="(prefs[preference] as CodePreference)" :name="preference" />
|
||||
<UrlPreferenceVue v-else-if="(prefs[preference] instanceof UrlPreference)" :pref="(prefs[preference] as UrlPreference)" :name="preference" />
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Card, CardTitle } from "../ui/card/index.ts";
|
||||
import { preferences as prefs } from "./preferences.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";
|
||||
import {
|
||||
BooleanPreference,
|
||||
CodePreference,
|
||||
MultiSelectPreference,
|
||||
NumberPreference,
|
||||
SelectPreference,
|
||||
TextPreference,
|
||||
UrlPreference,
|
||||
} from "./types.ts";
|
||||
|
||||
const { preferences = [], name } = defineProps<{
|
||||
preferences: (keyof typeof prefs)[];
|
||||
name: string;
|
||||
}>();
|
||||
</script>
|
||||
60
app/components/preferences/developer.vue
Normal file
60
app/components/preferences/developer.vue
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
<template>
|
||||
<Card class="grid gap-3 text-sm">
|
||||
<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 { toast } from "vue-sonner";
|
||||
import { Button } from "../ui/button";
|
||||
import { Card } from "../ui/card";
|
||||
|
||||
const copy = (data: string) => {
|
||||
navigator.clipboard.writeText(data);
|
||||
toast.success("Copied to clipboard");
|
||||
};
|
||||
|
||||
const appData = useAppData();
|
||||
|
||||
const data: [string, string | VNode][] = [
|
||||
["User ID", identity.value?.account.id ?? ""],
|
||||
["Instance domain", identity.value?.instance.domain ?? ""],
|
||||
["Instance version", identity.value?.instance.versia_version ?? ""],
|
||||
["Client ID", appData.value?.client_id ?? ""],
|
||||
[
|
||||
"Client secret",
|
||||
<Button
|
||||
variant="outline"
|
||||
class="font-sans"
|
||||
size="sm"
|
||||
// @ts-expect-error missing onClick types
|
||||
onClick={() => copy(appData.value?.client_secret ?? "")}
|
||||
>
|
||||
Click to copy
|
||||
</Button>,
|
||||
],
|
||||
[
|
||||
"Access token",
|
||||
<Button
|
||||
variant="outline"
|
||||
class="font-sans"
|
||||
size="sm"
|
||||
// @ts-expect-error missing onClick types
|
||||
onClick={() => copy(identity.value?.tokens.access_token ?? "")}
|
||||
>
|
||||
Click to copy
|
||||
</Button>,
|
||||
],
|
||||
];
|
||||
</script>
|
||||
169
app/components/preferences/dialog.vue
Normal file
169
app/components/preferences/dialog.vue
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
<script setup lang="ts">
|
||||
import {
|
||||
InfoIcon,
|
||||
PaletteIcon,
|
||||
SettingsIcon,
|
||||
ShieldCheckIcon,
|
||||
SmileIcon,
|
||||
TerminalSquareIcon,
|
||||
UserIcon,
|
||||
} from "lucide-vue-next";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
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 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", "Developer", "About"])
|
||||
// Remove duplicates
|
||||
.filter((c, i, a) => a.indexOf(c) === i);
|
||||
const extraPages = ["Account", "Emojis", "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",
|
||||
);
|
||||
|
||||
const { account: author4 } = useAccountFromAcct(client, "nyx@v.everypizza.im");
|
||||
|
||||
const open = ref(false);
|
||||
|
||||
useListen("preferences:open", () => {
|
||||
open.value = true;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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">
|
||||
<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="Account" as-child>
|
||||
<Page title="Account">
|
||||
<Profile />
|
||||
</Page>
|
||||
</TabsContent>
|
||||
<TabsContent value="Developer" as-child>
|
||||
<Page title="Developer">
|
||||
<Developer />
|
||||
</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" />
|
||||
<TinyCard v-if="author4" :account="author4" domain="v.everypizza.im" />
|
||||
</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>
|
||||
73
app/components/preferences/emojis/batch-dropdown.vue
Normal file
73
app/components/preferences/emojis/batch-dropdown.vue
Normal 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>
|
||||
108
app/components/preferences/emojis/dropdown.vue
Normal file
108
app/components/preferences/emojis/dropdown.vue
Normal 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>
|
||||
19
app/components/preferences/emojis/index.vue
Normal file
19
app/components/preferences/emojis/index.vue
Normal 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>
|
||||
362
app/components/preferences/emojis/table.vue
Normal file
362
app/components/preferences/emojis/table.vue
Normal 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>
|
||||
283
app/components/preferences/emojis/uploader.vue
Normal file
283
app/components/preferences/emojis/uploader.vue
Normal 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>
|
||||
7
app/components/preferences/index.vue
Normal file
7
app/components/preferences/index.vue
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<template>
|
||||
<Dialog />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import Dialog from "./dialog.vue";
|
||||
</script>
|
||||
15
app/components/preferences/page.vue
Normal file
15
app/components/preferences/page.vue
Normal 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>
|
||||
150
app/components/preferences/preferences.ts
Normal file
150
app/components/preferences/preferences.ts
Normal 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;
|
||||
63
app/components/preferences/profile.ts
Normal file
63
app/components/preferences/profile.ts
Normal 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_limit ?? Number.POSITIVE_INFINITY),
|
||||
m.civil_icy_ant_mend({
|
||||
size: identity.instance.configuration.accounts
|
||||
.header_limit,
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
avatar: z
|
||||
.instanceof(File)
|
||||
.refine(
|
||||
(v) =>
|
||||
v.size <=
|
||||
(identity.instance.configuration.accounts
|
||||
.avatar_limit ?? Number.POSITIVE_INFINITY),
|
||||
m.zippy_caring_raven_edit({
|
||||
size: identity.instance.configuration.accounts
|
||||
.avatar_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() }),
|
||||
),
|
||||
}),
|
||||
);
|
||||
185
app/components/preferences/profile.vue
Normal file
185
app/components/preferences/profile.vue
Normal 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>
|
||||
104
app/components/preferences/profile/fields.vue
Normal file
104
app/components/preferences/profile/fields.vue
Normal 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>
|
||||
243
app/components/preferences/profile/image-uploader.vue
Normal file
243
app/components/preferences/profile/image-uploader.vue
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogTrigger :as-child="true">
|
||||
<Button
|
||||
v-bind="$attrs"
|
||||
variant="ghost"
|
||||
class="h-fit w-fit p-0 m-0 relative group border overflow-hidden"
|
||||
>
|
||||
<Avatar class="size-32" :src="image" :name="displayName" />
|
||||
<div
|
||||
class="absolute inset-0 bg-background/80 flex group-hover:opacity-100 opacity-0 duration-200 items-center justify-center"
|
||||
>
|
||||
<Upload />
|
||||
</div>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogTitle>
|
||||
{{ m.due_hour_husky_prosper() }}
|
||||
</DialogTitle>
|
||||
<DialogDescription class="sr-only">
|
||||
{{ m.suave_broad_albatross_drop() }}
|
||||
</DialogDescription>
|
||||
<form class="grid gap-6" @submit="submit">
|
||||
<Tabs
|
||||
default-value="upload"
|
||||
class="mt-2 *:data-[slot=tabs-content]:mt-2"
|
||||
>
|
||||
<TabsList class="w-full *:w-full">
|
||||
<TabsTrigger value="upload">
|
||||
{{ m.flat_safe_haddock_gaze() }}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="gravatar">
|
||||
{{ m.inclusive_long_lizard_boost() }}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="url">
|
||||
{{ m.proud_next_elk_beam() }}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="upload">
|
||||
<FormField
|
||||
v-slot="{ handleChange, handleBlur }"
|
||||
name="image"
|
||||
>
|
||||
<FormItem>
|
||||
<FormLabel class="sr-only">
|
||||
{{ m.flat_safe_haddock_gaze() }}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
@change="handleChange"
|
||||
@blur="handleBlur"
|
||||
:disabled="isSubmitting"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{{ m.lime_late_millipede_urge() }}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</TabsContent>
|
||||
<TabsContent value="gravatar">
|
||||
<FormField
|
||||
v-slot="{ componentField, value }"
|
||||
name="email"
|
||||
@update:model-value="
|
||||
async (value) => {
|
||||
gravatarUrl = await emailToGravatar(value);
|
||||
}
|
||||
"
|
||||
>
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{{ m.lower_formal_kudu_lift() }}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
v-bind="componentField"
|
||||
:disabled="isSubmitting"
|
||||
placeholder="peter.griffin@fox.com"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<div v-if="value" class="grid gap-4 !mt-4">
|
||||
<Label>{{
|
||||
m.witty_honest_wallaby_support()
|
||||
}}</Label>
|
||||
<Avatar class="size-32" :src="gravatarUrl" />
|
||||
</div>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</TabsContent>
|
||||
<TabsContent value="url">
|
||||
<FormField
|
||||
v-slot="{ componentField, value }"
|
||||
name="url"
|
||||
>
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{{ m.proud_next_elk_beam() }}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
v-bind="componentField"
|
||||
:disabled="isSubmitting"
|
||||
placeholder="https://mysite.com/avatar.webp"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<div v-if="value" class="grid gap-4 !mt-4">
|
||||
<Label>{{
|
||||
m.witty_honest_wallaby_support()
|
||||
}}</Label>
|
||||
<Avatar class="size-32" :src="value" />
|
||||
</div>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<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.teary_antsy_panda_aid() }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { toTypedSchema } from "@vee-validate/zod";
|
||||
import { Upload } from "lucide-vue-next";
|
||||
import { useForm } from "vee-validate";
|
||||
import { z } from "zod";
|
||||
import Avatar from "~/components/profiles/avatar.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 { Label } from "~/components/ui/label";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||
import * as m from "~~/paraglide/messages.js";
|
||||
|
||||
const { maxSize } = defineProps<{
|
||||
displayName?: string;
|
||||
maxSize?: number;
|
||||
}>();
|
||||
|
||||
const image = defineModel<string>("image", {
|
||||
required: true,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
submitFile: [file: File];
|
||||
submitUrl: [url: string];
|
||||
}>();
|
||||
|
||||
const schema = toTypedSchema(
|
||||
z
|
||||
.object({
|
||||
image: z
|
||||
.instanceof(File, {
|
||||
message: m.sound_topical_gopher_offer(),
|
||||
})
|
||||
.refine(
|
||||
(v) => v.size <= (maxSize ?? Number.MAX_SAFE_INTEGER),
|
||||
m.zippy_caring_raven_edit({
|
||||
size: maxSize ?? Number.MAX_SAFE_INTEGER,
|
||||
}),
|
||||
),
|
||||
})
|
||||
.or(
|
||||
z.object({
|
||||
url: z.string().url(),
|
||||
}),
|
||||
)
|
||||
.or(
|
||||
z.object({
|
||||
email: z.string().email(),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const emailToGravatar = async (email: string) => {
|
||||
const sha256 = await crypto.subtle.digest(
|
||||
"SHA-256",
|
||||
new TextEncoder().encode(email),
|
||||
);
|
||||
|
||||
return `https://www.gravatar.com/avatar/${Array.from(new Uint8Array(sha256))
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("")}?size=512`;
|
||||
};
|
||||
|
||||
const open = ref(false);
|
||||
const gravatarUrl = ref<string | undefined>(undefined);
|
||||
|
||||
const { handleSubmit, isSubmitting } = useForm({
|
||||
validationSchema: schema,
|
||||
});
|
||||
|
||||
const submit = handleSubmit(async (values) => {
|
||||
if ((values as { image: File }).image) {
|
||||
emit("submitFile", (values as { image: File }).image);
|
||||
} else if ((values as { url: string }).url) {
|
||||
emit("submitUrl", (values as { url: string }).url);
|
||||
} else if ((values as { email: string }).email) {
|
||||
emit(
|
||||
"submitUrl",
|
||||
await emailToGravatar((values as { email: string }).email),
|
||||
);
|
||||
}
|
||||
|
||||
open.value = false;
|
||||
});
|
||||
</script>
|
||||
37
app/components/preferences/stats.vue
Normal file
37
app/components/preferences/stats.vue
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<template>
|
||||
<Card class="grid gap-3 text-sm max-w-sm">
|
||||
<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",
|
||||
<a
|
||||
href={pkg.repository.url.replace("git+", "")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{pkg.repository.url.replace("git+", "").replace("https://", "")}
|
||||
</a>,
|
||||
],
|
||||
];
|
||||
</script>
|
||||
71
app/components/preferences/types.ts
Normal file
71
app/components/preferences/types.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
40
app/components/preferences/types/base.vue
Normal file
40
app/components/preferences/types/base.vue
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<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 { preferences as prefs } from "../preferences";
|
||||
import type { Preference } from "../types";
|
||||
|
||||
const { pref, name } = defineProps<{
|
||||
pref: Preference<any>;
|
||||
name: keyof typeof prefs;
|
||||
}>();
|
||||
|
||||
const value = ref<any>(preferences[name].value);
|
||||
const setValue = (newValue: MaybeRef<any>) => {
|
||||
value.value = toValue(newValue);
|
||||
};
|
||||
|
||||
watch(value, (newVal) => {
|
||||
preferences[name].value = newVal;
|
||||
});
|
||||
|
||||
defineSlots<{
|
||||
default(props: {
|
||||
value: any;
|
||||
setValue: (value: MaybeRef<any>) => void;
|
||||
}): any;
|
||||
extra(props: { value: any; setValue: (value: MaybeRef<any>) => void }): any;
|
||||
}>();
|
||||
</script>
|
||||
17
app/components/preferences/types/boolean.vue
Normal file
17
app/components/preferences/types/boolean.vue
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<template>
|
||||
<Base :pref="pref" :name="name" 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 { preferences as prefs } from "../preferences";
|
||||
import type { BooleanPreference } from "../types";
|
||||
import Base from "./base.vue";
|
||||
|
||||
const { pref, name } = defineProps<{
|
||||
pref: BooleanPreference;
|
||||
name: keyof typeof prefs;
|
||||
}>();
|
||||
</script>
|
||||
36
app/components/preferences/types/code.vue
Normal file
36
app/components/preferences/types/code.vue
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<template>
|
||||
<Collapsible as-child>
|
||||
<Base :name="name" :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 { preferences as prefs } from "../preferences";
|
||||
import type { CodePreference } from "../types";
|
||||
import Base from "./base.vue";
|
||||
|
||||
const { pref, name } = defineProps<{
|
||||
pref: CodePreference;
|
||||
name: keyof typeof prefs;
|
||||
}>();
|
||||
</script>
|
||||
41
app/components/preferences/types/multiselect.vue
Normal file
41
app/components/preferences/types/multiselect.vue
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<template>
|
||||
<Base :pref="pref" :name="name" 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 { preferences as prefs } from "../preferences";
|
||||
import type { MultiSelectPreference } from "../types";
|
||||
import Base from "./base.vue";
|
||||
|
||||
const { pref, name } = defineProps<{
|
||||
pref: MultiSelectPreference<string>;
|
||||
name: keyof typeof prefs;
|
||||
}>();
|
||||
</script>
|
||||
29
app/components/preferences/types/number.vue
Normal file
29
app/components/preferences/types/number.vue
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<template>
|
||||
<Base :pref="pref" :name="name" 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 { preferences as prefs } from "../preferences";
|
||||
import type { NumberPreference } from "../types";
|
||||
import Base from "./base.vue";
|
||||
|
||||
const { pref, name } = defineProps<{
|
||||
pref: NumberPreference;
|
||||
name: keyof typeof prefs;
|
||||
}>();
|
||||
</script>
|
||||
35
app/components/preferences/types/select.vue
Normal file
35
app/components/preferences/types/select.vue
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<template>
|
||||
<Base :pref="pref" :name="name" 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 { preferences as prefs } from "../preferences";
|
||||
import type { SelectPreference } from "../types";
|
||||
import Base from "./base.vue";
|
||||
|
||||
const { pref, name } = defineProps<{
|
||||
pref: SelectPreference<string>;
|
||||
name: keyof typeof prefs;
|
||||
}>();
|
||||
</script>
|
||||
17
app/components/preferences/types/text.vue
Normal file
17
app/components/preferences/types/text.vue
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<template>
|
||||
<Base :pref="pref" :name="name" 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 { preferences as prefs } from "../preferences";
|
||||
import type { TextPreference } from "../types";
|
||||
import Base from "./base.vue";
|
||||
|
||||
const { pref, name } = defineProps<{
|
||||
pref: TextPreference;
|
||||
name: keyof typeof prefs;
|
||||
}>();
|
||||
</script>
|
||||
36
app/components/preferences/types/url.vue
Normal file
36
app/components/preferences/types/url.vue
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<template>
|
||||
<Collapsible as-child>
|
||||
<Base :pref="pref" :name="name">
|
||||
<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 { preferences as prefs } from "../preferences";
|
||||
import type { TextPreference } from "../types";
|
||||
import Base from "./base.vue";
|
||||
|
||||
const { pref, name } = defineProps<{
|
||||
pref: TextPreference;
|
||||
name: keyof typeof prefs;
|
||||
}>();
|
||||
</script>
|
||||
Loading…
Add table
Add a link
Reference in a new issue