mirror of
https://github.com/versia-pub/frontend.git
synced 2025-12-06 08:28:20 +01:00
Compare commits
7 commits
0443a37508
...
c464b0dfba
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c464b0dfba | ||
|
|
11bf67fbc9 | ||
|
|
128ca0c225 | ||
|
|
34ce25cc1d | ||
|
|
3ce71dd4df | ||
|
|
412e49dfe2 | ||
|
|
17bb75733c |
|
|
@ -1,5 +1,5 @@
|
||||||
# Paraglide doesn't properly work with Bun, so it needs Node
|
# Paraglide doesn't properly work with Bun, so it needs Node
|
||||||
FROM oven/bun:1.2.2-alpine AS base
|
FROM imbios/bun-node:latest-current-alpine AS base
|
||||||
|
|
||||||
# Install dependencies into temp directory
|
# Install dependencies into temp directory
|
||||||
# This will cache them and speed up future builds
|
# This will cache them and speed up future builds
|
||||||
|
|
|
||||||
30
app.vue
30
app.vue
|
|
@ -1,7 +1,7 @@
|
||||||
<template>
|
<template>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Component is="style">
|
<Component is="style">
|
||||||
{{ customCss.value }}
|
{{ preferences.custom_css }}
|
||||||
</Component>
|
</Component>
|
||||||
<NuxtPwaAssets />
|
<NuxtPwaAssets />
|
||||||
<NuxtLayout>
|
<NuxtLayout>
|
||||||
|
|
@ -19,7 +19,6 @@ import ConfirmationModal from "./components/modals/confirm.vue";
|
||||||
import { Toaster } from "./components/ui/sonner";
|
import { Toaster } from "./components/ui/sonner";
|
||||||
import { TooltipProvider } from "./components/ui/tooltip";
|
import { TooltipProvider } from "./components/ui/tooltip";
|
||||||
import { overwriteGetLocale } from "./paraglide/runtime";
|
import { overwriteGetLocale } from "./paraglide/runtime";
|
||||||
import { type EnumSetting, SettingIds } from "./settings";
|
|
||||||
// Sin
|
// Sin
|
||||||
//import "~/styles/mcdonalds.css";
|
//import "~/styles/mcdonalds.css";
|
||||||
|
|
||||||
|
|
@ -31,17 +30,16 @@ const origin = useRequestURL().searchParams.get("origin");
|
||||||
const appData = useAppData();
|
const appData = useAppData();
|
||||||
const instance = useInstance();
|
const instance = useInstance();
|
||||||
const description = useExtendedDescription(client);
|
const description = useExtendedDescription(client);
|
||||||
const customCss = useSetting(SettingIds.CustomCSS);
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
// Theme switcher
|
// Theme switcher
|
||||||
const theme = useSetting(SettingIds.Theme) as Ref<EnumSetting>;
|
|
||||||
const colorMode = useColorMode();
|
const colorMode = useColorMode();
|
||||||
|
const radius = useCssVar("--radius");
|
||||||
|
|
||||||
watch(theme.value, () => {
|
watch(preferences.color_theme, (newVal) => {
|
||||||
// Add theme-changing class to html to trigger transition
|
// Add theme-changing class to html to trigger transition
|
||||||
document.documentElement.classList.add("theme-changing");
|
document.documentElement.classList.add("theme-changing");
|
||||||
colorMode.preference = theme.value.value;
|
colorMode.preference = newVal;
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
// Remove theme-changing class after transition
|
// Remove theme-changing class after transition
|
||||||
|
|
@ -49,6 +47,16 @@ watch(theme.value, () => {
|
||||||
}, 1000);
|
}, 1000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
preferences.border_radius,
|
||||||
|
(newVal) => {
|
||||||
|
radius.value = `${newVal}rem`;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
immediate: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
useSeoMeta({
|
useSeoMeta({
|
||||||
titleTemplate: (titleChunk) => {
|
titleTemplate: (titleChunk) => {
|
||||||
return titleChunk ? `${titleChunk} · Versia` : "Versia";
|
return titleChunk ? `${titleChunk} · Versia` : "Versia";
|
||||||
|
|
@ -111,13 +119,13 @@ html.theme-changing * {
|
||||||
box-shadow 1s ease !important;
|
box-shadow 1s ease !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slide-down-enter-active,
|
.slide-up-enter-active,
|
||||||
.slide-down-leave-active {
|
.slide-up-leave-active {
|
||||||
transition: transform 0.3s ease;
|
transition: transform 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slide-down-enter-from,
|
.slide-up-enter-from,
|
||||||
.slide-down-leave-to {
|
.slide-up-leave-to {
|
||||||
transform: translateY(-100%);
|
transform: translateY(100%);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,8 @@
|
||||||
"rules": {
|
"rules": {
|
||||||
"all": true,
|
"all": true,
|
||||||
"suspicious": {
|
"suspicious": {
|
||||||
"noConsole": "off"
|
"noConsole": "off",
|
||||||
|
"noExplicitAny": "off"
|
||||||
},
|
},
|
||||||
"performance": {
|
"performance": {
|
||||||
"noBarrelFile": "off"
|
"noBarrelFile": "off"
|
||||||
|
|
@ -20,7 +21,8 @@
|
||||||
"noUnusedVariables": "off",
|
"noUnusedVariables": "off",
|
||||||
"noUnusedImports": "off",
|
"noUnusedImports": "off",
|
||||||
"noUndeclaredDependencies": "off",
|
"noUndeclaredDependencies": "off",
|
||||||
"useImportExtensions": "off"
|
"useImportExtensions": "off",
|
||||||
|
"useJsxKeyInIterable": "off"
|
||||||
},
|
},
|
||||||
"complexity": {
|
"complexity": {
|
||||||
"noExcessiveCognitiveComplexity": "off"
|
"noExcessiveCognitiveComplexity": "off"
|
||||||
|
|
|
||||||
|
|
@ -159,7 +159,6 @@ import {
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
} from "~/components/ui/select";
|
} from "~/components/ui/select";
|
||||||
import * as m from "~/paraglide/messages.js";
|
import * as m from "~/paraglide/messages.js";
|
||||||
import { SettingIds } from "~/settings";
|
|
||||||
import EditorContent from "../editor/content.vue";
|
import EditorContent from "../editor/content.vue";
|
||||||
import { Button } from "../ui/button";
|
import { Button } from "../ui/button";
|
||||||
import { DialogFooter } from "../ui/dialog";
|
import { DialogFooter } from "../ui/dialog";
|
||||||
|
|
@ -169,13 +168,11 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||||
import Files from "./files.vue";
|
import Files from "./files.vue";
|
||||||
|
|
||||||
const { Control_Enter, Command_Enter } = useMagicKeys();
|
const { Control_Enter, Command_Enter } = useMagicKeys();
|
||||||
const ctrlEnterSend = useSetting(SettingIds.CtrlEnterToSend);
|
|
||||||
const defaultVisibility = useSetting(SettingIds.DefaultVisibility);
|
|
||||||
const { play } = useAudio();
|
const { play } = useAudio();
|
||||||
const fileInput = ref<HTMLInputElement | null>(null);
|
const fileInput = ref<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
watch([Control_Enter, Command_Enter], () => {
|
watch([Control_Enter, Command_Enter], () => {
|
||||||
if (sending.value || !ctrlEnterSend.value.value) {
|
if (sending.value || !preferences.ctrl_enter_send.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -220,9 +217,10 @@ const state = reactive({
|
||||||
sensitive: relation?.type === "edit" ? relation.note.sensitive : false,
|
sensitive: relation?.type === "edit" ? relation.note.sensitive : false,
|
||||||
contentWarning: relation?.type === "edit" ? relation.note.spoiler_text : "",
|
contentWarning: relation?.type === "edit" ? relation.note.spoiler_text : "",
|
||||||
contentType: "text/html" as "text/html" | "text/plain",
|
contentType: "text/html" as "text/html" | "text/plain",
|
||||||
visibility: (relation?.type === "edit"
|
visibility:
|
||||||
|
relation?.type === "edit"
|
||||||
? relation.note.visibility
|
? relation.note.visibility
|
||||||
: (defaultVisibility.value.value ?? "public")) as Status["visibility"],
|
: preferences.default_visibility.value,
|
||||||
files: (relation?.type === "edit"
|
files: (relation?.type === "edit"
|
||||||
? relation.note.media_attachments.map((a) => ({
|
? relation.note.media_attachments.map((a) => ({
|
||||||
apiId: a.id,
|
apiId: a.id,
|
||||||
|
|
|
||||||
34
components/form/switch.vue
Normal file
34
components/form/switch.vue
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
<template>
|
||||||
|
<Card class="p-4">
|
||||||
|
<FormItem class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-2">
|
||||||
|
<CardHeader class="flex flex-col gap-1.5 p-0">
|
||||||
|
<FormLabel class="font-semibold tracking-tight" :as="CardTitle">
|
||||||
|
{{ title }}
|
||||||
|
</FormLabel>
|
||||||
|
<FormDescription class="text-xs leading-none" v-if="description">
|
||||||
|
{{ description }}
|
||||||
|
</FormDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<FormControl>
|
||||||
|
<slot />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</Card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { Card, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||||
|
import {
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "../ui/form";
|
||||||
|
|
||||||
|
const { title, description } = defineProps<{
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
29
components/form/text.vue
Normal file
29
components/form/text.vue
Normal 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>
|
||||||
|
|
@ -19,9 +19,9 @@ defineProps<{
|
||||||
modalOptions: ConfirmModalOptions;
|
modalOptions: ConfirmModalOptions;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
defineEmits<{
|
const emit = defineEmits<{
|
||||||
confirm: (result: ConfirmModalResult) => void;
|
confirm: [result: ConfirmModalResult];
|
||||||
cancel: () => void;
|
cancel: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const inputValue = ref<string>("");
|
const inputValue = ref<string>("");
|
||||||
|
|
@ -55,10 +55,10 @@ const inputValue = ref<string>("");
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" @click="() => $emit('cancel')">
|
<Button variant="outline" @click="() => emit('cancel')">
|
||||||
{{ modalOptions.cancelText }}
|
{{ modalOptions.cancelText }}
|
||||||
</Button>
|
</Button>
|
||||||
<Button @click="() => $emit('confirm', {
|
<Button @click="() => emit('confirm', {
|
||||||
confirmed: true,
|
confirmed: true,
|
||||||
value: inputValue,
|
value: inputValue,
|
||||||
})">
|
})">
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@ import { Ellipsis, Heart, Quote, Repeat, Reply } from "lucide-vue-next";
|
||||||
import { toast } from "vue-sonner";
|
import { toast } from "vue-sonner";
|
||||||
import * as m from "~/paraglide/messages.js";
|
import * as m from "~/paraglide/messages.js";
|
||||||
import { getLocale } from "~/paraglide/runtime";
|
import { getLocale } from "~/paraglide/runtime";
|
||||||
import { SettingIds } from "~/settings";
|
|
||||||
import { confirmModalService } from "../modals/composable";
|
import { confirmModalService } from "../modals/composable";
|
||||||
import ActionButton from "./action-button.vue";
|
import ActionButton from "./action-button.vue";
|
||||||
import Menu from "./menu.vue";
|
import Menu from "./menu.vue";
|
||||||
|
|
@ -48,11 +47,8 @@ const emit = defineEmits<{
|
||||||
}>();
|
}>();
|
||||||
const { play } = useAudio();
|
const { play } = useAudio();
|
||||||
|
|
||||||
const confirmLikes = useSetting(SettingIds.ConfirmLike);
|
|
||||||
const confirmReblogs = useSetting(SettingIds.ConfirmReblog);
|
|
||||||
|
|
||||||
const like = async () => {
|
const like = async () => {
|
||||||
if (confirmLikes.value.value) {
|
if (preferences.confirm_actions.value.includes("like")) {
|
||||||
const confirmation = await confirmModalService.confirm({
|
const confirmation = await confirmModalService.confirm({
|
||||||
title: m.slimy_least_ray_aid(),
|
title: m.slimy_least_ray_aid(),
|
||||||
message: m.stale_new_ray_jolt(),
|
message: m.stale_new_ray_jolt(),
|
||||||
|
|
@ -74,7 +70,7 @@ const like = async () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const unlike = async () => {
|
const unlike = async () => {
|
||||||
if (confirmLikes.value.value) {
|
if (preferences.confirm_actions.value.includes("like")) {
|
||||||
const confirmation = await confirmModalService.confirm({
|
const confirmation = await confirmModalService.confirm({
|
||||||
title: m.odd_strong_halibut_prosper(),
|
title: m.odd_strong_halibut_prosper(),
|
||||||
message: m.slow_blue_parrot_savor(),
|
message: m.slow_blue_parrot_savor(),
|
||||||
|
|
@ -95,7 +91,7 @@ const unlike = async () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const reblog = async () => {
|
const reblog = async () => {
|
||||||
if (confirmReblogs.value.value) {
|
if (preferences.confirm_actions.value.includes("reblog")) {
|
||||||
const confirmation = await confirmModalService.confirm({
|
const confirmation = await confirmModalService.confirm({
|
||||||
title: m.best_mellow_llama_surge(),
|
title: m.best_mellow_llama_surge(),
|
||||||
message: m.salty_plain_mallard_gaze(),
|
message: m.salty_plain_mallard_gaze(),
|
||||||
|
|
@ -116,7 +112,7 @@ const reblog = async () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const unreblog = async () => {
|
const unreblog = async () => {
|
||||||
if (confirmReblogs.value.value) {
|
if (preferences.confirm_actions.value.includes("reblog")) {
|
||||||
const confirmation = await confirmModalService.confirm({
|
const confirmation = await confirmModalService.confirm({
|
||||||
title: m.main_fancy_octopus_loop(),
|
title: m.main_fancy_octopus_loop(),
|
||||||
message: m.odd_alive_swan_express(),
|
message: m.odd_alive_swan_express(),
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
<template>
|
<template>
|
||||||
<ContentWarning v-if="(sensitive || contentWarning) && showCw.value" :content-warning="contentWarning" v-model="blurred" />
|
<ContentWarning v-if="(sensitive || contentWarning) && preferences.show_content_warning" :content-warning="contentWarning" v-model="blurred" />
|
||||||
|
|
||||||
<OverflowGuard :character-count="characterCount" :class="(blurred && showCw.value) && 'blur-md'">
|
<OverflowGuard :character-count="characterCount" :class="(blurred && preferences.show_content_warning) && 'blur-md'">
|
||||||
<Prose v-html="content" v-render-emojis="emojis"></Prose>
|
<Prose v-html="content" v-render-emojis="emojis"></Prose>
|
||||||
</OverflowGuard>
|
</OverflowGuard>
|
||||||
|
|
||||||
<Attachments v-if="attachments.length > 0" :attachments="attachments" :class="(blurred && showCw.value) && 'blur-xl'" />
|
<Attachments v-if="attachments.length > 0" :attachments="attachments" :class="(blurred && preferences.show_content_warning) && 'blur-xl'" />
|
||||||
|
|
||||||
<div v-if="quote" class="mt-4 rounded border overflow-hidden">
|
<div v-if="quote" class="mt-4 rounded border overflow-hidden">
|
||||||
<Note :note="quote" :hide-actions="true" :small-layout="true" />
|
<Note :note="quote" :hide-actions="true" :small-layout="true" />
|
||||||
|
|
@ -14,7 +14,6 @@
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { Attachment, Emoji, Status } from "@versia/client/types";
|
import type { Attachment, Emoji, Status } from "@versia/client/types";
|
||||||
import { type BooleanSetting, SettingIds } from "~/settings";
|
|
||||||
import Attachments from "./attachments.vue";
|
import Attachments from "./attachments.vue";
|
||||||
import ContentWarning from "./content-warning.vue";
|
import ContentWarning from "./content-warning.vue";
|
||||||
import Note from "./note.vue";
|
import Note from "./note.vue";
|
||||||
|
|
@ -32,7 +31,6 @@ const { content, plainContent, sensitive, contentWarning } = defineProps<{
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const blurred = ref(sensitive || !!contentWarning);
|
const blurred = ref(sensitive || !!contentWarning);
|
||||||
const showCw = useSetting(SettingIds.ShowContentWarning) as Ref<BooleanSetting>;
|
|
||||||
|
|
||||||
const characterCount = plainContent?.length;
|
const characterCount = plainContent?.length;
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="rounded flex flex-row items-center gap-3">
|
<div class="rounded flex flex-row items-center gap-3">
|
||||||
<HoverCard v-model:open="popupOpen" @update:open="() => {
|
<HoverCard v-model:open="popupOpen" @update:open="() => {
|
||||||
if (!enableHoverCard.value) {
|
if (!preferences.popup_avatar_hover) {
|
||||||
popupOpen = false;
|
popupOpen = false;
|
||||||
}
|
}
|
||||||
}" :open-delay="2000">
|
}" :open-delay="2000">
|
||||||
|
|
@ -51,7 +51,6 @@ import type {
|
||||||
} from "@vueuse/core";
|
} from "@vueuse/core";
|
||||||
import { AtSign, Globe, Lock, LockOpen } from "lucide-vue-next";
|
import { AtSign, Globe, Lock, LockOpen } from "lucide-vue-next";
|
||||||
import { getLocale } from "~/paraglide/runtime";
|
import { getLocale } from "~/paraglide/runtime";
|
||||||
import { SettingIds } from "~/settings";
|
|
||||||
import Avatar from "../profiles/avatar.vue";
|
import Avatar from "../profiles/avatar.vue";
|
||||||
import SmallCard from "../profiles/small-card.vue";
|
import SmallCard from "../profiles/small-card.vue";
|
||||||
import {
|
import {
|
||||||
|
|
@ -59,7 +58,6 @@ import {
|
||||||
HoverCardContent,
|
HoverCardContent,
|
||||||
HoverCardTrigger,
|
HoverCardTrigger,
|
||||||
} from "../ui/hover-card";
|
} from "../ui/hover-card";
|
||||||
import CopyableText from "./copyable-text.vue";
|
|
||||||
|
|
||||||
const { createdAt, noteUrl, author, authorUrl } = defineProps<{
|
const { createdAt, noteUrl, author, authorUrl } = defineProps<{
|
||||||
cornerAvatar?: string;
|
cornerAvatar?: string;
|
||||||
|
|
@ -94,7 +92,6 @@ const fullTime = new Intl.DateTimeFormat(getLocale(), {
|
||||||
dateStyle: "medium",
|
dateStyle: "medium",
|
||||||
timeStyle: "short",
|
timeStyle: "short",
|
||||||
}).format(createdAt);
|
}).format(createdAt);
|
||||||
const enableHoverCard = useSetting(SettingIds.PopupAvatarHover);
|
|
||||||
const popupOpen = ref(false);
|
const popupOpen = ref(false);
|
||||||
|
|
||||||
const visibilities = {
|
const visibilities = {
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@ import {
|
||||||
import { toast } from "vue-sonner";
|
import { toast } from "vue-sonner";
|
||||||
import { confirmModalService } from "~/components/modals/composable.ts";
|
import { confirmModalService } from "~/components/modals/composable.ts";
|
||||||
import * as m from "~/paraglide/messages.js";
|
import * as m from "~/paraglide/messages.js";
|
||||||
import { SettingIds } from "~/settings";
|
|
||||||
|
|
||||||
const { authorId, noteId } = defineProps<{
|
const { authorId, noteId } = defineProps<{
|
||||||
apiNoteString: string;
|
apiNoteString: string;
|
||||||
|
|
@ -41,8 +40,6 @@ const { copy } = useClipboard();
|
||||||
const loggedIn = !!identity.value;
|
const loggedIn = !!identity.value;
|
||||||
const authorIsMe = loggedIn && authorId === identity.value?.account.id;
|
const authorIsMe = loggedIn && authorId === identity.value?.account.id;
|
||||||
|
|
||||||
const confirmDeletes = useSetting(SettingIds.ConfirmDelete);
|
|
||||||
|
|
||||||
const copyText = (text: string) => {
|
const copyText = (text: string) => {
|
||||||
copy(text);
|
copy(text);
|
||||||
toast.success(m.flat_nice_worm_dream());
|
toast.success(m.flat_nice_worm_dream());
|
||||||
|
|
@ -57,7 +54,7 @@ const blockUser = async (userId: string) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const _delete = async () => {
|
const _delete = async () => {
|
||||||
if (confirmDeletes.value.value) {
|
if (preferences.confirm_actions.value.includes("delete")) {
|
||||||
const confirmation = await confirmModalService.confirm({
|
const confirmation = await confirmModalService.confirm({
|
||||||
title: m.calm_icy_weasel_twirl(),
|
title: m.calm_icy_weasel_twirl(),
|
||||||
message: m.gray_fun_toucan_slide(),
|
message: m.gray_fun_toucan_slide(),
|
||||||
|
|
|
||||||
45
components/preferences/category.vue
Normal file
45
components/preferences/category.vue
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
<template>
|
||||||
|
<section class="space-y-2">
|
||||||
|
<CardTitle class="text-xs">
|
||||||
|
{{ name }}
|
||||||
|
</CardTitle>
|
||||||
|
<Card class="p-0 gap-0">
|
||||||
|
<div v-for="preference of preferences" :key="preference">
|
||||||
|
<TextPreferenceVue v-if="(prefs[preference] instanceof TextPreference)" :pref="(prefs[preference] as TextPreference)" :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";
|
||||||
|
// biome-ignore lint/style/useImportType: <explanation>
|
||||||
|
import { preferences as prefs } from "./preferences.ts";
|
||||||
|
import {
|
||||||
|
BooleanPreference,
|
||||||
|
CodePreference,
|
||||||
|
MultiSelectPreference,
|
||||||
|
NumberPreference,
|
||||||
|
SelectPreference,
|
||||||
|
TextPreference,
|
||||||
|
UrlPreference,
|
||||||
|
} from "./types.ts";
|
||||||
|
import BooleanPreferenceVue from "./types/boolean.vue";
|
||||||
|
import CodePreferenceVue from "./types/code.vue";
|
||||||
|
import MultiSelectPreferenceVue from "./types/multiselect.vue";
|
||||||
|
import NumberPreferenceVue from "./types/number.vue";
|
||||||
|
import SelectPreferenceVue from "./types/select.vue";
|
||||||
|
import TextPreferenceVue from "./types/text.vue";
|
||||||
|
import UrlPreferenceVue from "./types/url.vue";
|
||||||
|
|
||||||
|
const { preferences = [], name } = defineProps<{
|
||||||
|
preferences: (keyof typeof prefs)[];
|
||||||
|
name: string;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
@ -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>
|
|
||||||
60
components/preferences/developer.vue
Normal file
60
components/preferences/developer.vue
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
<template>
|
||||||
|
<Card class="grid gap-3 text-sm p-4">
|
||||||
|
<dl class="grid gap-3">
|
||||||
|
<div v-for="[key, value] of data" :key="key" class="flex flex-row items-baseline justify-between gap-4 truncate">
|
||||||
|
<dt class="text-muted-foreground">
|
||||||
|
{{ key }}
|
||||||
|
</dt>
|
||||||
|
<dd class="font-mono" v-if="typeof value === 'string'">{{ value }}</dd>
|
||||||
|
<dd class="font-mono" v-else>
|
||||||
|
<component :is="value" />
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</Card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="tsx" setup>
|
||||||
|
import type { VNode } from "vue";
|
||||||
|
import { 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>
|
||||||
166
components/preferences/dialog.vue
Normal file
166
components/preferences/dialog.vue
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
InfoIcon,
|
||||||
|
PaletteIcon,
|
||||||
|
SettingsIcon,
|
||||||
|
ShieldCheckIcon,
|
||||||
|
SmileIcon,
|
||||||
|
TerminalSquareIcon,
|
||||||
|
UserIcon,
|
||||||
|
} from "lucide-vue-next";
|
||||||
|
import pkg from "~/package.json";
|
||||||
|
import Avatar from "../profiles/avatar.vue";
|
||||||
|
import TinyCard from "../profiles/tiny-card.vue";
|
||||||
|
import { Separator } from "../ui/separator";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
|
||||||
|
import Category from "./category.vue";
|
||||||
|
import 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 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" />
|
||||||
|
</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>
|
||||||
72
components/preferences/emojis/batch-dropdown.vue
Normal file
72
components/preferences/emojis/batch-dropdown.vue
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
<template>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger as-child>
|
||||||
|
<slot />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent class="min-w-48">
|
||||||
|
<DropdownMenuItem @click="deleteAll" :disabled="!canEdit">
|
||||||
|
<Delete />
|
||||||
|
{{ m.tense_quick_cod_favor() }}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { type Emoji, RolePermission } from "@versia/client/types";
|
||||||
|
import { Delete } from "lucide-vue-next";
|
||||||
|
import { toast } from "vue-sonner";
|
||||||
|
import { confirmModalService } from "~/components/modals/composable";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "~/components/ui/dropdown-menu";
|
||||||
|
import * as m from "~/paraglide/messages.js";
|
||||||
|
|
||||||
|
const { emojis } = defineProps<{
|
||||||
|
emojis: Emoji[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const permissions = usePermissions();
|
||||||
|
const canEdit =
|
||||||
|
(!emojis.some((e) => e.global) &&
|
||||||
|
permissions.value.includes(RolePermission.ManageOwnEmojis)) ||
|
||||||
|
permissions.value.includes(RolePermission.ManageEmojis);
|
||||||
|
|
||||||
|
const deleteAll = async () => {
|
||||||
|
if (!identity.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { confirmed } = await confirmModalService.confirm({
|
||||||
|
title: m.tense_quick_cod_favor(),
|
||||||
|
message: m.next_hour_jurgen_sprout({
|
||||||
|
amount: emojis.length,
|
||||||
|
}),
|
||||||
|
confirmText: m.tense_quick_cod_favor(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (confirmed) {
|
||||||
|
const id = toast.loading(
|
||||||
|
m.equal_only_crow_file({
|
||||||
|
amount: emojis.length,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await Promise.all(
|
||||||
|
emojis.map((emoji) => client.value.deleteEmoji(emoji.id)),
|
||||||
|
);
|
||||||
|
toast.dismiss(id);
|
||||||
|
toast.success("Emojis deleted");
|
||||||
|
|
||||||
|
identity.value.emojis = identity.value.emojis.filter(
|
||||||
|
(e) => !emojis.some((emoji) => e.id === emoji.id),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
toast.dismiss(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -1,38 +1,10 @@
|
||||||
<template>
|
<template>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<Card
|
<DropdownMenuTrigger as-child>
|
||||||
:class="
|
<Button variant="ghost" size="icon" title="Open menu" class="size-8 p-0">
|
||||||
cn(
|
<MoreHorizontal class="size-4" />
|
||||||
'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>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
<DropdownMenuContent class="min-w-48">
|
<DropdownMenuContent class="min-w-48">
|
||||||
<DropdownMenuItem @click="editName">
|
<DropdownMenuItem @click="editName">
|
||||||
<TextCursorInput />
|
<TextCursorInput />
|
||||||
|
|
@ -52,20 +24,11 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { type Emoji, RolePermission } from "@versia/client/types";
|
import { type Emoji, RolePermission } from "@versia/client/types";
|
||||||
import { Delete, Ellipsis, TextCursorInput } from "lucide-vue-next";
|
import { Delete, MoreHorizontal, TextCursorInput } from "lucide-vue-next";
|
||||||
import { toast } from "vue-sonner";
|
import { toast } from "vue-sonner";
|
||||||
import { confirmModalService } from "~/components/modals/composable";
|
import { confirmModalService } from "~/components/modals/composable";
|
||||||
import { Avatar, AvatarImage } from "~/components/ui/avatar";
|
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardDescription,
|
|
||||||
CardFooter,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "~/components/ui/card";
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
51
components/preferences/emojis/index.vue
Normal file
51
components/preferences/emojis/index.vue
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
<template>
|
||||||
|
<div v-if="emojis.length > 0" class="grow">
|
||||||
|
<Table :emojis="emojis" :can-upload="canUpload" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { type Emoji, RolePermission } from "@versia/client/types";
|
||||||
|
import * as m from "~/paraglide/messages.js";
|
||||||
|
import Table from "./table.vue";
|
||||||
|
|
||||||
|
const permissions = usePermissions();
|
||||||
|
const canUpload = computed(
|
||||||
|
() =>
|
||||||
|
permissions.value.includes(RolePermission.ManageOwnEmojis) ||
|
||||||
|
permissions.value.includes(RolePermission.ManageEmojis),
|
||||||
|
);
|
||||||
|
|
||||||
|
const emojis = computed(
|
||||||
|
() =>
|
||||||
|
identity.value?.emojis?.filter((emoji) =>
|
||||||
|
emoji.shortcode.toLowerCase().includes(search.value.toLowerCase()),
|
||||||
|
) ?? [],
|
||||||
|
);
|
||||||
|
|
||||||
|
const search = ref("");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sort emojis by category
|
||||||
|
*/
|
||||||
|
const categories = computed(() => {
|
||||||
|
const categories = new Map<string, Emoji[]>();
|
||||||
|
for (const emoji of emojis.value) {
|
||||||
|
if (!emoji.category) {
|
||||||
|
if (!categories.has(m.lucky_ago_rat_pinch())) {
|
||||||
|
categories.set(m.lucky_ago_rat_pinch(), []);
|
||||||
|
}
|
||||||
|
|
||||||
|
categories.get(m.lucky_ago_rat_pinch())?.push(emoji);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!categories.has(emoji.category)) {
|
||||||
|
categories.set(emoji.category, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
categories.get(emoji.category)?.push(emoji);
|
||||||
|
}
|
||||||
|
return categories;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
361
components/preferences/emojis/table.vue
Normal file
361
components/preferences/emojis/table.vue
Normal file
|
|
@ -0,0 +1,361 @@
|
||||||
|
<script setup lang="tsx">
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import type {
|
||||||
|
ColumnDef,
|
||||||
|
ColumnFiltersState,
|
||||||
|
ExpandedState,
|
||||||
|
SortingState,
|
||||||
|
Updater,
|
||||||
|
VisibilityState,
|
||||||
|
} from "@tanstack/vue-table";
|
||||||
|
import {
|
||||||
|
FlexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
getExpandedRowModel,
|
||||||
|
getFilteredRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
useVueTable,
|
||||||
|
} from "@tanstack/vue-table";
|
||||||
|
import type { Emoji } from "@versia/client/types";
|
||||||
|
import {
|
||||||
|
ArrowDownAZ,
|
||||||
|
ArrowUpAz,
|
||||||
|
ArrowUpDown,
|
||||||
|
ChevronDown,
|
||||||
|
Ellipsis,
|
||||||
|
Globe,
|
||||||
|
Home,
|
||||||
|
Plus,
|
||||||
|
} from "lucide-vue-next";
|
||||||
|
import { ref } from "vue";
|
||||||
|
import BatchDropdown from "./batch-dropdown.vue";
|
||||||
|
import Dropdown from "./dropdown.vue";
|
||||||
|
import Uploader from "./uploader.vue";
|
||||||
|
|
||||||
|
// No destructuring props to avoid reactivity issues
|
||||||
|
const props = defineProps<{
|
||||||
|
emojis: Emoji[];
|
||||||
|
canUpload: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emojisRef = computed(() => props.emojis);
|
||||||
|
|
||||||
|
const valueUpdater = <T extends Updater<any>>(updaterOrValue: T, ref: Ref) => {
|
||||||
|
ref.value =
|
||||||
|
typeof updaterOrValue === "function"
|
||||||
|
? updaterOrValue(ref.value)
|
||||||
|
: updaterOrValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: ColumnDef<Emoji>[] = [
|
||||||
|
{
|
||||||
|
id: "select",
|
||||||
|
header: ({ table }) => (
|
||||||
|
<Checkbox
|
||||||
|
modelValue={
|
||||||
|
table.getIsAllPageRowsSelected() ||
|
||||||
|
(table.getIsSomePageRowsSelected() && "indeterminate")
|
||||||
|
}
|
||||||
|
onUpdate:modelValue={(value) =>
|
||||||
|
table.toggleAllPageRowsSelected(!!value)
|
||||||
|
}
|
||||||
|
aria-label="Select all"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Checkbox
|
||||||
|
modelValue={row.getIsSelected()}
|
||||||
|
onUpdate:modelValue={(value) => row.toggleSelected(!!value)}
|
||||||
|
aria-label="Select row"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
enableHiding: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "url",
|
||||||
|
header: "Image",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<img
|
||||||
|
src={row.getValue("url")}
|
||||||
|
alt={`:${row.getValue("shortcode")}:`}
|
||||||
|
title={row.getValue("shortcode")}
|
||||||
|
class="h-[1lh] align-middle inline not-prose hover:scale-110 transition-transform duration-75 ease-in-out"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "shortcode",
|
||||||
|
header: ({ column }) => {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
class="!p-0 !h-auto"
|
||||||
|
// @ts-expect-error types don't include onClick
|
||||||
|
onClick={() =>
|
||||||
|
column.toggleSorting(column.getIsSorted() === "asc")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Shortcode
|
||||||
|
{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>
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
<DialogDescription class="sr-only">
|
<DialogDescription class="sr-only">
|
||||||
{{ m.frail_great_marten_pet() }}
|
{{ m.frail_great_marten_pet() }}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
<form class="p-4 grid gap-6" @submit="submit">
|
<form class="grid gap-6" @submit="submit">
|
||||||
<div
|
<div
|
||||||
v-if="values.image"
|
v-if="values.image"
|
||||||
class="flex items-center justify-around *:size-20 *:p-2 *:rounded *:border *:shadow"
|
class="flex items-center justify-around *:size-20 *:p-2 *:rounded *:border *:shadow"
|
||||||
|
|
@ -125,32 +125,18 @@
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
v-slot="{ componentField, value, handleChange }"
|
v-slot="{ value, handleChange }"
|
||||||
v-if="hasEmojiAdmin"
|
v-if="hasEmojiAdmin"
|
||||||
name="global"
|
name="global"
|
||||||
:as="Card"
|
as-child
|
||||||
>
|
>
|
||||||
<FormItem
|
<FormSwitch :title="m.pink_sharp_carp_work()" :description="m.dark_pretty_hyena_link()">
|
||||||
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
|
<Switch
|
||||||
:checked="value"
|
:model-value="value"
|
||||||
@update:checked="handleChange"
|
@update:model-value="handleChange"
|
||||||
v-bind="componentField"
|
|
||||||
:disabled="isSubmitting"
|
:disabled="isSubmitting"
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormSwitch>
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|
@ -178,6 +164,7 @@ import { RolePermission } from "@versia/client/types";
|
||||||
import { useForm } from "vee-validate";
|
import { useForm } from "vee-validate";
|
||||||
import { toast } from "vue-sonner";
|
import { toast } from "vue-sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import FormSwitch from "~/components/form/switch.vue";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
|
|
|
||||||
7
components/preferences/index.vue
Normal file
7
components/preferences/index.vue
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<template>
|
||||||
|
<Dialog />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import Dialog from "./dialog.vue";
|
||||||
|
</script>
|
||||||
15
components/preferences/page.vue
Normal file
15
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
components/preferences/preferences.ts
Normal file
150
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
components/preferences/profile.ts
Normal file
63
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_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() }),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
185
components/preferences/profile.vue
Normal file
185
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>
|
||||||
|
|
@ -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>
|
|
||||||
104
components/preferences/profile/fields.vue
Normal file
104
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>
|
||||||
|
|
@ -21,10 +21,10 @@
|
||||||
<DialogDescription class="sr-only">
|
<DialogDescription class="sr-only">
|
||||||
{{ m.suave_broad_albatross_drop() }}
|
{{ m.suave_broad_albatross_drop() }}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
<form class="p-4 grid gap-6" @submit="submit">
|
<form class="grid gap-6" @submit="submit">
|
||||||
<Tabs
|
<Tabs
|
||||||
default-value="upload"
|
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">
|
<TabsList class="w-full *:w-full">
|
||||||
<TabsTrigger value="upload">
|
<TabsTrigger value="upload">
|
||||||
|
|
@ -222,7 +222,7 @@ const emailToGravatar = async (email: string) => {
|
||||||
const open = ref(false);
|
const open = ref(false);
|
||||||
const gravatarUrl = ref<string | undefined>(undefined);
|
const gravatarUrl = ref<string | undefined>(undefined);
|
||||||
|
|
||||||
const { handleSubmit, isSubmitting, values } = useForm({
|
const { handleSubmit, isSubmitting } = useForm({
|
||||||
validationSchema: schema,
|
validationSchema: schema,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
37
components/preferences/stats.vue
Normal file
37
components/preferences/stats.vue
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
<template>
|
||||||
|
<Card class="grid gap-3 text-sm max-w-sm p-4">
|
||||||
|
<dl class="grid gap-3">
|
||||||
|
<div v-for="[key, value] of data" :key="key" class="flex flex-row items-baseline justify-between gap-4 truncate">
|
||||||
|
<dt class="text-muted-foreground">
|
||||||
|
{{ key }}
|
||||||
|
</dt>
|
||||||
|
<dd class="font-mono" v-if="typeof value === 'string'">{{ value }}</dd>
|
||||||
|
<dd class="font-mono" v-else>
|
||||||
|
<component :is="value" />
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</Card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="tsx" setup>
|
||||||
|
import type { VNode } from "vue";
|
||||||
|
import pkg from "~/package.json";
|
||||||
|
import { Card } from "../ui/card";
|
||||||
|
|
||||||
|
const data: [string, string | VNode][] = [
|
||||||
|
["Version", pkg.version],
|
||||||
|
["Licence", pkg.license],
|
||||||
|
["Author", pkg.author.name],
|
||||||
|
[
|
||||||
|
"Repository",
|
||||||
|
<a
|
||||||
|
href={pkg.repository.url.replace("git+", "")}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{pkg.repository.url.replace("git+", "").replace("https://", "")}
|
||||||
|
</a>,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
71
components/preferences/types.ts
Normal file
71
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
43
components/preferences/types/base.vue
Normal file
43
components/preferences/types/base.vue
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
<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
components/preferences/types/boolean.vue
Normal file
17
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
components/preferences/types/code.vue
Normal file
36
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
components/preferences/types/multiselect.vue
Normal file
41
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
components/preferences/types/number.vue
Normal file
29
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
components/preferences/types/select.vue
Normal file
35
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
components/preferences/types/text.vue
Normal file
17
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
components/preferences/types/url.vue
Normal file
36
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>
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<Avatar :class="[shape.value === 'square' && 'rounded-md', 'bg-secondary']">
|
<Avatar :class="['rounded-md bg-secondary']">
|
||||||
<AvatarFallback v-if="name">
|
<AvatarFallback v-if="name">
|
||||||
{{ getInitials(name) }}
|
{{ getInitials(name) }}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
|
|
@ -8,7 +8,6 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { SettingIds } from "~/settings";
|
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
|
||||||
|
|
||||||
const { name } = defineProps<{
|
const { name } = defineProps<{
|
||||||
|
|
@ -29,6 +28,4 @@ const getInitials = (name: string): string => {
|
||||||
|
|
||||||
return `${firstLetter}${secondLetter}`.toUpperCase();
|
return `${firstLetter}${secondLetter}`.toUpperCase();
|
||||||
};
|
};
|
||||||
|
|
||||||
const shape = useSetting(SettingIds.AvatarShape);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,6 @@ import { Button } from "~/components/ui/button";
|
||||||
import { Card, CardContent, CardFooter, CardTitle } from "~/components/ui/card";
|
import { Card, CardContent, CardFooter, CardTitle } from "~/components/ui/card";
|
||||||
import { Separator } from "~/components/ui/separator";
|
import { Separator } from "~/components/ui/separator";
|
||||||
import * as m from "~/paraglide/messages.js";
|
import * as m from "~/paraglide/messages.js";
|
||||||
import { SettingIds } from "~/settings";
|
|
||||||
import { confirmModalService } from "../modals/composable";
|
import { confirmModalService } from "../modals/composable";
|
||||||
import ProfileActions from "./profile-actions.vue";
|
import ProfileActions from "./profile-actions.vue";
|
||||||
import ProfileBadges from "./profile-badges.vue";
|
import ProfileBadges from "./profile-badges.vue";
|
||||||
|
|
@ -91,10 +90,8 @@ const { relationship, isLoading } = useRelationship(client, account.id);
|
||||||
const isMe = identity.value?.account.id === account.id;
|
const isMe = identity.value?.account.id === account.id;
|
||||||
const [username, instance] = account.acct.split("@");
|
const [username, instance] = account.acct.split("@");
|
||||||
|
|
||||||
const confirmFollows = useSetting(SettingIds.ConfirmFollow);
|
|
||||||
|
|
||||||
const follow = async () => {
|
const follow = async () => {
|
||||||
if (confirmFollows.value.value) {
|
if (preferences.confirm_actions.value.includes("follow")) {
|
||||||
const confirmation = await confirmModalService.confirm({
|
const confirmation = await confirmModalService.confirm({
|
||||||
title: m.many_fair_capybara_imagine(),
|
title: m.many_fair_capybara_imagine(),
|
||||||
message: m.mellow_yummy_jannes_cuddle({
|
message: m.mellow_yummy_jannes_cuddle({
|
||||||
|
|
@ -118,7 +115,7 @@ const follow = async () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const unfollow = async () => {
|
const unfollow = async () => {
|
||||||
if (confirmFollows.value.value) {
|
if (preferences.confirm_actions.value.includes("follow")) {
|
||||||
const confirmation = await confirmModalService.confirm({
|
const confirmation = await confirmModalService.confirm({
|
||||||
title: m.funny_aloof_swan_loop(),
|
title: m.funny_aloof_swan_loop(),
|
||||||
message: m.white_best_dolphin_catch({
|
message: m.white_best_dolphin_catch({
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<template>
|
<template>
|
||||||
<Card
|
<Card
|
||||||
class="flex-row gap-2 p-2 truncate items-center"
|
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" />
|
<Avatar :src="account.avatar" :name="account.display_name" class="size-10" />
|
||||||
<CardContent class="leading-tight">
|
<CardContent class="leading-tight">
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,11 @@
|
||||||
<script setup lang="ts">
|
<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 TinyCard from "~/components/profiles/tiny-card.vue";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import {
|
import {
|
||||||
|
|
@ -37,6 +43,10 @@ const { $pwa } = useNuxtApp();
|
||||||
{{ m.salty_aloof_turkey_nudge() }}
|
{{ m.salty_aloof_turkey_nudge() }}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</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"
|
<Button v-if="$pwa?.needRefresh" variant="destructive" size="lg"
|
||||||
class="w-full group-data-[collapsible=icon]:px-4" @click="$pwa?.updateServiceWorker(true)">
|
class="w-full group-data-[collapsible=icon]:px-4" @click="$pwa?.updateServiceWorker(true)">
|
||||||
<DownloadCloud />
|
<DownloadCloud />
|
||||||
|
|
|
||||||
|
|
@ -14,12 +14,6 @@
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
<SidebarGroup v-if="identity" class="mt-auto">
|
|
||||||
<SidebarGroupLabel>{{
|
|
||||||
m.close_short_kitten_coax()
|
|
||||||
}}</SidebarGroupLabel>
|
|
||||||
<NavGroup :items="sidebarConfig.navMain" />
|
|
||||||
</SidebarGroup>
|
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
<FooterActions />
|
<FooterActions />
|
||||||
<SidebarRail />
|
<SidebarRail />
|
||||||
|
|
@ -38,6 +32,5 @@ import {
|
||||||
import * as m from "~/paraglide/messages.js";
|
import * as m from "~/paraglide/messages.js";
|
||||||
import FooterActions from "./footer/footer-actions.vue";
|
import FooterActions from "./footer/footer-actions.vue";
|
||||||
import InstanceHeader from "./instance/instance-header.vue";
|
import InstanceHeader from "./instance/instance-header.vue";
|
||||||
import NavGroup from "./navigation/nav-group.vue";
|
|
||||||
import NavItems from "./navigation/nav-items.vue";
|
import NavItems from "./navigation/nav-items.vue";
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,44 +1,9 @@
|
||||||
import {
|
import { BedSingle, Bell, Globe, House, MapIcon } from "lucide-vue-next";
|
||||||
BedSingle,
|
|
||||||
Bell,
|
|
||||||
Globe,
|
|
||||||
House,
|
|
||||||
MapIcon,
|
|
||||||
Settings2,
|
|
||||||
} from "lucide-vue-next";
|
|
||||||
import * as m from "~/paraglide/messages.js";
|
import * as m from "~/paraglide/messages.js";
|
||||||
import type { SidebarConfig } from "~/types/sidebar";
|
import type { SidebarConfig } from "~/types/sidebar";
|
||||||
|
|
||||||
export const sidebarConfig: SidebarConfig = {
|
export const sidebarConfig: SidebarConfig = {
|
||||||
navMain: [
|
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",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
other: [
|
other: [
|
||||||
{
|
{
|
||||||
title: m.bland_chunky_sparrow_propel(),
|
title: m.bland_chunky_sparrow_propel(),
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,8 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import Timelines from "~/components/navigation/timelines.vue";
|
import Timelines from "~/components/navigation/timelines.vue";
|
||||||
import { SidebarInset } from "~/components/ui/sidebar";
|
|
||||||
import { SettingIds } from "~/settings";
|
|
||||||
import LeftSidebar from "./left-sidebar.vue";
|
import LeftSidebar from "./left-sidebar.vue";
|
||||||
import RightSidebar from "./right-sidebar.vue";
|
import RightSidebar from "./right-sidebar.vue";
|
||||||
|
|
||||||
const showRightSidebar = useSetting(SettingIds.NotificationsSidebar);
|
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const isMd = useMediaQuery("(max-width: 768px)");
|
const isMd = useMediaQuery("(max-width: 768px)");
|
||||||
const showTimelines = computed(
|
const showTimelines = computed(
|
||||||
|
|
@ -28,5 +23,5 @@ const showTimelines = computed(
|
||||||
</header>
|
</header>
|
||||||
<slot />
|
<slot />
|
||||||
</main>
|
</main>
|
||||||
<RightSidebar v-if="identity" v-show="showRightSidebar.value" />
|
<RightSidebar v-if="identity" v-show="preferences.display_notifications_sidebar" />
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@
|
||||||
<!-- If there are no posts at all -->
|
<!-- If there are no posts at all -->
|
||||||
<NoPosts v-else-if="hasReachedEnd && items.length === 0" />
|
<NoPosts v-else-if="hasReachedEnd && items.length === 0" />
|
||||||
|
|
||||||
<div v-else-if="!infiniteScroll.value" class="py-10 px-4">
|
<div v-else-if="!preferences.infinite_scroll" class="py-10 px-4">
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@click="loadNext"
|
@click="loadNext"
|
||||||
|
|
@ -43,7 +43,6 @@
|
||||||
import type { Notification, Status } from "@versia/client/types";
|
import type { Notification, Status } from "@versia/client/types";
|
||||||
import { useIntersectionObserver } from "@vueuse/core";
|
import { useIntersectionObserver } from "@vueuse/core";
|
||||||
import * as m from "~/paraglide/messages.js";
|
import * as m from "~/paraglide/messages.js";
|
||||||
import { SettingIds } from "~/settings";
|
|
||||||
import NoPosts from "../errors/NoPosts.vue";
|
import NoPosts from "../errors/NoPosts.vue";
|
||||||
import ReachedEnd from "../errors/ReachedEnd.vue";
|
import ReachedEnd from "../errors/ReachedEnd.vue";
|
||||||
import Spinner from "../graphics/spinner.vue";
|
import Spinner from "../graphics/spinner.vue";
|
||||||
|
|
@ -66,14 +65,13 @@ const emit = defineEmits<(e: "update") => void>();
|
||||||
|
|
||||||
const loadMoreTrigger = ref<HTMLElement | null>(null);
|
const loadMoreTrigger = ref<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
// @ts-expect-error Too complex?
|
||||||
useIntersectionObserver(loadMoreTrigger, ([observer]) => {
|
useIntersectionObserver(loadMoreTrigger, ([observer]) => {
|
||||||
if (observer?.isIntersecting && !props.isLoading && !props.hasReachedEnd) {
|
if (observer?.isIntersecting && !props.isLoading && !props.hasReachedEnd) {
|
||||||
props.loadNext();
|
props.loadNext();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const infiniteScroll = useSetting(SettingIds.InfiniteScroll);
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.items,
|
() => props.items,
|
||||||
() => {
|
() => {
|
||||||
|
|
|
||||||
|
|
@ -35,5 +35,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||||
<Check class="size-3.5" />
|
<Check class="size-3.5" />
|
||||||
</slot>
|
</slot>
|
||||||
</CheckboxIndicator>
|
</CheckboxIndicator>
|
||||||
|
<!-- Fixes an issue where empty buttons behave weirdly in tanstack table layouts -->
|
||||||
|
<Check class="size-3.5 opacity-0" />
|
||||||
</CheckboxRoot>
|
</CheckboxRoot>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||||
v-bind="forwarded"
|
v-bind="forwarded"
|
||||||
:class="
|
:class="
|
||||||
cn(
|
cn(
|
||||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
|
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] max-h-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
|
||||||
props.class,
|
props.class,
|
||||||
)"
|
)"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
25
components/ui/number-field/NumberField.vue
Normal file
25
components/ui/number-field/NumberField.vue
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { NumberFieldRootEmits, NumberFieldRootProps } from "reka-ui";
|
||||||
|
import { NumberFieldRoot, useForwardPropsEmits } from "reka-ui";
|
||||||
|
import { type HTMLAttributes, computed } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps<
|
||||||
|
NumberFieldRootProps & { class?: HTMLAttributes["class"] }
|
||||||
|
>();
|
||||||
|
const emits = defineEmits<NumberFieldRootEmits>();
|
||||||
|
|
||||||
|
const delegatedProps = computed(() => {
|
||||||
|
const { class: _, ...delegated } = props;
|
||||||
|
|
||||||
|
return delegated;
|
||||||
|
});
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NumberFieldRoot v-bind="forwarded" :class="cn('grid gap-1.5', props.class)">
|
||||||
|
<slot />
|
||||||
|
</NumberFieldRoot>
|
||||||
|
</template>
|
||||||
14
components/ui/number-field/NumberFieldContent.vue
Normal file
14
components/ui/number-field/NumberFieldContent.vue
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { HTMLAttributes } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="cn('relative [&>[data-slot=input]]:has-[[data-slot=increment]]:pr-5 [&>[data-slot=input]]:has-[[data-slot=decrement]]:pl-5', props.class)">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
27
components/ui/number-field/NumberFieldDecrement.vue
Normal file
27
components/ui/number-field/NumberFieldDecrement.vue
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Minus } from "lucide-vue-next";
|
||||||
|
import type { NumberFieldDecrementProps } from "reka-ui";
|
||||||
|
import { NumberFieldDecrement, useForwardProps } from "reka-ui";
|
||||||
|
import { type HTMLAttributes, computed } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps<
|
||||||
|
NumberFieldDecrementProps & { class?: HTMLAttributes["class"] }
|
||||||
|
>();
|
||||||
|
|
||||||
|
const delegatedProps = computed(() => {
|
||||||
|
const { class: _, ...delegated } = props;
|
||||||
|
|
||||||
|
return delegated;
|
||||||
|
});
|
||||||
|
|
||||||
|
const forwarded = useForwardProps(delegatedProps);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NumberFieldDecrement data-slot="decrement" v-bind="forwarded" :class="cn('absolute top-1/2 -translate-y-1/2 left-0 p-3 disabled:cursor-not-allowed disabled:opacity-20', props.class)">
|
||||||
|
<slot>
|
||||||
|
<Minus class="h-4 w-4" />
|
||||||
|
</slot>
|
||||||
|
</NumberFieldDecrement>
|
||||||
|
</template>
|
||||||
27
components/ui/number-field/NumberFieldIncrement.vue
Normal file
27
components/ui/number-field/NumberFieldIncrement.vue
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Plus } from "lucide-vue-next";
|
||||||
|
import type { NumberFieldIncrementProps } from "reka-ui";
|
||||||
|
import { NumberFieldIncrement, useForwardProps } from "reka-ui";
|
||||||
|
import { type HTMLAttributes, computed } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps<
|
||||||
|
NumberFieldIncrementProps & { class?: HTMLAttributes["class"] }
|
||||||
|
>();
|
||||||
|
|
||||||
|
const delegatedProps = computed(() => {
|
||||||
|
const { class: _, ...delegated } = props;
|
||||||
|
|
||||||
|
return delegated;
|
||||||
|
});
|
||||||
|
|
||||||
|
const forwarded = useForwardProps(delegatedProps);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NumberFieldIncrement data-slot="increment" v-bind="forwarded" :class="cn('absolute top-1/2 -translate-y-1/2 right-0 disabled:cursor-not-allowed disabled:opacity-20 p-3', props.class)">
|
||||||
|
<slot>
|
||||||
|
<Plus class="h-4 w-4" />
|
||||||
|
</slot>
|
||||||
|
</NumberFieldIncrement>
|
||||||
|
</template>
|
||||||
16
components/ui/number-field/NumberFieldInput.vue
Normal file
16
components/ui/number-field/NumberFieldInput.vue
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { NumberFieldInput } from "reka-ui";
|
||||||
|
import type { HTMLAttributes } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NumberFieldInput
|
||||||
|
data-slot="input"
|
||||||
|
:class="cn('flex h-9 w-full rounded-md border border-input bg-transparent py-1 text-sm text-center shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50', props.class)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
5
components/ui/number-field/index.ts
Normal file
5
components/ui/number-field/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
export { default as NumberField } from "./NumberField.vue";
|
||||||
|
export { default as NumberFieldContent } from "./NumberFieldContent.vue";
|
||||||
|
export { default as NumberFieldDecrement } from "./NumberFieldDecrement.vue";
|
||||||
|
export { default as NumberFieldIncrement } from "./NumberFieldIncrement.vue";
|
||||||
|
export { default as NumberFieldInput } from "./NumberFieldInput.vue";
|
||||||
|
|
@ -29,7 +29,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||||
data-slot="switch"
|
data-slot="switch"
|
||||||
v-bind="forwarded"
|
v-bind="forwarded"
|
||||||
:class="cn(
|
:class="cn(
|
||||||
'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] hover:cursor-pointer disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
props.class,
|
props.class,
|
||||||
)"
|
)"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
16
components/ui/table/Table.vue
Normal file
16
components/ui/table/Table.vue
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { HTMLAttributes } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div data-slot="table-container" class="relative w-full overflow-auto">
|
||||||
|
<table data-slot="table" :class="cn('w-full caption-bottom text-sm', props.class)">
|
||||||
|
<slot />
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
17
components/ui/table/TableBody.vue
Normal file
17
components/ui/table/TableBody.vue
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { HTMLAttributes } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<tbody
|
||||||
|
data-slot="table-body"
|
||||||
|
:class="cn('[&_tr:last-child]:border-0', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</tbody>
|
||||||
|
</template>
|
||||||
17
components/ui/table/TableCaption.vue
Normal file
17
components/ui/table/TableCaption.vue
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { HTMLAttributes } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<caption
|
||||||
|
data-slot="table-caption"
|
||||||
|
:class="cn('text-muted-foreground mt-4 text-sm', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</caption>
|
||||||
|
</template>
|
||||||
22
components/ui/table/TableCell.vue
Normal file
22
components/ui/table/TableCell.vue
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { HTMLAttributes } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<td
|
||||||
|
data-slot="table-cell"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
40
components/ui/table/TableEmpty.vue
Normal file
40
components/ui/table/TableEmpty.vue
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { type HTMLAttributes, computed } from "vue";
|
||||||
|
import TableCell from "./TableCell.vue";
|
||||||
|
import TableRow from "./TableRow.vue";
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
class?: HTMLAttributes["class"];
|
||||||
|
colspan?: number;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
colspan: 1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const delegatedProps = computed(() => {
|
||||||
|
const { class: _, ...delegated } = props;
|
||||||
|
|
||||||
|
return delegated;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'p-4 whitespace-nowrap align-middle text-sm text-foreground',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-center py-10">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</template>
|
||||||
17
components/ui/table/TableFooter.vue
Normal file
17
components/ui/table/TableFooter.vue
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { HTMLAttributes } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<tfoot
|
||||||
|
data-slot="table-footer"
|
||||||
|
:class="cn('bg-muted/50 border-t font-medium [&>tr]:last:border-b-0', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</tfoot>
|
||||||
|
</template>
|
||||||
17
components/ui/table/TableHead.vue
Normal file
17
components/ui/table/TableHead.vue
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { HTMLAttributes } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<th
|
||||||
|
data-slot="table-head"
|
||||||
|
:class="cn('text-muted-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</th>
|
||||||
|
</template>
|
||||||
17
components/ui/table/TableHeader.vue
Normal file
17
components/ui/table/TableHeader.vue
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { HTMLAttributes } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<thead
|
||||||
|
data-slot="table-header"
|
||||||
|
:class="cn('[&_tr]:border-b', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</thead>
|
||||||
|
</template>
|
||||||
17
components/ui/table/TableRow.vue
Normal file
17
components/ui/table/TableRow.vue
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { HTMLAttributes } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<tr
|
||||||
|
data-slot="table-row"
|
||||||
|
:class="cn('hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
9
components/ui/table/index.ts
Normal file
9
components/ui/table/index.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
export { default as Table } from "./Table.vue";
|
||||||
|
export { default as TableBody } from "./TableBody.vue";
|
||||||
|
export { default as TableCaption } from "./TableCaption.vue";
|
||||||
|
export { default as TableCell } from "./TableCell.vue";
|
||||||
|
export { default as TableEmpty } from "./TableEmpty.vue";
|
||||||
|
export { default as TableFooter } from "./TableFooter.vue";
|
||||||
|
export { default as TableHead } from "./TableHead.vue";
|
||||||
|
export { default as TableHeader } from "./TableHeader.vue";
|
||||||
|
export { default as TableRow } from "./TableRow.vue";
|
||||||
12
components/ui/table/utils.ts
Normal file
12
components/ui/table/utils.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import type { Updater } from "@tanstack/vue-table";
|
||||||
|
import type { Ref } from "vue";
|
||||||
|
|
||||||
|
export function valueUpdater<T extends Updater<any>>(
|
||||||
|
updaterOrValue: T,
|
||||||
|
ref: Ref,
|
||||||
|
) {
|
||||||
|
ref.value =
|
||||||
|
typeof updaterOrValue === "function"
|
||||||
|
? updaterOrValue(ref.value)
|
||||||
|
: updaterOrValue;
|
||||||
|
}
|
||||||
|
|
@ -19,7 +19,7 @@ const delegatedProps = computed(() => {
|
||||||
data-slot="tabs-list"
|
data-slot="tabs-list"
|
||||||
v-bind="delegatedProps"
|
v-bind="delegatedProps"
|
||||||
:class="cn(
|
:class="cn(
|
||||||
'bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]',
|
'bg-muted text-muted-foreground inline-flex w-fit items-center justify-center rounded-lg p-1 overflow-x-auto',
|
||||||
props.class,
|
props.class,
|
||||||
)"
|
)"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ const forwardedProps = useForwardProps(delegatedProps);
|
||||||
data-slot="tabs-trigger"
|
data-slot="tabs-trigger"
|
||||||
v-bind="forwardedProps"
|
v-bind="forwardedProps"
|
||||||
:class="cn(
|
:class="cn(
|
||||||
`data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4`,
|
`data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4`,
|
||||||
props.class,
|
props.class,
|
||||||
)"
|
)"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,7 @@ export const useConfig = () => {
|
||||||
"jessew@social.lysand.org",
|
"jessew@social.lysand.org",
|
||||||
"jessew@beta.versia.social",
|
"jessew@beta.versia.social",
|
||||||
"jessew@versia.social",
|
"jessew@versia.social",
|
||||||
|
"jessew@vs.cpluspatch.com",
|
||||||
"aprl@social.lysand.org",
|
"aprl@social.lysand.org",
|
||||||
"aprl@beta.versia.social",
|
"aprl@beta.versia.social",
|
||||||
"aprl@versia.social",
|
"aprl@versia.social",
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ type ApplicationEvents = {
|
||||||
"account:update": Account;
|
"account:update": Account;
|
||||||
"attachment:view": Attachment;
|
"attachment:view": Attachment;
|
||||||
"identity:change": Identity;
|
"identity:change": Identity;
|
||||||
|
"preferences:open": undefined;
|
||||||
error: {
|
error: {
|
||||||
code: string;
|
code: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,3 @@
|
||||||
import { SettingIds } from "~/settings";
|
|
||||||
|
|
||||||
export const useLanguage = () => {
|
export const useLanguage = () => {
|
||||||
const lang = useSetting(SettingIds.Language);
|
return computed(() => preferences.language.value);
|
||||||
|
|
||||||
return computed(() => lang.value.value as "en" | "fr" | "en-pt");
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
56
composables/Preference.ts
Normal file
56
composables/Preference.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { StorageSerializers } from "@vueuse/core";
|
||||||
|
import { preferences as prefs } from "~/components/preferences/preferences";
|
||||||
|
|
||||||
|
type SerializedPreferences = {
|
||||||
|
[K in keyof typeof prefs]: (typeof prefs)[K]["options"]["defaultValue"];
|
||||||
|
};
|
||||||
|
|
||||||
|
const usePreferences = (): {
|
||||||
|
[K in keyof typeof prefs]: WritableComputedRef<
|
||||||
|
(typeof prefs)[K]["options"]["defaultValue"]
|
||||||
|
>;
|
||||||
|
} => {
|
||||||
|
const localStorage = useLocalStorage<SerializedPreferences>(
|
||||||
|
"versia:preferences",
|
||||||
|
Object.fromEntries(
|
||||||
|
Object.entries(prefs).map(([key, value]) => [
|
||||||
|
key,
|
||||||
|
value.options.defaultValue,
|
||||||
|
]),
|
||||||
|
) as SerializedPreferences,
|
||||||
|
{
|
||||||
|
serializer: {
|
||||||
|
read(raw) {
|
||||||
|
return StorageSerializers.object.read(raw);
|
||||||
|
},
|
||||||
|
write(value) {
|
||||||
|
return StorageSerializers.object.write(value);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(prefs).map(([key, value]) => [
|
||||||
|
key,
|
||||||
|
computed({
|
||||||
|
get() {
|
||||||
|
return (
|
||||||
|
localStorage.value[key as keyof typeof prefs] ??
|
||||||
|
value.options.defaultValue
|
||||||
|
);
|
||||||
|
},
|
||||||
|
set(newValue) {
|
||||||
|
// @ts-expect-error Key is marked as readonly in the type
|
||||||
|
localStorage.value[key] = newValue;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
) as {
|
||||||
|
[K in keyof typeof prefs]: WritableComputedRef<
|
||||||
|
(typeof prefs)[K]["options"]["defaultValue"]
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const preferences = usePreferences();
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
import { StorageSerializers } from "@vueuse/core";
|
|
||||||
import {
|
|
||||||
type SettingIds,
|
|
||||||
type Settings,
|
|
||||||
mergeSettings,
|
|
||||||
settings as settingsJson,
|
|
||||||
} from "~/settings";
|
|
||||||
|
|
||||||
const useSettings = () => {
|
|
||||||
return useLocalStorage<Settings>("versia:settings", settingsJson(), {
|
|
||||||
serializer: {
|
|
||||||
read(raw) {
|
|
||||||
const json = StorageSerializers.object.read(raw);
|
|
||||||
|
|
||||||
return mergeSettings(json);
|
|
||||||
},
|
|
||||||
write(value) {
|
|
||||||
const json = Object.fromEntries(
|
|
||||||
Object.entries(value).map(([key, value]) => [
|
|
||||||
key,
|
|
||||||
value.value,
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
|
|
||||||
// flatMap object values to .value
|
|
||||||
return StorageSerializers.object.write(json);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const settings = useSettings();
|
|
||||||
|
|
||||||
export const useSetting = <Id extends SettingIds>(
|
|
||||||
id: Id,
|
|
||||||
): Ref<Settings[Id]> => {
|
|
||||||
const setting = ref(settings.value[id]) as Ref<Settings[Id]>;
|
|
||||||
|
|
||||||
watch(settings, (newSettings) => {
|
|
||||||
setting.value = newSettings[id];
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(setting, (newSetting) => {
|
|
||||||
settings.value = {
|
|
||||||
...settings.value,
|
|
||||||
[id]: newSetting,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return setting;
|
|
||||||
};
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
</AppSidebar>
|
</AppSidebar>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
<MobileNavbar v-if="identity" />
|
<MobileNavbar v-if="identity" />
|
||||||
|
<Preferences />
|
||||||
<ComposerDialog />
|
<ComposerDialog />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -15,12 +16,11 @@
|
||||||
import ComposerDialog from "~/components/composer/dialog.vue";
|
import ComposerDialog from "~/components/composer/dialog.vue";
|
||||||
import AuthRequired from "~/components/errors/AuthRequired.vue";
|
import AuthRequired from "~/components/errors/AuthRequired.vue";
|
||||||
import MobileNavbar from "~/components/navigation/mobile-navbar.vue";
|
import MobileNavbar from "~/components/navigation/mobile-navbar.vue";
|
||||||
|
import Preferences from "~/components/preferences/index.vue";
|
||||||
import AppSidebar from "~/components/sidebars/sidebar.vue";
|
import AppSidebar from "~/components/sidebars/sidebar.vue";
|
||||||
import { SidebarProvider } from "~/components/ui/sidebar";
|
import { SidebarProvider } from "~/components/ui/sidebar";
|
||||||
import { SettingIds } from "~/settings";
|
|
||||||
|
|
||||||
const colorMode = useColorMode();
|
const colorMode = useColorMode();
|
||||||
const themeSetting = useSetting(SettingIds.Theme);
|
|
||||||
const { n, d } = useMagicKeys();
|
const { n, d } = useMagicKeys();
|
||||||
const activeElement = useActiveElement();
|
const activeElement = useActiveElement();
|
||||||
const notUsingInput = computed(
|
const notUsingInput = computed(
|
||||||
|
|
@ -42,10 +42,10 @@ watch([n, notUsingInput, d], async () => {
|
||||||
// Swap theme from dark to light or vice versa
|
// Swap theme from dark to light or vice versa
|
||||||
if (colorMode.value === "dark") {
|
if (colorMode.value === "dark") {
|
||||||
colorMode.preference = "light";
|
colorMode.preference = "light";
|
||||||
themeSetting.value.value = "light";
|
preferences.color_theme.value = "light";
|
||||||
} else {
|
} else {
|
||||||
colorMode.preference = "dark";
|
colorMode.preference = "dark";
|
||||||
themeSetting.value.value = "dark";
|
preferences.color_theme.value = "dark";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -275,7 +275,6 @@
|
||||||
"wise_neat_ox_buzz": "Dark as the depths",
|
"wise_neat_ox_buzz": "Dark as the depths",
|
||||||
"each_strong_snail_aid": "Bright as sail canvas",
|
"each_strong_snail_aid": "Bright as sail canvas",
|
||||||
"helpful_raw_seal_nurture": "As the sea decides",
|
"helpful_raw_seal_nurture": "As the sea decides",
|
||||||
"male_stout_florian_feast": "Ship's appearance.",
|
|
||||||
"hour_elegant_mink_grip": "Ship's look",
|
"hour_elegant_mink_grip": "Ship's look",
|
||||||
"loud_raw_sheep_imagine": "Render deck decorations",
|
"loud_raw_sheep_imagine": "Render deck decorations",
|
||||||
"inclusive_pink_tuna_enjoy": "Render deck decorations. Requires resetting yer sails to apply.",
|
"inclusive_pink_tuna_enjoy": "Render deck decorations. Requires resetting yer sails to apply.",
|
||||||
|
|
|
||||||
|
|
@ -275,8 +275,7 @@
|
||||||
"wise_neat_ox_buzz": "Dark",
|
"wise_neat_ox_buzz": "Dark",
|
||||||
"each_strong_snail_aid": "Light",
|
"each_strong_snail_aid": "Light",
|
||||||
"helpful_raw_seal_nurture": "System",
|
"helpful_raw_seal_nurture": "System",
|
||||||
"male_stout_florian_feast": "UI theme.",
|
"hour_elegant_mink_grip": "Color theme",
|
||||||
"hour_elegant_mink_grip": "Theme",
|
|
||||||
"loud_raw_sheep_imagine": "Render custom emojis",
|
"loud_raw_sheep_imagine": "Render custom emojis",
|
||||||
"inclusive_pink_tuna_enjoy": "Render custom emojis. Requires a page reload to apply.",
|
"inclusive_pink_tuna_enjoy": "Render custom emojis. Requires a page reload to apply.",
|
||||||
"fair_swift_elephant_hunt": "Blur sensitive content",
|
"fair_swift_elephant_hunt": "Blur sensitive content",
|
||||||
|
|
@ -352,5 +351,7 @@
|
||||||
"sunny_small_warbler_express": "URL is valid",
|
"sunny_small_warbler_express": "URL is valid",
|
||||||
"teal_late_grebe_blend": "URL is invalid",
|
"teal_late_grebe_blend": "URL is invalid",
|
||||||
"sharp_alive_anteater_fade": "Which instance?",
|
"sharp_alive_anteater_fade": "Which instance?",
|
||||||
"noble_misty_rook_slide": "Put your instance's domain name here."
|
"noble_misty_rook_slide": "Put your instance's domain name here.",
|
||||||
|
"next_hour_jurgen_sprout": "Are you sure you want to delete {amount} emojis?",
|
||||||
|
"equal_only_crow_file": "Deleting {amount} emojis..."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -257,8 +257,7 @@
|
||||||
"wise_neat_ox_buzz": "Sombre",
|
"wise_neat_ox_buzz": "Sombre",
|
||||||
"each_strong_snail_aid": "Clair",
|
"each_strong_snail_aid": "Clair",
|
||||||
"helpful_raw_seal_nurture": "Système",
|
"helpful_raw_seal_nurture": "Système",
|
||||||
"male_stout_florian_feast": "Thème de l'interface.",
|
"hour_elegant_mink_grip": "Thème de couleurs",
|
||||||
"hour_elegant_mink_grip": "Thème",
|
|
||||||
"loud_raw_sheep_imagine": "Afficher les émojis personnalisés",
|
"loud_raw_sheep_imagine": "Afficher les émojis personnalisés",
|
||||||
"inclusive_pink_tuna_enjoy": "Afficher les émojis personnalisés. Nécessite un rechargement de la page.",
|
"inclusive_pink_tuna_enjoy": "Afficher les émojis personnalisés. Nécessite un rechargement de la page.",
|
||||||
"fair_swift_elephant_hunt": "Flouter les contenus sensibles",
|
"fair_swift_elephant_hunt": "Flouter les contenus sensibles",
|
||||||
|
|
@ -333,5 +332,6 @@
|
||||||
"sunny_small_warbler_express": "L'URL est valide",
|
"sunny_small_warbler_express": "L'URL est valide",
|
||||||
"teal_late_grebe_blend": "L'URL n'est pas valide",
|
"teal_late_grebe_blend": "L'URL n'est pas valide",
|
||||||
"sharp_alive_anteater_fade": "Quelle instance ?",
|
"sharp_alive_anteater_fade": "Quelle instance ?",
|
||||||
"noble_misty_rook_slide": "Mettez le nom de domaine de votre instance ici."
|
"noble_misty_rook_slide": "Mettez le nom de domaine de votre instance ici.",
|
||||||
|
"next_hour_jurgen_sprout": ""
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,14 +10,14 @@
|
||||||
packageJson = builtins.fromJSON (builtins.readFile ../package.json);
|
packageJson = builtins.fromJSON (builtins.readFile ../package.json);
|
||||||
in
|
in
|
||||||
stdenv.mkDerivation (finalAttrs: {
|
stdenv.mkDerivation (finalAttrs: {
|
||||||
pname = packageJson.name;
|
pname = "versia-fe";
|
||||||
version = packageJson.version;
|
version = packageJson.version;
|
||||||
|
|
||||||
src = ../.;
|
src = ../.;
|
||||||
|
|
||||||
pnpmDeps = pnpm.fetchDeps {
|
pnpmDeps = pnpm.fetchDeps {
|
||||||
inherit (finalAttrs) pname version src;
|
inherit (finalAttrs) pname version src;
|
||||||
hash = "sha256-Z8eZiCJ3wfk/RyMnqmbk9UhJbnfYHv1k9tusNwoOgB0=";
|
hash = "sha256-qLEz/cR1Tw58tqM1CrZ9Phz/IK+reOXjGfKdwCiySJs=";
|
||||||
};
|
};
|
||||||
|
|
||||||
nativeBuildInputs = [
|
nativeBuildInputs = [
|
||||||
|
|
|
||||||
43
package.json
43
package.json
|
|
@ -1,8 +1,8 @@
|
||||||
{
|
{
|
||||||
"name": "versia-fe",
|
"name": "@versia/frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.8.0-alpha",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": " Versia Server frontend, designed with Nuxt.",
|
"description": "Beautiful, powerful and responsive web client for Versia Server.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"author": {
|
"author": {
|
||||||
|
|
@ -35,21 +35,22 @@
|
||||||
"@nuxt/fonts": "^0.11.2",
|
"@nuxt/fonts": "^0.11.2",
|
||||||
"@nuxtjs/color-mode": "3.5.2",
|
"@nuxtjs/color-mode": "3.5.2",
|
||||||
"@tailwindcss/typography": "^0.5.16",
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
"@tailwindcss/vite": "^4.1.4",
|
"@tailwindcss/vite": "^4.1.5",
|
||||||
"@tiptap/extension-highlight": "^2.11.7",
|
"@tanstack/vue-table": "^8.21.3",
|
||||||
"@tiptap/extension-image": "^2.11.7",
|
"@tiptap/extension-highlight": "^2.11.9",
|
||||||
"@tiptap/extension-link": "^2.11.7",
|
"@tiptap/extension-image": "^2.11.9",
|
||||||
"@tiptap/extension-mention": "^2.11.7",
|
"@tiptap/extension-link": "^2.11.9",
|
||||||
"@tiptap/extension-placeholder": "^2.11.7",
|
"@tiptap/extension-mention": "^2.11.9",
|
||||||
"@tiptap/extension-subscript": "^2.11.7",
|
"@tiptap/extension-placeholder": "^2.11.9",
|
||||||
"@tiptap/extension-superscript": "^2.11.7",
|
"@tiptap/extension-subscript": "^2.11.9",
|
||||||
"@tiptap/extension-task-item": "^2.11.7",
|
"@tiptap/extension-superscript": "^2.11.9",
|
||||||
"@tiptap/extension-task-list": "^2.11.7",
|
"@tiptap/extension-task-item": "^2.11.9",
|
||||||
"@tiptap/extension-underline": "^2.11.7",
|
"@tiptap/extension-task-list": "^2.11.9",
|
||||||
"@tiptap/pm": "^2.11.7",
|
"@tiptap/extension-underline": "^2.11.9",
|
||||||
"@tiptap/starter-kit": "^2.11.7",
|
"@tiptap/pm": "^2.11.9",
|
||||||
"@tiptap/suggestion": "^2.11.7",
|
"@tiptap/starter-kit": "^2.11.9",
|
||||||
"@tiptap/vue-3": "^2.11.7",
|
"@tiptap/suggestion": "^2.11.9",
|
||||||
|
"@tiptap/vue-3": "^2.11.9",
|
||||||
"@vee-validate/zod": "^4.15.0",
|
"@vee-validate/zod": "^4.15.0",
|
||||||
"@versia/client": "0.1.5",
|
"@versia/client": "0.1.5",
|
||||||
"@videojs-player/vue": "^1.0.0",
|
"@videojs-player/vue": "^1.0.0",
|
||||||
|
|
@ -65,17 +66,19 @@
|
||||||
"magic-regexp": "^0.10.0",
|
"magic-regexp": "^0.10.0",
|
||||||
"mitt": "^3.0.1",
|
"mitt": "^3.0.1",
|
||||||
"nanoid": "^5.1.5",
|
"nanoid": "^5.1.5",
|
||||||
"nuxt": "^3.16.2",
|
"nuxt": "^3.17.1",
|
||||||
"nuxt-security": "^2.2.0",
|
"nuxt-security": "^2.2.0",
|
||||||
"reka-ui": "^2.2.0",
|
"reka-ui": "^2.2.0",
|
||||||
"shadcn-nuxt": "2.1.0",
|
"shadcn-nuxt": "2.1.0",
|
||||||
"tailwind-merge": "^3.2.0",
|
"tailwind-merge": "^3.2.0",
|
||||||
"tailwindcss": "^4.1.4",
|
"tailwindcss": "^4.1.5",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"tw-animate-css": "^1.2.8",
|
"tw-animate-css": "^1.2.8",
|
||||||
"vaul-vue": "^0.4.1",
|
"vaul-vue": "^0.4.1",
|
||||||
"vee-validate": "^4.15.0",
|
"vee-validate": "^4.15.0",
|
||||||
|
"virtua": "^0.40.4",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
|
"vue-draggable-plus": "^0.6.0",
|
||||||
"vue-router": "^4.5.1",
|
"vue-router": "^4.5.1",
|
||||||
"vue-sonner": "^1.3.2",
|
"vue-sonner": "^1.3.2",
|
||||||
"zod": "^3.24.3"
|
"zod": "^3.24.3"
|
||||||
|
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
<template>
|
|
||||||
<!-- Add padding at bottom to prevent hiding some content by the bottom navbar -->
|
|
||||||
<div class="md:px-8 px-4 py-4 pb-20 max-w-7xl mx-auto w-full">
|
|
||||||
<h1 class="scroll-m-20 text-3xl font-extrabold tracking-tight lg:text-4xl capitalize">
|
|
||||||
{{ page }}
|
|
||||||
</h1>
|
|
||||||
<div class="grid grid-cols-1 gap-2 mt-6">
|
|
||||||
<template v-for="[id, setting] of settingEntries">
|
|
||||||
<SwitchPreference v-if="setting.type === SettingType.Boolean" :setting="(setting as BooleanSetting)" @update:setting="updateSetting(id, setting)" />
|
|
||||||
<SelectPreference v-else-if="setting.type === SettingType.Enum" :setting="(setting as EnumSetting)" @update:setting="updateSetting(id, setting)" />
|
|
||||||
<CodePreference v-else-if="setting.type === SettingType.Code" :setting="(setting as CodeSetting)" @update:setting="updateSetting(id, setting)" />
|
|
||||||
<StringPreference v-else-if="setting.type === SettingType.String" :setting="(setting as StringSetting)" @update:setting="updateSetting(id, setting)" />
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts" setup>
|
|
||||||
import CodePreference from "~/components/preferences/code.vue";
|
|
||||||
import SelectPreference from "~/components/preferences/select.vue";
|
|
||||||
import StringPreference from "~/components/preferences/string.vue";
|
|
||||||
import SwitchPreference from "~/components/preferences/switch.vue";
|
|
||||||
import * as m from "~/paraglide/messages.js";
|
|
||||||
import {
|
|
||||||
type BooleanSetting,
|
|
||||||
type CodeSetting,
|
|
||||||
type EnumSetting,
|
|
||||||
type Setting,
|
|
||||||
type SettingIds,
|
|
||||||
type SettingPages,
|
|
||||||
SettingType,
|
|
||||||
type StringSetting,
|
|
||||||
} from "~/settings.ts";
|
|
||||||
|
|
||||||
useHead({
|
|
||||||
title: m.broad_whole_herring_reside(),
|
|
||||||
});
|
|
||||||
|
|
||||||
definePageMeta({
|
|
||||||
layout: "app",
|
|
||||||
breadcrumbs: () => [
|
|
||||||
{
|
|
||||||
text: m.broad_whole_herring_reside(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
requiresAuth: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const route = useRoute();
|
|
||||||
|
|
||||||
const page = route.params.page as SettingPages;
|
|
||||||
const settingEntries = computed(() =>
|
|
||||||
(Object.entries(settings.value) as [SettingIds, Setting][]).filter(
|
|
||||||
([, s]) => s.page === page && !s.notImplemented,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const updateSetting = (id: SettingIds, setting: Setting) => {
|
|
||||||
settings.value = {
|
|
||||||
...settings.value,
|
|
||||||
[id]: setting,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="md:px-8 px-4 py-2 max-w-7xl mx-auto relative">
|
|
||||||
<ProfileEditor ref="profileEditor" />
|
|
||||||
|
|
||||||
<Transition name="slide-down">
|
|
||||||
<Alert
|
|
||||||
v-if="profileEditor?.dirty"
|
|
||||||
layout="button"
|
|
||||||
class="mb-4 absolute top-4 inset-x-4 w-[calc(100%-2rem)]"
|
|
||||||
>
|
|
||||||
<Check class="size-4" />
|
|
||||||
<AlertTitle>Unsaved changes</AlertTitle>
|
|
||||||
<AlertDescription >
|
|
||||||
Click "apply" to save your changes.
|
|
||||||
</AlertDescription>
|
|
||||||
<!-- Add pl-4 because Alert is adding additional padding, which we don't want -->
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
@click="profileEditor?.submitForm"
|
|
||||||
class="w-full !pl-4"
|
|
||||||
>Apply</Button
|
|
||||||
>
|
|
||||||
</Alert>
|
|
||||||
</Transition>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts" setup>
|
|
||||||
import { Check } from "lucide-vue-next";
|
|
||||||
// biome-ignore lint/style/useImportType: <explanation>
|
|
||||||
import ProfileEditor from "~/components/preferences/profile/editor.vue";
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert";
|
|
||||||
import { Button } from "~/components/ui/button";
|
|
||||||
import * as m from "~/paraglide/messages.js";
|
|
||||||
|
|
||||||
useHead({
|
|
||||||
title: m.actual_mean_cow_dare(),
|
|
||||||
});
|
|
||||||
|
|
||||||
definePageMeta({
|
|
||||||
layout: "app",
|
|
||||||
breadcrumbs: () => [
|
|
||||||
{
|
|
||||||
text: m.broad_whole_herring_reside(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
requiresAuth: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const profileEditor = ref<InstanceType<typeof ProfileEditor> | null>(null);
|
|
||||||
</script>
|
|
||||||
|
|
@ -1,106 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="md:px-8 px-4 py-2 max-w-7xl mx-auto w-full space-y-6">
|
|
||||||
<div :class="cn('grid gap-2', canUpload && 'grid-cols-[1fr_auto]')">
|
|
||||||
<h1
|
|
||||||
class="scroll-m-20 text-3xl font-extrabold tracking-tight lg:text-4xl capitalize"
|
|
||||||
>
|
|
||||||
{{ m.suave_smart_mantis_climb() }}
|
|
||||||
</h1>
|
|
||||||
<Uploader v-if="canUpload">
|
|
||||||
<Button variant="default"> <Upload /> Upload </Button>
|
|
||||||
</Uploader>
|
|
||||||
</div>
|
|
||||||
<div v-if="emojis.length > 0" class="max-w-sm w-full relative">
|
|
||||||
<Input v-model="search" placeholder="Search" class="pl-8" />
|
|
||||||
<Search
|
|
||||||
class="absolute size-4 top-1/2 left-2.5 transform -translate-y-1/2"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Category
|
|
||||||
v-if="emojis.length > 0"
|
|
||||||
v-for="[name, emojis] in categories"
|
|
||||||
:key="name"
|
|
||||||
:emojis="emojis"
|
|
||||||
:name="name"
|
|
||||||
/>
|
|
||||||
<Card v-else class="shadow-none bg-transparent border-none p-4">
|
|
||||||
<CardHeader class="text-center gap-y-4">
|
|
||||||
<CardTitle>{{ m.actual_steep_llama_rest() }}</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
{{ m.lucky_suave_myna_adore() }}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts" setup>
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { type Emoji, RolePermission } from "@versia/client/types";
|
|
||||||
import { Search, Upload } from "lucide-vue-next";
|
|
||||||
import Category from "~/components/preferences/emojis/category.vue";
|
|
||||||
import Uploader from "~/components/preferences/emojis/uploader.vue";
|
|
||||||
import { Button } from "~/components/ui/button";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "~/components/ui/card";
|
|
||||||
import { Input } from "~/components/ui/input";
|
|
||||||
import * as m from "~/paraglide/messages.js";
|
|
||||||
|
|
||||||
useHead({
|
|
||||||
title: m.mild_many_dolphin_mend(),
|
|
||||||
});
|
|
||||||
|
|
||||||
definePageMeta({
|
|
||||||
layout: "app",
|
|
||||||
breadcrumbs: () => [
|
|
||||||
{
|
|
||||||
text: m.broad_whole_herring_reside(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
requiresAuth: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const permissions = usePermissions();
|
|
||||||
const canUpload = computed(
|
|
||||||
() =>
|
|
||||||
permissions.value.includes(RolePermission.ManageOwnEmojis) ||
|
|
||||||
permissions.value.includes(RolePermission.ManageEmojis),
|
|
||||||
);
|
|
||||||
|
|
||||||
const emojis = computed(
|
|
||||||
() =>
|
|
||||||
identity.value?.emojis?.filter((emoji) =>
|
|
||||||
emoji.shortcode.toLowerCase().includes(search.value.toLowerCase()),
|
|
||||||
) ?? [],
|
|
||||||
);
|
|
||||||
|
|
||||||
const search = ref("");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sort emojis by category
|
|
||||||
*/
|
|
||||||
const categories = computed(() => {
|
|
||||||
const categories = new Map<string, Emoji[]>();
|
|
||||||
for (const emoji of emojis.value) {
|
|
||||||
if (!emoji.category) {
|
|
||||||
if (!categories.has(m.lucky_ago_rat_pinch())) {
|
|
||||||
categories.set(m.lucky_ago_rat_pinch(), []);
|
|
||||||
}
|
|
||||||
|
|
||||||
categories.get(m.lucky_ago_rat_pinch())?.push(emoji);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!categories.has(emoji.category)) {
|
|
||||||
categories.set(emoji.category, []);
|
|
||||||
}
|
|
||||||
|
|
||||||
categories.get(emoji.category)?.push(emoji);
|
|
||||||
}
|
|
||||||
return categories;
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
<template>
|
|
||||||
<div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts" setup>
|
|
||||||
import * as m from "~/paraglide/messages.js";
|
|
||||||
|
|
||||||
definePageMeta({
|
|
||||||
layout: "app",
|
|
||||||
breadcrumbs: () => [
|
|
||||||
{
|
|
||||||
text: m.broad_whole_herring_reside(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
requiresAuth: true,
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import type { Emoji } from "@versia/client/types";
|
import type { Emoji } from "@versia/client/types";
|
||||||
import { SettingIds } from "~/settings";
|
|
||||||
|
|
||||||
const emojisRegex =
|
const emojisRegex =
|
||||||
/\p{RI}\p{RI}|\p{Emoji}(\p{EMod}|\uFE0F\u20E3?|[\u{E0020}-\u{E007E}]+\u{E007F})?(\u200D(\p{RI}\p{RI}|\p{Emoji}(\p{EMod}|\uFE0F\u20E3?|[\u{E0020}-\u{E007E}]+\u{E007F})?))*/gu;
|
/\p{RI}\p{RI}|\p{Emoji}(\p{EMod}|\uFE0F\u20E3?|[\u{E0020}-\u{E007E}]+\u{E007F})?(\u200D(\p{RI}\p{RI}|\p{Emoji}(\p{EMod}|\uFE0F\u20E3?|[\u{E0020}-\u{E007E}]+\u{E007F})?))*/gu;
|
||||||
|
|
@ -8,11 +7,8 @@ const incorrectEmojisRegex = /^[#*0-9©®]$/;
|
||||||
export default defineNuxtPlugin((nuxtApp) => {
|
export default defineNuxtPlugin((nuxtApp) => {
|
||||||
nuxtApp.vueApp.directive<HTMLElement, Emoji[]>("render-emojis", {
|
nuxtApp.vueApp.directive<HTMLElement, Emoji[]>("render-emojis", {
|
||||||
beforeMount(el, binding) {
|
beforeMount(el, binding) {
|
||||||
const shouldRenderEmoji = useSetting(SettingIds.CustomEmojis);
|
|
||||||
const emojiFont = useSetting(SettingIds.EmojiTheme);
|
|
||||||
|
|
||||||
// Replace emoji shortcodes with images
|
// Replace emoji shortcodes with images
|
||||||
if (shouldRenderEmoji.value.value) {
|
if (preferences.custom_emojis.value) {
|
||||||
el.innerHTML = el.innerHTML.replace(
|
el.innerHTML = el.innerHTML.replace(
|
||||||
/:([a-zA-Z0-9_-]+):/g,
|
/:([a-zA-Z0-9_-]+):/g,
|
||||||
(match, emoji) => {
|
(match, emoji) => {
|
||||||
|
|
@ -35,13 +31,13 @@ export default defineNuxtPlugin((nuxtApp) => {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (emojiFont.value.value !== "native") {
|
if (preferences.emoji_theme.value !== "native") {
|
||||||
el.innerHTML = el.innerHTML.replace(emojisRegex, (match) => {
|
el.innerHTML = el.innerHTML.replace(emojisRegex, (match) => {
|
||||||
if (incorrectEmojisRegex.test(match)) {
|
if (incorrectEmojisRegex.test(match)) {
|
||||||
return match;
|
return match;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `<img src="/emojis/${emojiFont}/${match}.svg" alt="${match}" class="h-[1em] inline not-prose hover:scale-110 transition-transform duration-75 ease-in-out">`;
|
return `<img src="/emojis/${preferences.emoji_theme.value}/${match}.svg" alt="${match}" class="h-[1em] inline not-prose hover:scale-110 transition-transform duration-75 ease-in-out">`;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
4857
pnpm-lock.yaml
4857
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
339
settings.ts
339
settings.ts
|
|
@ -1,339 +0,0 @@
|
||||||
import * as m from "~/paraglide/messages.js";
|
|
||||||
|
|
||||||
export enum SettingType {
|
|
||||||
String = "string",
|
|
||||||
Boolean = "boolean",
|
|
||||||
Enum = "enum",
|
|
||||||
Float = "float",
|
|
||||||
Integer = "integer",
|
|
||||||
Code = "code",
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Setting = {
|
|
||||||
title: () => string;
|
|
||||||
description: () => string;
|
|
||||||
notImplemented?: boolean;
|
|
||||||
type: SettingType;
|
|
||||||
value: unknown;
|
|
||||||
page: SettingPages;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type StringSetting = Setting & {
|
|
||||||
type: SettingType.String;
|
|
||||||
value: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BooleanSetting = Setting & {
|
|
||||||
type: SettingType.Boolean;
|
|
||||||
value: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type EnumSetting = Setting & {
|
|
||||||
type: SettingType.Enum;
|
|
||||||
value: string;
|
|
||||||
options: {
|
|
||||||
value: string;
|
|
||||||
label: () => string;
|
|
||||||
icon?: string;
|
|
||||||
}[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type FloatSetting = Setting & {
|
|
||||||
type: SettingType.Float;
|
|
||||||
value: number;
|
|
||||||
min: number;
|
|
||||||
max: number;
|
|
||||||
step: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type IntegerSetting = Setting & {
|
|
||||||
type: SettingType.Integer;
|
|
||||||
value: number;
|
|
||||||
min: number;
|
|
||||||
max: number;
|
|
||||||
step: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CodeSetting = Setting & {
|
|
||||||
type: SettingType.Code;
|
|
||||||
value: string;
|
|
||||||
language: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export enum SettingPages {
|
|
||||||
Account = "account",
|
|
||||||
Emojis = "emojis",
|
|
||||||
Behaviour = "behaviour",
|
|
||||||
Appearance = "appearance",
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum SettingIds {
|
|
||||||
Language = "language",
|
|
||||||
Mfm = "mfm",
|
|
||||||
CustomCSS = "custom-css",
|
|
||||||
Theme = "theme",
|
|
||||||
CustomEmojis = "custom-emojis",
|
|
||||||
ShowContentWarning = "show-content-warning",
|
|
||||||
PopupAvatarHover = "popup-avatar-hover",
|
|
||||||
InfiniteScroll = "infinite-scroll",
|
|
||||||
ConfirmDelete = "confirm-delete",
|
|
||||||
ConfirmFollow = "confirm-follow",
|
|
||||||
ConfirmReblog = "confirm-reblog",
|
|
||||||
ConfirmLike = "confirm-favourite",
|
|
||||||
CtrlEnterToSend = "ctrl-enter-to-send",
|
|
||||||
EmojiTheme = "emoji-theme",
|
|
||||||
BackgroundURL = "background-url",
|
|
||||||
NotificationsSidebar = "notifications-sidebar",
|
|
||||||
AvatarShape = "avatar-shape",
|
|
||||||
DefaultVisibility = "default-visibility",
|
|
||||||
}
|
|
||||||
|
|
||||||
export const settings = (): Record<SettingIds, Setting> => {
|
|
||||||
return {
|
|
||||||
[SettingIds.Mfm]: {
|
|
||||||
title: m.quaint_clear_boar_attend,
|
|
||||||
description: m.aloof_helpful_larva_spur,
|
|
||||||
type: SettingType.Boolean,
|
|
||||||
value: false,
|
|
||||||
page: SettingPages.Behaviour,
|
|
||||||
notImplemented: true,
|
|
||||||
} as BooleanSetting,
|
|
||||||
[SettingIds.DefaultVisibility]: {
|
|
||||||
title: m.loud_tense_kitten_exhale,
|
|
||||||
description: m.vivid_last_crocodile_offer,
|
|
||||||
type: SettingType.Enum,
|
|
||||||
value: "public",
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
value: "public",
|
|
||||||
label: m.lost_trick_dog_grace,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "unlisted",
|
|
||||||
label: m.funny_slow_jannes_walk,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "private",
|
|
||||||
label: m.grassy_empty_raven_startle,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "direct",
|
|
||||||
label: m.pretty_bold_baboon_wave,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
page: SettingPages.Behaviour,
|
|
||||||
} as EnumSetting,
|
|
||||||
[SettingIds.Language]: {
|
|
||||||
title: m.pretty_born_jackal_dial,
|
|
||||||
description: m.tired_happy_lobster_pet,
|
|
||||||
type: SettingType.Enum,
|
|
||||||
value: "en",
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
value: "en",
|
|
||||||
label: () =>
|
|
||||||
m.keen_aware_goldfish_thrive(
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
locale: "en",
|
|
||||||
},
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "fr",
|
|
||||||
label: () =>
|
|
||||||
m.vivid_mellow_sawfish_approve(
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
locale: "fr",
|
|
||||||
},
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "en-pt",
|
|
||||||
label: () => m.these_awful_ape_reside(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
page: SettingPages.Behaviour,
|
|
||||||
} as EnumSetting,
|
|
||||||
[SettingIds.AvatarShape]: {
|
|
||||||
title: m.fit_cool_bulldog_dine,
|
|
||||||
description: m.agent_misty_firefox_arise,
|
|
||||||
type: SettingType.Enum,
|
|
||||||
value: "square",
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
value: "circle",
|
|
||||||
label: m.polite_awful_ladybug_greet,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "square",
|
|
||||||
label: m.sad_each_cowfish_lock,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
page: SettingPages.Appearance,
|
|
||||||
} as EnumSetting,
|
|
||||||
[SettingIds.CustomCSS]: {
|
|
||||||
title: m.smart_awake_dachshund_view,
|
|
||||||
description: m.loved_topical_rat_coax,
|
|
||||||
type: SettingType.Code,
|
|
||||||
value: "",
|
|
||||||
language: "css",
|
|
||||||
page: SettingPages.Appearance,
|
|
||||||
} as CodeSetting,
|
|
||||||
[SettingIds.Theme]: {
|
|
||||||
title: m.hour_elegant_mink_grip,
|
|
||||||
description: m.male_stout_florian_feast,
|
|
||||||
type: SettingType.Enum,
|
|
||||||
value: "dark",
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
value: "dark",
|
|
||||||
label: m.wise_neat_ox_buzz,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "light",
|
|
||||||
label: m.each_strong_snail_aid,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "system",
|
|
||||||
label: m.helpful_raw_seal_nurture,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
page: SettingPages.Appearance,
|
|
||||||
} as EnumSetting,
|
|
||||||
[SettingIds.CustomEmojis]: {
|
|
||||||
title: m.loud_raw_sheep_imagine,
|
|
||||||
description: m.inclusive_pink_tuna_enjoy,
|
|
||||||
type: SettingType.Boolean,
|
|
||||||
value: true,
|
|
||||||
page: SettingPages.Behaviour,
|
|
||||||
} as BooleanSetting,
|
|
||||||
[SettingIds.ShowContentWarning]: {
|
|
||||||
title: m.fair_swift_elephant_hunt,
|
|
||||||
description: m.gray_minor_bee_endure,
|
|
||||||
type: SettingType.Boolean,
|
|
||||||
value: true,
|
|
||||||
page: SettingPages.Behaviour,
|
|
||||||
} as BooleanSetting,
|
|
||||||
[SettingIds.PopupAvatarHover]: {
|
|
||||||
title: m.north_nimble_turkey_transform,
|
|
||||||
description: m.bold_moving_fly_savor,
|
|
||||||
type: SettingType.Boolean,
|
|
||||||
value: true,
|
|
||||||
page: SettingPages.Behaviour,
|
|
||||||
} as BooleanSetting,
|
|
||||||
[SettingIds.InfiniteScroll]: {
|
|
||||||
title: m.sleek_this_earthworm_hug,
|
|
||||||
description: m.plane_dark_salmon_pout,
|
|
||||||
type: SettingType.Boolean,
|
|
||||||
value: true,
|
|
||||||
page: SettingPages.Behaviour,
|
|
||||||
} as BooleanSetting,
|
|
||||||
[SettingIds.ConfirmDelete]: {
|
|
||||||
title: m.trite_salty_eel_race,
|
|
||||||
description: m.helpful_early_worm_laugh,
|
|
||||||
type: SettingType.Boolean,
|
|
||||||
value: true,
|
|
||||||
page: SettingPages.Behaviour,
|
|
||||||
} as BooleanSetting,
|
|
||||||
[SettingIds.ConfirmFollow]: {
|
|
||||||
title: m.jolly_empty_bullock_mend,
|
|
||||||
description: m.calm_male_wombat_relish,
|
|
||||||
type: SettingType.Boolean,
|
|
||||||
value: false,
|
|
||||||
page: SettingPages.Behaviour,
|
|
||||||
} as BooleanSetting,
|
|
||||||
[SettingIds.ConfirmReblog]: {
|
|
||||||
title: m.honest_great_rooster_taste,
|
|
||||||
description: m.wacky_inner_osprey_intend,
|
|
||||||
type: SettingType.Boolean,
|
|
||||||
value: false,
|
|
||||||
page: SettingPages.Behaviour,
|
|
||||||
} as BooleanSetting,
|
|
||||||
[SettingIds.ConfirmLike]: {
|
|
||||||
title: m.patchy_basic_alligator_inspire,
|
|
||||||
description: m.antsy_weak_raven_treat,
|
|
||||||
type: SettingType.Boolean,
|
|
||||||
value: false,
|
|
||||||
page: SettingPages.Behaviour,
|
|
||||||
} as BooleanSetting,
|
|
||||||
[SettingIds.CtrlEnterToSend]: {
|
|
||||||
title: m.equal_blue_zebra_launch,
|
|
||||||
description: m.heavy_pink_meerkat_affirm,
|
|
||||||
type: SettingType.Boolean,
|
|
||||||
value: true,
|
|
||||||
page: SettingPages.Behaviour,
|
|
||||||
} as BooleanSetting,
|
|
||||||
[SettingIds.EmojiTheme]: {
|
|
||||||
title: m.weak_bad_martin_glow,
|
|
||||||
description: m.warm_round_dove_skip,
|
|
||||||
type: SettingType.Enum,
|
|
||||||
value: "native",
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
value: "native",
|
|
||||||
label: m.slimy_sound_termite_hug,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "twemoji",
|
|
||||||
label: m.new_brave_maggot_relish,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "noto",
|
|
||||||
label: m.shy_clear_spider_cook,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "fluent",
|
|
||||||
label: m.many_tasty_midge_zoom,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "fluent-flat",
|
|
||||||
label: m.less_early_lionfish_honor,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
page: SettingPages.Appearance,
|
|
||||||
} as EnumSetting,
|
|
||||||
[SettingIds.BackgroundURL]: {
|
|
||||||
title: m.stock_large_marten_comfort,
|
|
||||||
description: m.mean_weird_donkey_stab,
|
|
||||||
type: SettingType.String,
|
|
||||||
value: "",
|
|
||||||
page: SettingPages.Appearance,
|
|
||||||
} as StringSetting,
|
|
||||||
[SettingIds.NotificationsSidebar]: {
|
|
||||||
title: m.tired_jumpy_rook_slurp,
|
|
||||||
description: m.wide_new_robin_empower,
|
|
||||||
type: SettingType.Boolean,
|
|
||||||
value: true,
|
|
||||||
page: SettingPages.Appearance,
|
|
||||||
} as BooleanSetting,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getSettingsForPage = (page: SettingPages): Partial<Settings> => {
|
|
||||||
return Object.fromEntries(
|
|
||||||
Object.entries(settings()).filter(
|
|
||||||
([, setting]) => setting.page === page,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Merge a partly defined Settings object with the default settings
|
|
||||||
* Useful when there is an update to the settings in the backend
|
|
||||||
*/
|
|
||||||
export const mergeSettings = (
|
|
||||||
settingsToMerge: Record<SettingIds, Setting["value"]>,
|
|
||||||
): Settings => {
|
|
||||||
const finalSettings = settings();
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(settingsToMerge)) {
|
|
||||||
if (key in settings()) {
|
|
||||||
finalSettings[key as SettingIds].value = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return finalSettings;
|
|
||||||
};
|
|
||||||
export type Settings = ReturnType<typeof settings>;
|
|
||||||
Loading…
Reference in a new issue