mirror of
https://github.com/versia-pub/frontend.git
synced 2025-12-06 08:28:20 +01:00
feat: ✨ Add settings page to configure account and preferences
This commit is contained in:
parent
633ff184e3
commit
1691daa000
|
|
@ -24,8 +24,8 @@
|
||||||
- [x] Media uploads
|
- [x] Media uploads
|
||||||
- [x] WCAG 2.2 AAA compliance
|
- [x] WCAG 2.2 AAA compliance
|
||||||
- Testing is automated and may not catch all issues, please report any accessibility issues you find.
|
- Testing is automated and may not catch all issues, please report any accessibility issues you find.
|
||||||
- [ ] Settings
|
- [x] Settings
|
||||||
- [ ] Profile editing
|
- [x] Profile editing
|
||||||
|
|
||||||
### Browser Support
|
### Browser Support
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
<template>
|
<template>
|
||||||
<button v-bind="$props" type="button" :disabled="loading"
|
<button v-bind="$props" type="button" :disabled="loading"
|
||||||
:class="['relative isolate text-base/6 font-semibold px-[calc(theme(spacing[3.5])-1px)] py-[calc(theme(spacing[2.5])-1px)] sm:px-[calc(theme(spacing.3)-1px)] sm:py-[calc(theme(spacing[1.5])-1px)] sm:text-sm/6 focus:outline-none focus:outline focus:outline-2 focus:outline-offset-2 focus:outline-[--btn-bg] before:absolute before:inset-0 before:-z-10 before:rounded-[calc(theme(borderRadius.lg)-1px)] before:shadow before:hidden after:absolute after:-z-10 after:-inset-px after:rounded-md before:disabled:shadow-none after:disabled:shadow-none text-white cursor-default rounded-md duration-200 hover:shadow disabled:opacity-70 content-none disabled:cursor-not-allowed shadow-sm bg-[--btn-bg] before:bg-[--btn-bg] after:active:bg-[--btn-hover-overlay] after:hover:bg-[--btn-hover-overlay]', loading && '[&>*]:invisible']">
|
:class="['relative isolate text-base/6 font-semibold px-[calc(theme(spacing[3.5])-1px)] py-[calc(theme(spacing[2.5])-1px)] sm:px-[calc(theme(spacing.3)-1px)] sm:py-[calc(theme(spacing[1.5])-1px)] sm:text-sm/6 focus:outline-none focus:outline focus:outline-2 focus:outline-offset-2 focus:outline-[--btn-bg] before:absolute before:inset-0 before:-z-10 before:rounded-[calc(theme(borderRadius.lg)-1px)] before:shadow before:hidden after:absolute after:-z-10 after:-inset-px after:rounded-md before:disabled:shadow-none after:disabled:shadow-none text-white cursor-default rounded-md duration-200 hover:shadow disabled:opacity-70 content-none disabled:cursor-not-allowed shadow-sm bg-[--btn-bg] before:bg-[--btn-bg] after:active:bg-[--btn-hover-overlay] after:hover:bg-[--btn-hover-overlay]', loading && '[&>*]:invisible']">
|
||||||
<div v-if="loading" class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 !visible">
|
<div v-if="loading" class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 !visible size-5">
|
||||||
<iconify-icon icon="tabler:loader-2" height="1.25rem" width="1.25rem" class="animate-spin" />
|
<iconify-icon icon="tabler:loader-2" height="none" class="animate-spin size-5" />
|
||||||
</div>
|
</div>
|
||||||
<slot />
|
<slot />
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -6,20 +6,8 @@
|
||||||
</OverlayScrollbarsComponent>
|
</OverlayScrollbarsComponent>
|
||||||
</div>
|
</div>
|
||||||
<div class="px-6 pb-4 pt-5">
|
<div class="px-6 pb-4 pt-5">
|
||||||
<div class="pb-2 relative">
|
<InputsRichTextbox v-model:model-content="content" @paste="handlePaste" :disabled="loading"
|
||||||
<textarea :disabled="loading" ref="textarea" v-model="content" :placeholder="chosenSplash"
|
:placeholder="chosenSplash" :max-characters="characterLimit" class="focus:!ring-0 max-h-[70dvh]" />
|
||||||
@paste="handlePaste"
|
|
||||||
class="resize-none min-h-48 prose prose-invert max-h-[70dvh] w-full p-0 focus:!ring-0 !ring-none !border-none !outline-none placeholder:text-zinc-500 bg-transparent appearance-none focus:!border-none focus:!outline-none disabled:cursor-not-allowed"
|
|
||||||
aria-label="Compose your message"></textarea>
|
|
||||||
<div :class="['absolute bottom-0 right-0 p-2 text-gray-400 font-semibold text-xs', remainingCharacters < 0 && 'text-red-500']"
|
|
||||||
aria-live="polite">
|
|
||||||
{{ remainingCharacters }}
|
|
||||||
</div>
|
|
||||||
<ComposerEmojiSuggestbox :textarea="textarea" v-if="!!currentlyBeingTypedEmoji"
|
|
||||||
:currently-typing-emoji="currentlyBeingTypedEmoji" @autocomplete="autocompleteEmoji" />
|
|
||||||
<ComposerMentionSuggestbox :textarea="textarea" v-if="!!currentlyBeingTypedMention"
|
|
||||||
:currently-typing-mention="currentlyBeingTypedMention" @autocomplete="autocompleteMention" />
|
|
||||||
</div>
|
|
||||||
<!-- Content warning textbox -->
|
<!-- Content warning textbox -->
|
||||||
<div v-if="cw" class="mb-4">
|
<div v-if="cw" class="mb-4">
|
||||||
<input type="text" v-model="cwContent" placeholder="Add a content warning"
|
<input type="text" v-model="cwContent" placeholder="Add a content warning"
|
||||||
|
|
@ -50,27 +38,23 @@
|
||||||
<ButtonsPrimary :loading="loading" @click="send" class="ml-auto rounded-full"
|
<ButtonsPrimary :loading="loading" @click="send" class="ml-auto rounded-full"
|
||||||
:disabled="!canSubmit || loading">
|
:disabled="!canSubmit || loading">
|
||||||
<span>{{
|
<span>{{
|
||||||
respondingType === "edit" ? "Edit!" : "Send!"
|
respondingType === "edit" ? "Edit!" : "Send!"
|
||||||
}}</span>
|
}}</span>
|
||||||
</ButtonsPrimary>
|
</ButtonsPrimary>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { char, createRegExp, exactly } from "magic-regexp";
|
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import type { Instance } from "~/types/mastodon/instance";
|
import type { Instance } from "~/types/mastodon/instance";
|
||||||
import type { Status } from "~/types/mastodon/status";
|
import type { Status } from "~/types/mastodon/status";
|
||||||
import { OverlayScrollbarsComponent } from "#imports";
|
import { OverlayScrollbarsComponent } from "#imports";
|
||||||
import type FileUploader from "./file-uploader.vue";
|
import type FileUploader from "./file-uploader.vue";
|
||||||
|
|
||||||
const textarea = ref<HTMLTextAreaElement | undefined>(undefined);
|
|
||||||
const uploader = ref<InstanceType<typeof FileUploader> | undefined>(undefined);
|
const uploader = ref<InstanceType<typeof FileUploader> | undefined>(undefined);
|
||||||
const { input: content } = useTextareaAutosize({
|
|
||||||
element: textarea,
|
|
||||||
});
|
|
||||||
const { Control_Enter, Command_Enter, Control_Alt } = useMagicKeys();
|
const { Control_Enter, Command_Enter, Control_Alt } = useMagicKeys();
|
||||||
|
const content = ref("");
|
||||||
const respondingTo = ref<Status | null>(null);
|
const respondingTo = ref<Status | null>(null);
|
||||||
const respondingType = ref<"reply" | "quote" | "edit" | null>(null);
|
const respondingType = ref<"reply" | "quote" | "edit" | null>(null);
|
||||||
const identity = useCurrentIdentity();
|
const identity = useCurrentIdentity();
|
||||||
|
|
@ -80,41 +64,11 @@ const markdown = ref(true);
|
||||||
|
|
||||||
const splashes = useConfig().COMPOSER_SPLASHES;
|
const splashes = useConfig().COMPOSER_SPLASHES;
|
||||||
const chosenSplash = ref(splashes[Math.floor(Math.random() * splashes.length)]);
|
const chosenSplash = ref(splashes[Math.floor(Math.random() * splashes.length)]);
|
||||||
const currentlyBeingTypedEmoji = computed(() => {
|
|
||||||
const match = content.value?.match(partiallyTypedEmojiValidator);
|
|
||||||
return match ? match.at(-1)?.replace(":", "") ?? "" : null;
|
|
||||||
});
|
|
||||||
const currentlyBeingTypedMention = computed(() => {
|
|
||||||
const match = content.value?.match(partiallyTypedMentionValidator);
|
|
||||||
return match ? match.at(-1)?.replace("@", "") ?? "" : null;
|
|
||||||
});
|
|
||||||
|
|
||||||
const openFilePicker = () => {
|
const openFilePicker = () => {
|
||||||
uploader.value?.openFilePicker();
|
uploader.value?.openFilePicker();
|
||||||
};
|
};
|
||||||
|
|
||||||
const autocompleteEmoji = (emoji: string) => {
|
|
||||||
// Replace the end of the string with the emoji
|
|
||||||
content.value = content.value?.replace(
|
|
||||||
createRegExp(
|
|
||||||
exactly(":"),
|
|
||||||
exactly(currentlyBeingTypedEmoji.value ?? "").notBefore(char),
|
|
||||||
),
|
|
||||||
`:${emoji}:`,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const autocompleteMention = (mention: string) => {
|
|
||||||
// Replace the end of the string with the mention
|
|
||||||
content.value = content.value?.replace(
|
|
||||||
createRegExp(
|
|
||||||
exactly("@"),
|
|
||||||
exactly(currentlyBeingTypedMention.value ?? "").notBefore(char),
|
|
||||||
),
|
|
||||||
`@${mention} `,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const files = ref<
|
const files = ref<
|
||||||
{
|
{
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -170,7 +124,6 @@ onMounted(() => {
|
||||||
respondingType.value = "reply";
|
respondingType.value = "reply";
|
||||||
if (note.account.id !== identity.value?.account.id)
|
if (note.account.id !== identity.value?.account.id)
|
||||||
content.value = `@${note.account.acct} `;
|
content.value = `@${note.account.acct} `;
|
||||||
textarea.value?.focus();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
useListen("composer:quote", (note: Status) => {
|
useListen("composer:quote", (note: Status) => {
|
||||||
|
|
@ -178,7 +131,6 @@ onMounted(() => {
|
||||||
respondingType.value = "quote";
|
respondingType.value = "quote";
|
||||||
if (note.account.id !== identity.value?.account.id)
|
if (note.account.id !== identity.value?.account.id)
|
||||||
content.value = `@${note.account.acct} `;
|
content.value = `@${note.account.acct} `;
|
||||||
textarea.value?.focus();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
useListen("composer:edit", async (note: Status) => {
|
useListen("composer:edit", async (note: Status) => {
|
||||||
|
|
@ -199,7 +151,6 @@ onMounted(() => {
|
||||||
respondingType.value = "edit";
|
respondingType.value = "edit";
|
||||||
content.value = source.data.text;
|
content.value = source.data.text;
|
||||||
cwContent.value = source.data.spoiler_text;
|
cwContent.value = source.data.spoiler_text;
|
||||||
textarea.value?.focus();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
|
|
@ -285,7 +236,4 @@ const send = async () => {
|
||||||
const characterLimit = computed(
|
const characterLimit = computed(
|
||||||
() => props.instance?.configuration.statuses.max_characters ?? 0,
|
() => props.instance?.configuration.statuses.max_characters ?? 0,
|
||||||
);
|
);
|
||||||
const remainingCharacters = computed(
|
|
||||||
() => characterLimit.value - (content.value?.length ?? 0),
|
|
||||||
);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -8,19 +8,18 @@
|
||||||
<div
|
<div
|
||||||
class="mt-4 text-center flex flex-col justify-center sm:mt-0 sm:text-left bg-dark-800 py-2 px-4 rounded grow ring-1 ring-white/10">
|
class="mt-4 text-center flex flex-col justify-center sm:mt-0 sm:text-left bg-dark-800 py-2 px-4 rounded grow ring-1 ring-white/10">
|
||||||
<p class="text-sm font-medium text-gray-300">Welcome back,</p>
|
<p class="text-sm font-medium text-gray-300">Welcome back,</p>
|
||||||
<p class="text-xl font-bold text-gray-50 sm:text-2xl line-clamp-1"
|
<p class="text-xl font-bold text-gray-50 sm:text-2xl line-clamp-1" v-html="display_name"></p>
|
||||||
v-html="useParsedContent(identity.account.display_name, []).value"></p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- <div class="mt-5 flex justify-center sm:mt-0">
|
|
||||||
<ButtonsSecondary @click="useEvent('composer:open')">
|
|
||||||
Compose
|
|
||||||
</ButtonsSecondary>
|
|
||||||
</div> -->
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
const identity = useCurrentIdentity();
|
const identity = useCurrentIdentity();
|
||||||
|
const settings = useSettings();
|
||||||
|
const { display_name } = useParsedAccount(
|
||||||
|
computed(() => identity.value?.account),
|
||||||
|
settings,
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
85
components/inputs/rich-textbox.vue
Normal file
85
components/inputs/rich-textbox.vue
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
<template>
|
||||||
|
<div class="relative">
|
||||||
|
<textarea v-bind="$attrs" ref="textarea" v-model="content"
|
||||||
|
class="resize-none min-h-48 prose prose-invert w-full p-0 !ring-none !border-none !outline-none placeholder:text-zinc-500 bg-transparent appearance-none focus:!border-none focus:!outline-none disabled:cursor-not-allowed"
|
||||||
|
aria-label="Compose your message" :autofocus="true"></textarea>
|
||||||
|
<div v-if="maxCharacters"
|
||||||
|
:class="['absolute bottom-0 right-0 p-2 text-gray-300 font-semibold text-xs', remainingCharacters < 0 && 'text-red-500']"
|
||||||
|
aria-live="polite">
|
||||||
|
{{ remainingCharacters }}
|
||||||
|
</div>
|
||||||
|
<ComposerEmojiSuggestbox :textarea="textarea" v-if="!!currentlyBeingTypedEmoji"
|
||||||
|
:currently-typing-emoji="currentlyBeingTypedEmoji" @autocomplete="autocompleteEmoji" />
|
||||||
|
<ComposerMentionSuggestbox :textarea="textarea" v-if="!!currentlyBeingTypedMention"
|
||||||
|
:currently-typing-mention="currentlyBeingTypedMention" @autocomplete="autocompleteMention" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { char, createRegExp, exactly } from "magic-regexp";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
});
|
||||||
|
const props = defineProps<{
|
||||||
|
maxCharacters?: number;
|
||||||
|
modelContent: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
"update:modelContent": [value: string];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const textarea = ref<HTMLTextAreaElement | undefined>(undefined);
|
||||||
|
const { input: content } = useTextareaAutosize({
|
||||||
|
element: textarea,
|
||||||
|
input: props.modelContent,
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelContent,
|
||||||
|
(value) => {
|
||||||
|
content.value = value;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(content, (newValue) => {
|
||||||
|
emit("update:modelContent", newValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
const remainingCharacters = computed(
|
||||||
|
() =>
|
||||||
|
(props.maxCharacters ?? Number.POSITIVE_INFINITY) -
|
||||||
|
(content.value?.length ?? 0),
|
||||||
|
);
|
||||||
|
const currentlyBeingTypedEmoji = computed(() => {
|
||||||
|
const match = content.value?.match(partiallyTypedEmojiValidator);
|
||||||
|
return match ? match.at(-1)?.replace(":", "") ?? "" : null;
|
||||||
|
});
|
||||||
|
const currentlyBeingTypedMention = computed(() => {
|
||||||
|
const match = content.value?.match(partiallyTypedMentionValidator);
|
||||||
|
return match ? match.at(-1)?.replace("@", "") ?? "" : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const autocompleteEmoji = (emoji: string) => {
|
||||||
|
// Replace the end of the string with the emoji
|
||||||
|
content.value = content.value?.replace(
|
||||||
|
createRegExp(
|
||||||
|
exactly(":"),
|
||||||
|
exactly(currentlyBeingTypedEmoji.value ?? "").notBefore(char),
|
||||||
|
),
|
||||||
|
`:${emoji}:`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const autocompleteMention = (mention: string) => {
|
||||||
|
// Replace the end of the string with the mention
|
||||||
|
content.value = content.value?.replace(
|
||||||
|
createRegExp(
|
||||||
|
exactly("@"),
|
||||||
|
exactly(currentlyBeingTypedMention.value ?? "").notBefore(char),
|
||||||
|
),
|
||||||
|
`@${mention} `,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
88
components/settings/oidc.vue
Normal file
88
components/settings/oidc.vue
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
<template>
|
||||||
|
<!-- OIDC linked accounts manager -->
|
||||||
|
<div class="w-full ring-1 ring-white/5 pb-5 bg-dark-800 rounded overflow-hidden">
|
||||||
|
<div class="px-4 py-4">
|
||||||
|
<h3 class="font-semibold text-gray-300 text-xl">Linked accounts</h3>
|
||||||
|
</div>
|
||||||
|
<div class="px-4 py-3">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2 gap-4">
|
||||||
|
<div v-for="provider of ssoConfig?.providers" :key="provider.id"
|
||||||
|
class="flex items-center justify-between p-4 bg-dark-700 rounded">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<AvatarsCentered :src="provider.icon" :alt="provider.name" class="h-8 w-8" />
|
||||||
|
<span class="font-semibold text-gray-300">{{ provider.name }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<ButtonsPrimary :loading="loading" v-if="!linkedProviders?.find(p => p.id === provider.id)"
|
||||||
|
@click="link(provider.id)">
|
||||||
|
<span>Link</span>
|
||||||
|
</ButtonsPrimary>
|
||||||
|
<ButtonsSecondary :loading="loading" v-else @click="unlink(provider.id)">
|
||||||
|
<span>Unlink</span>
|
||||||
|
</ButtonsSecondary>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { ResponseError } from "@lysand-org/client";
|
||||||
|
|
||||||
|
const client = useClient();
|
||||||
|
const ssoConfig = useSSOConfig();
|
||||||
|
const linkedProviders = useLinkedSSO(client);
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
const link = async (providerId: string) => {
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const output = await client.value.post<{
|
||||||
|
link: string;
|
||||||
|
}>("/api/v1/sso", {
|
||||||
|
issuer: providerId,
|
||||||
|
});
|
||||||
|
|
||||||
|
window.location.href = output.data.link;
|
||||||
|
} catch (error) {
|
||||||
|
const e = error as ResponseError<{ error: string }>;
|
||||||
|
|
||||||
|
useEvent("notification:new", {
|
||||||
|
title: "Failed to link account",
|
||||||
|
message: e.response.data.error,
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const unlink = async (providerId: string) => {
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.value.delete<void>(`/api/v1/sso/${providerId}`);
|
||||||
|
|
||||||
|
useEvent("notification:new", {
|
||||||
|
title: "Account unlinked",
|
||||||
|
type: "success",
|
||||||
|
});
|
||||||
|
|
||||||
|
linkedProviders.value = linkedProviders.value.filter(
|
||||||
|
(p) => p.id !== providerId,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const e = error as ResponseError<{ error: string }>;
|
||||||
|
|
||||||
|
useEvent("notification:new", {
|
||||||
|
title: "Failed to unlink account",
|
||||||
|
message: e.response.data.error,
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = false;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
120
components/settings/profile-editor.vue
Normal file
120
components/settings/profile-editor.vue
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="w-full md:px-8 px-4 py-4 bg-dark-700 grid justify-center lg:grid-cols-[minmax(auto,_36rem)_1fr] grid-cols-1 gap-4">
|
||||||
|
<form class="w-full ring-1 ring-inset ring-white/5 pb-5 bg-dark-800 rounded overflow-hidden"
|
||||||
|
@submit.prevent="save">
|
||||||
|
<AvatarsCentered :src="account?.header" :alt="`${account?.acct}'s header image'`"
|
||||||
|
class="w-full aspect-[8/3] border-b border-white/10 bg-dark-700 !rounded-none" />
|
||||||
|
|
||||||
|
<div class="flex items-start justify-between px-4 py-3">
|
||||||
|
<AvatarsCentered :src="account?.avatar" :alt="`${account?.acct}'s avatar'`"
|
||||||
|
class="h-32 w-32 -mt-[4.5rem] z-10 shrink-0 rounded ring-2 ring-dark-800" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2 px-4">
|
||||||
|
<InputsText @input="displayName = ($event.target as HTMLInputElement).value" :value="displayName"
|
||||||
|
aria-label="Display name" :disabled="loading" />
|
||||||
|
<div class="mt-2 grid grid-cols-[auto_1fr] items-center gap-x-2">
|
||||||
|
<iconify-icon icon="tabler:at" width="none" class="size-6" aria-hidden="true" />
|
||||||
|
<InputsText @input="acct = ($event.target as HTMLInputElement).value" :value="acct"
|
||||||
|
aria-label="Username" :disabled="loading" />
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-300 text-xs mt-2">
|
||||||
|
Changing your username will break all links to your profile.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 px-4">
|
||||||
|
<InputsRichTextbox v-model:model-content="note" :max-characters="noteCharacterLimit" :disabled="loading"
|
||||||
|
class="rounded ring-white/10 ring-2 focus:ring-primary-600 px-4 py-2 max-h-[40dvh] max-w-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="px-4 mt-4 grid grid-cols-2 gap-2">
|
||||||
|
<ButtonsPrimary class="w-full" type="submit" :loading="loading">
|
||||||
|
<span>Save</span>
|
||||||
|
</ButtonsPrimary>
|
||||||
|
<ButtonsSecondary class="w-full" @click="revert" type="button" :loading="loading">
|
||||||
|
<span>Revert</span>
|
||||||
|
</ButtonsSecondary>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div>
|
||||||
|
<SettingsOidc />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { ResponseError } from "@lysand-org/client";
|
||||||
|
|
||||||
|
const identity = useCurrentIdentity();
|
||||||
|
const account = computed(() => identity.value?.account);
|
||||||
|
const note = ref(account.value?.source?.note ?? "");
|
||||||
|
const displayName = ref(account.value?.display_name ?? "");
|
||||||
|
const acct = ref(account.value?.acct ?? "");
|
||||||
|
const noteCharacterLimit = computed(
|
||||||
|
() => identity.value?.instance.configuration.statuses.max_characters ?? 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const client = useClient();
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
const revert = () => {
|
||||||
|
useEvent("notification:new", {
|
||||||
|
title: "Reverted to current bio",
|
||||||
|
type: "success",
|
||||||
|
});
|
||||||
|
|
||||||
|
note.value = account.value?.source?.note ?? "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
const changedData = {
|
||||||
|
display_name:
|
||||||
|
displayName.value === account.value?.display_name
|
||||||
|
? undefined
|
||||||
|
: displayName.value,
|
||||||
|
username: acct.value === account.value?.acct ? undefined : acct.value,
|
||||||
|
note:
|
||||||
|
note.value === account.value?.source?.note ? undefined : note.value,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
Object.values(changedData).filter((v) => v !== undefined).length === 0
|
||||||
|
) {
|
||||||
|
useEvent("notification:new", {
|
||||||
|
title: "No changes",
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await client.value.updateCredentials(
|
||||||
|
Object.fromEntries(
|
||||||
|
Object.entries(changedData).filter(([, v]) => v !== undefined),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEvent("notification:new", {
|
||||||
|
title: "Profile updated",
|
||||||
|
type: "success",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (identity.value) identity.value.account = data;
|
||||||
|
} catch (e) {
|
||||||
|
const error = e as ResponseError<{ error: string }>;
|
||||||
|
|
||||||
|
useEvent("notification:new", {
|
||||||
|
title: "Failed to update profile",
|
||||||
|
message: error.response.data.error,
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = false;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
47
components/settings/renderer.vue
Normal file
47
components/settings/renderer.vue
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
<template>
|
||||||
|
<div class="w-full px-8 py-4 bg-dark-700 hover:bg-dark-500 duration-100">
|
||||||
|
<Switch.Root v-model:checked="checked" class="grid grid-cols-[1fr_auto] gap-x-4"
|
||||||
|
@click="setting.notImplemented ? $event.preventDefault() : undefined"
|
||||||
|
v-if="setting.type === SettingType.Boolean" @update:checked="c => checked = c">
|
||||||
|
<Switch.Label :data-disabled="setting.notImplemented ? '' : undefined"
|
||||||
|
class="row-start-1 select-none text-base/6 data-[disabled]:opacity-50 sm:text-sm/6 text-white font-semibold">
|
||||||
|
{{
|
||||||
|
setting.title
|
||||||
|
}}</Switch.Label>
|
||||||
|
<p v-if="setting.notImplemented" class="text-xs mt-1 row-start-3 text-red-300 font-semibold">Not implemented
|
||||||
|
</p>
|
||||||
|
<p v-else :data-disabled="setting.notImplemented ? '' : undefined"
|
||||||
|
class="text-base/6 row-start-2 data-[disabled]:opacity-50 sm:text-sm/6 text-gray-300">{{
|
||||||
|
setting.description }}
|
||||||
|
</p>
|
||||||
|
<Switch.Control :data-disabled="setting.notImplemented ? '' : undefined"
|
||||||
|
:data-checked="checked ? '' : undefined"
|
||||||
|
class="group col-start-2 relative isolate inline-flex h-6 w-10 cursor-default rounded-full p-[3px] sm:h-5 sm:w-8 transition duration-0 ease-in-out data-[changing]:duration-200 forced-colors:outline forced-colors:[--switch-bg:Highlight] ring-1 ring-inset bg-white/5 ring-white/15 data-[checked]:bg-[--switch-bg] data-[checked]:ring-[--switch-bg-ring] focus:outline-none focus:outline focus:outline-2 focus:outline-offset-2 focus:outline-blue-500 hover:data-[checked]:ring-[--switch-bg-ring] hover:ring-white/25 data-[disabled]:bg-zinc-200 data-[disabled]:data-[checked]:bg-zinc-200 data-[disabled]:opacity-50 data-[disabled]:bg-white/15 data-[disabled]:data-[checked]:bg-white/15 data-[disabled]:data-[checked]:ring-white/15 [--switch-bg-ring:transparent] [--switch-bg:theme(colors.primary.600/25%)] [--switch-shadow:theme(colors.black/10%)] [--switch:white] [--switch-ring:theme(colors.white/10%)]">
|
||||||
|
<Switch.Thumb
|
||||||
|
class="pointer-events-none relative inline-block size-[1.125rem] rounded-full sm:size-3.5 translate-x-0 transition duration-200 ease-in-out border border-transparent bg-white shadow ring-1 ring-black/5 group-data-[checked]:bg-[--switch] group-data-[checked]:shadow-[--switch-shadow] group-data-[checked]:ring-[--switch-ring] group-data-[checked]:translate-x-4 sm:group-data-[checked]:translate-x-3 group-data-[disabled]:group-data-[checked]:bg-white group-data-[disabled]:group-data-[checked]:shadow group-data-[disabled]:group-data-[checked]:ring-black/5" />
|
||||||
|
</Switch.Control>
|
||||||
|
<Switch.HiddenInput />
|
||||||
|
</Switch.Root>
|
||||||
|
<div v-else class="grid grid-cols-[1fr_auto] gap-x-4">
|
||||||
|
<h4 class="row-start-1 select-none text-base/6 sm:text-sm/6 text-white font-semibold">{{ setting.title }}
|
||||||
|
</h4>
|
||||||
|
<p class="text-xs mt-1 row-start-3 text-red-300 font-semibold">Not implemented</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { Switch } from "@ark-ui/vue";
|
||||||
|
import { type Setting, type SettingIds, SettingType } from "~/settings";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
setting: Setting;
|
||||||
|
}>();
|
||||||
|
const checked = ref(!!props.setting.value);
|
||||||
|
|
||||||
|
const setting = useSetting(props.setting.id as SettingIds);
|
||||||
|
|
||||||
|
watch(checked, (c) => {
|
||||||
|
setting.value.value = c;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
<div class="flex flex-row items-center justify-between w-full">
|
<div class="flex flex-row items-center justify-between w-full">
|
||||||
<div class="font-semibold text-gray-200 text-sm line-clamp-1 break-all">
|
<div class="font-semibold text-gray-200 text-sm line-clamp-1 break-all">
|
||||||
{{
|
{{
|
||||||
currentIdentity.account.display_name }}
|
currentIdentity.account.display_name }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-gray-400 text-xs line-clamp-1 break-all w-full">
|
<span class="text-gray-400 text-xs line-clamp-1 break-all w-full">
|
||||||
|
|
@ -44,13 +44,13 @@
|
||||||
<div class="flex flex-row items-center justify-between w-full">
|
<div class="flex flex-row items-center justify-between w-full">
|
||||||
<div class="font-semibold text-gray-200 line-clamp-1 break-all">
|
<div class="font-semibold text-gray-200 line-clamp-1 break-all">
|
||||||
{{
|
{{
|
||||||
identity.account.display_name }}
|
identity.account.display_name }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-gray-400 text-sm line-clamp-1 break-all w-full">
|
<span class="text-gray-400 text-sm line-clamp-1 break-all w-full">
|
||||||
@{{
|
@{{
|
||||||
identity.account.acct
|
identity.account.acct
|
||||||
}}
|
}}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button data-part="item"
|
<button data-part="item"
|
||||||
|
|
@ -60,6 +60,20 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
<Menu.Item value="" v-if="currentIdentity">
|
||||||
|
<NuxtLink href="/settings" class="w-full">
|
||||||
|
<div class="rounded text-left flex flex-row gap-x-2 hover:scale-[95%]">
|
||||||
|
<div
|
||||||
|
class="shrink-0 size-12 border-dashed border-white/20 border flex items-center justify-center rounded">
|
||||||
|
<iconify-icon icon="tabler:adjustments" class="size-6 text-gray-200" width="none" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex flex-col items-start font-semibold p-1 justify-around text-sm text-gray-300 grow overflow-hidden">
|
||||||
|
Settings
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NuxtLink>
|
||||||
|
</Menu.Item>
|
||||||
<Menu.Item value="">
|
<Menu.Item value="">
|
||||||
<button @click="$emit('signIn')" class="w-full">
|
<button @click="$emit('signIn')" class="w-full">
|
||||||
<div class="rounded text-left flex flex-row gap-x-2 hover:scale-[95%]">
|
<div class="rounded text-left flex flex-row gap-x-2 hover:scale-[95%]">
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,20 @@
|
||||||
<span class="pr-28 line-clamp-1">Register</span>
|
<span class="pr-28 line-clamp-1">Register</span>
|
||||||
</ButtonsBase>
|
</ButtonsBase>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
<NuxtLink href="/settings" v-if="identity">
|
||||||
|
<button @click="$emit('signIn')" class="w-full overflow-hidden">
|
||||||
|
<div class="rounded text-left flex flex-row items-center gap-x-2 hover:scale-[95%]">
|
||||||
|
<div
|
||||||
|
class="shrink-0 size-12 border-dashed border-white/20 border flex items-center justify-center rounded">
|
||||||
|
<iconify-icon icon="tabler:adjustments" class="size-6 text-gray-200" width="none" />
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="line-clamp-1 font-semibold p-1 justify-around text-sm text-gray-300 grow overflow-hidden">
|
||||||
|
Settings
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</NuxtLink>
|
||||||
<h3 v-if="identity"
|
<h3 v-if="identity"
|
||||||
class="font-semibold text-gray-300 text-xs uppercase opacity-0 group-hover:opacity-100 duration-200">
|
class="font-semibold text-gray-300 text-xs uppercase opacity-0 group-hover:opacity-100 duration-200">
|
||||||
Posts</h3>
|
Posts</h3>
|
||||||
|
|
|
||||||
33
components/sidebars/settings.vue
Normal file
33
components/sidebars/settings.vue
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
<template>
|
||||||
|
<Tabs.Root v-model="tab">
|
||||||
|
<Tabs.List class="flex flex-row p-4 gap-4 bg-dark-800 relative ring-1 ring-white/5 overflow-x-auto">
|
||||||
|
<Tabs.Trigger :value="page" v-for="page of SettingPages" :as-child="true">
|
||||||
|
<ButtonsBase class="capitalize hover:bg-white/5">
|
||||||
|
{{ page }}
|
||||||
|
</ButtonsBase>
|
||||||
|
</Tabs.Trigger>
|
||||||
|
<Tabs.Indicator class="h-1 bg-gray-300 w-[--width] top-0 rounded-b" />
|
||||||
|
</Tabs.List>
|
||||||
|
<Tabs.Content :value="page" v-for="page of SettingPages">
|
||||||
|
<slot :name="page" />
|
||||||
|
</Tabs.Content>
|
||||||
|
</Tabs.Root>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { Tabs } from "@ark-ui/vue";
|
||||||
|
import { SettingPages } from "~/settings";
|
||||||
|
|
||||||
|
const tab = ref<SettingPages>(
|
||||||
|
(window.location.hash.slice(1) as SettingPages) || SettingPages.Account,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update page hash when tab changes
|
||||||
|
watch(
|
||||||
|
tab,
|
||||||
|
(value) => {
|
||||||
|
window.location.hash = value;
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
@ -174,6 +174,7 @@ useListen("composer:send-edit", (note) => {
|
||||||
|
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
const identity = useCurrentIdentity();
|
const identity = useCurrentIdentity();
|
||||||
|
const settings = useSettings();
|
||||||
const {
|
const {
|
||||||
loaded,
|
loaded,
|
||||||
note: outputtedNote,
|
note: outputtedNote,
|
||||||
|
|
@ -185,7 +186,7 @@ const {
|
||||||
reblog,
|
reblog,
|
||||||
isReply,
|
isReply,
|
||||||
reblogDisplayName,
|
reblogDisplayName,
|
||||||
} = useNoteData(noteRef, client);
|
} = useNoteData(noteRef, client, settings);
|
||||||
|
|
||||||
const openBlank = (url: string) => window.open(url, "_blank");
|
const openBlank = (url: string) => window.open(url, "_blank");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
<AvatarsCentered v-if="notification?.account?.avatar" :src="notification?.account.avatar"
|
<AvatarsCentered v-if="notification?.account?.avatar" :src="notification?.account.avatar"
|
||||||
:alt="`${notification?.account.acct}'s avatar'`"
|
:alt="`${notification?.account.acct}'s avatar'`"
|
||||||
class="h-6 w-6 shrink-0 rounded ring-1 ring-white/10" />
|
class="h-6 w-6 shrink-0 rounded ring-1 ring-white/10" />
|
||||||
<span class="text-gray-200 line-clamp-1"><strong v-html="accountName"></strong> {{ text
|
<span class="text-gray-200 line-clamp-1"><strong v-html="display_name"></strong> {{ text
|
||||||
}}</span>
|
}}</span>
|
||||||
</Skeleton>
|
</Skeleton>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -63,10 +63,12 @@ const rejectFollowRequest = async () => {
|
||||||
isWorkingOnFollowRequest.value = false;
|
isWorkingOnFollowRequest.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const accountName = useParsedContent(
|
const settings = useSettings();
|
||||||
props.notification?.account?.display_name ?? "",
|
const { display_name } = useParsedAccount(
|
||||||
props.notification?.account?.emojis ?? [],
|
props.notification?.account,
|
||||||
|
settings,
|
||||||
);
|
);
|
||||||
|
|
||||||
const text = computed(() => {
|
const text = computed(() => {
|
||||||
if (!props.notification) return "";
|
if (!props.notification) return "";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@
|
||||||
<h2
|
<h2
|
||||||
class="text-xl font-bold text-gray-100 tracking-tight bg-gradient-to-r from-primary-300 via-purple-300 to-indigo-400 text-transparent bg-clip-text">
|
class="text-xl font-bold text-gray-100 tracking-tight bg-gradient-to-r from-primary-300 via-purple-300 to-indigo-400 text-transparent bg-clip-text">
|
||||||
<Skeleton :enabled="skeleton" :min-width="200" :max-width="350" class="h-6">
|
<Skeleton :enabled="skeleton" :min-width="200" :max-width="350" class="h-6">
|
||||||
{{ account?.display_name }}
|
<span v-html="display_name"></span>
|
||||||
<iconify-icon v-if="account?.locked" icon="tabler:lock" width="1.25rem" height="1.25rem"
|
<iconify-icon v-if="account?.locked" icon="tabler:lock" width="1.25rem" height="1.25rem"
|
||||||
class="text-gray-400 cursor-pointer align-text-top"
|
class="text-gray-400 cursor-pointer align-text-top"
|
||||||
title="This account manually approves its followers" />
|
title="This account manually approves its followers" />
|
||||||
|
|
@ -48,7 +48,7 @@
|
||||||
<Skeleton :enabled="true" v-if="skeleton" class="!h-6" :min-width="50" :max-width="100" width-unit="%"
|
<Skeleton :enabled="true" v-if="skeleton" class="!h-6" :min-width="50" :max-width="100" width-unit="%"
|
||||||
shape="rect" type="content">
|
shape="rect" type="content">
|
||||||
</Skeleton>
|
</Skeleton>
|
||||||
<div class="prose prose-invert" v-html="parsedNote" v-else></div>
|
<div class="prose prose-invert" v-html="note" v-else></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-3 flex items-center space-x-4 px-4">
|
<div class="mt-3 flex items-center space-x-4 px-4">
|
||||||
|
|
@ -79,8 +79,8 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="!skeleton && parsedFields.length > 0" class="mt-4 px-4 flex-col flex space-y-3">
|
<div v-if="!skeleton && fields && fields.length > 0" class="mt-4 px-4 flex-col flex space-y-3">
|
||||||
<div v-for="field of parsedFields" :key="field.name" class="flex flex-col gap-1">
|
<div v-for="field of fields" :key="field.name" class="flex flex-col gap-1">
|
||||||
<span class="text-primary-500 font-semibold" v-html="field.name"></span>
|
<span class="text-primary-500 font-semibold" v-html="field.name"></span>
|
||||||
<span class="text-gray-200 prose prose-invert break-all" v-html="field.value"></span>
|
<span class="text-gray-200 prose prose-invert break-all" v-html="field.value"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -134,13 +134,6 @@ const formattedJoin = computed(() =>
|
||||||
}).format(new Date(props.account?.created_at ?? 0)),
|
}).format(new Date(props.account?.created_at ?? 0)),
|
||||||
);
|
);
|
||||||
|
|
||||||
const parsedNote = ref("");
|
|
||||||
const parsedFields: Ref<
|
|
||||||
{
|
|
||||||
name: string;
|
|
||||||
value: string;
|
|
||||||
}[]
|
|
||||||
> = ref([]);
|
|
||||||
const handle = computed(() => {
|
const handle = computed(() => {
|
||||||
if (!props.account?.acct.includes("@")) {
|
if (!props.account?.acct.includes("@")) {
|
||||||
return `${props.account?.acct}@${new URL(useBaseUrl().value).host}`;
|
return `${props.account?.acct}@${new URL(useBaseUrl().value).host}`;
|
||||||
|
|
@ -154,27 +147,9 @@ const visibleRoles = computed(
|
||||||
() => props.account?.roles.filter((r) => r.visible) ?? [],
|
() => props.account?.roles.filter((r) => r.visible) ?? [],
|
||||||
);
|
);
|
||||||
|
|
||||||
watch(
|
const settings = useSettings();
|
||||||
skeleton,
|
const { display_name, fields, note } = useParsedAccount(
|
||||||
async () => {
|
computed(() => props.account),
|
||||||
if (skeleton.value) return;
|
settings,
|
||||||
parsedNote.value =
|
|
||||||
useParsedContent(
|
|
||||||
props.account?.note ?? "",
|
|
||||||
props.account?.emojis ?? [],
|
|
||||||
).value ?? "";
|
|
||||||
parsedFields.value =
|
|
||||||
props.account?.fields.map((field) => ({
|
|
||||||
name:
|
|
||||||
useParsedContent(field.name, props.account?.emojis ?? [])
|
|
||||||
.value ?? "",
|
|
||||||
value:
|
|
||||||
useParsedContent(field.value, props.account?.emojis ?? [])
|
|
||||||
.value ?? "",
|
|
||||||
})) ?? [];
|
|
||||||
},
|
|
||||||
{
|
|
||||||
immediate: true,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
25
composables/LinkedSSO.ts
Normal file
25
composables/LinkedSSO.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import type { LysandClient } from "@lysand-org/client";
|
||||||
|
|
||||||
|
type SSOProvider = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
icon: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useLinkedSSO = (client: MaybeRef<LysandClient>) => {
|
||||||
|
if (!client) {
|
||||||
|
return ref([] as SSOProvider[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = ref([] as SSOProvider[]);
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
toValue(client)
|
||||||
|
?.get<SSOProvider[]>("/api/v1/sso")
|
||||||
|
.then((res) => {
|
||||||
|
output.value = res.data;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return output;
|
||||||
|
};
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import type { LysandClient } from "@lysand-org/client";
|
import type { LysandClient } from "@lysand-org/client";
|
||||||
|
import { SettingIds, type Settings } from "~/settings";
|
||||||
import type { Status } from "~/types/mastodon/status";
|
import type { Status } from "~/types/mastodon/status";
|
||||||
|
|
||||||
export const useNoteData = (
|
export const useNoteData = (
|
||||||
noteProp: MaybeRef<Status | undefined>,
|
noteProp: MaybeRef<Status | undefined>,
|
||||||
client: Ref<LysandClient>,
|
client: Ref<LysandClient>,
|
||||||
|
settings: MaybeRef<Settings>,
|
||||||
) => {
|
) => {
|
||||||
const isReply = computed(() => !!toValue(noteProp)?.in_reply_to_id);
|
const isReply = computed(() => !!toValue(noteProp)?.in_reply_to_id);
|
||||||
const isQuote = computed(() => !!toValue(noteProp)?.quote);
|
const isQuote = computed(() => !!toValue(noteProp)?.quote);
|
||||||
|
|
@ -15,11 +17,13 @@ export const useNoteData = (
|
||||||
? toValue(noteProp)?.reblog ?? toValue(noteProp)
|
? toValue(noteProp)?.reblog ?? toValue(noteProp)
|
||||||
: toValue(noteProp),
|
: toValue(noteProp),
|
||||||
);
|
);
|
||||||
|
const showContentWarning = useSetting(SettingIds.ShowContentWarning);
|
||||||
const shouldHide = computed(
|
const shouldHide = computed(
|
||||||
() =>
|
() =>
|
||||||
renderedNote.value?.sensitive ||
|
(renderedNote.value?.sensitive ||
|
||||||
!!renderedNote.value?.spoiler_text ||
|
!!renderedNote.value?.spoiler_text ||
|
||||||
false,
|
false) &&
|
||||||
|
(showContentWarning.value.value as boolean),
|
||||||
);
|
);
|
||||||
const mentions = useResolveMentions(
|
const mentions = useResolveMentions(
|
||||||
computed(() => renderedNote.value?.mentions ?? []),
|
computed(() => renderedNote.value?.mentions ?? []),
|
||||||
|
|
@ -29,12 +33,15 @@ export const useNoteData = (
|
||||||
computed(() => renderedNote.value?.content ?? ""),
|
computed(() => renderedNote.value?.content ?? ""),
|
||||||
computed(() => renderedNote.value?.emojis ?? []),
|
computed(() => renderedNote.value?.emojis ?? []),
|
||||||
mentions,
|
mentions,
|
||||||
|
settings,
|
||||||
);
|
);
|
||||||
const loaded = computed(() => content.value !== null);
|
const loaded = computed(() => content.value !== null);
|
||||||
|
|
||||||
const reblogDisplayName = useParsedContent(
|
const reblogDisplayName = useParsedContent(
|
||||||
toValue(noteProp)?.account.display_name ?? "",
|
toValue(noteProp)?.account.display_name ?? "",
|
||||||
toValue(noteProp)?.account.emojis ?? [],
|
toValue(noteProp)?.account.emojis ?? [],
|
||||||
|
undefined,
|
||||||
|
settings,
|
||||||
);
|
);
|
||||||
const reblog = computed(() =>
|
const reblog = computed(() =>
|
||||||
isReblog.value && toValue(noteProp) && !isQuote.value
|
isReblog.value && toValue(noteProp) && !isQuote.value
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { renderToString } from "vue/server-renderer";
|
import { renderToString } from "vue/server-renderer";
|
||||||
|
import { SettingIds, type Settings, getSettingById } from "~/settings";
|
||||||
import type { Account } from "~/types/mastodon/account";
|
import type { Account } from "~/types/mastodon/account";
|
||||||
import type { Emoji } from "~/types/mastodon/emoji";
|
import type { Emoji } from "~/types/mastodon/emoji";
|
||||||
import MentionComponent from "../components/social-elements/notes/mention.vue";
|
import MentionComponent from "../components/social-elements/notes/mention.vue";
|
||||||
|
|
@ -13,6 +14,7 @@ export const useParsedContent = (
|
||||||
content: MaybeRef<string>,
|
content: MaybeRef<string>,
|
||||||
emojis: MaybeRef<Emoji[]>,
|
emojis: MaybeRef<Emoji[]>,
|
||||||
mentions: MaybeRef<Account[]> = ref([]),
|
mentions: MaybeRef<Account[]> = ref([]),
|
||||||
|
settings: MaybeRef<Settings> = ref([]),
|
||||||
): Ref<string | null> => {
|
): Ref<string | null> => {
|
||||||
const result = ref(null as string | null);
|
const result = ref(null as string | null);
|
||||||
|
|
||||||
|
|
@ -26,28 +28,35 @@ export const useParsedContent = (
|
||||||
const contentHtml = document.createElement("div");
|
const contentHtml = document.createElement("div");
|
||||||
contentHtml.innerHTML = toValue(content);
|
contentHtml.innerHTML = toValue(content);
|
||||||
|
|
||||||
// Replace emoji shortcodes with images
|
const shouldRenderEmoji = getSettingById(
|
||||||
const paragraphs = contentHtml.querySelectorAll("p");
|
toValue(settings),
|
||||||
|
SettingIds.CustomEmojis,
|
||||||
|
)?.value;
|
||||||
|
|
||||||
for (const paragraph of paragraphs) {
|
// Replace emoji shortcodes with images
|
||||||
paragraph.innerHTML = paragraph.innerHTML.replace(
|
if (shouldRenderEmoji) {
|
||||||
/:([a-z0-9_-]+):/g,
|
const paragraphs = contentHtml.querySelectorAll("p");
|
||||||
(match, emoji) => {
|
|
||||||
const emojiData = toValue(emojis).find(
|
for (const paragraph of paragraphs) {
|
||||||
(e) => e.shortcode === emoji,
|
paragraph.innerHTML = paragraph.innerHTML.replace(
|
||||||
);
|
/:([a-z0-9_-]+):/g,
|
||||||
if (!emojiData) {
|
(match, emoji) => {
|
||||||
return match;
|
const emojiData = toValue(emojis).find(
|
||||||
}
|
(e) => e.shortcode === emoji,
|
||||||
const image = document.createElement("img");
|
);
|
||||||
image.src = emojiData.url;
|
if (!emojiData) {
|
||||||
image.alt = `:${emoji}:`;
|
return match;
|
||||||
image.title = emojiData.shortcode;
|
}
|
||||||
image.className =
|
const image = document.createElement("img");
|
||||||
"h-6 align-text-bottom inline not-prose hover:scale-110 transition-transform duration-75 ease-in-out";
|
image.src = emojiData.url;
|
||||||
return image.outerHTML;
|
image.alt = `:${emoji}:`;
|
||||||
},
|
image.title = emojiData.shortcode;
|
||||||
);
|
image.className =
|
||||||
|
"h-6 align-text-bottom inline not-prose hover:scale-110 transition-transform duration-75 ease-in-out";
|
||||||
|
return image.outerHTML;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replace links containing mentions with interactive mentions
|
// Replace links containing mentions with interactive mentions
|
||||||
|
|
@ -76,3 +85,36 @@ export const useParsedContent = (
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useParsedAccount = (
|
||||||
|
account: MaybeRef<Account | undefined | null>,
|
||||||
|
settings: MaybeRef<Settings>,
|
||||||
|
) => {
|
||||||
|
const display_name = computed(() => toValue(account)?.display_name ?? "");
|
||||||
|
const note = computed(() => toValue(account)?.note ?? "");
|
||||||
|
const fields = computed(() => toValue(account)?.fields ?? []);
|
||||||
|
const emojis = computed(() => toValue(account)?.emojis ?? []);
|
||||||
|
|
||||||
|
const parsedDisplayName = useParsedContent(
|
||||||
|
display_name,
|
||||||
|
emojis,
|
||||||
|
undefined,
|
||||||
|
settings,
|
||||||
|
);
|
||||||
|
|
||||||
|
const parsedNote = useParsedContent(note, emojis, undefined, settings);
|
||||||
|
|
||||||
|
const parsedFields = computed(() =>
|
||||||
|
fields.value.map((field) => ({
|
||||||
|
...field,
|
||||||
|
value: useParsedContent(field.value, emojis, undefined, settings)
|
||||||
|
.value,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
display_name: parsedDisplayName,
|
||||||
|
note: parsedNote,
|
||||||
|
fields: parsedFields,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
|
||||||
56
composables/Settings.ts
Normal file
56
composables/Settings.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { StorageSerializers } from "@vueuse/core";
|
||||||
|
import {
|
||||||
|
type Setting,
|
||||||
|
type SettingIds,
|
||||||
|
type Settings,
|
||||||
|
parseFromJson,
|
||||||
|
settings,
|
||||||
|
} from "~/settings";
|
||||||
|
|
||||||
|
export const useSettings = () => {
|
||||||
|
return useLocalStorage<Settings>("lysand:settings", settings, {
|
||||||
|
serializer: {
|
||||||
|
read(raw) {
|
||||||
|
const json = StorageSerializers.object.read(raw);
|
||||||
|
|
||||||
|
return parseFromJson(json);
|
||||||
|
},
|
||||||
|
write(value) {
|
||||||
|
// key/value, with key being id and value being the value
|
||||||
|
const json = value.reduce(
|
||||||
|
(acc, setting) => {
|
||||||
|
acc[setting.id] = setting.value;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, unknown>,
|
||||||
|
);
|
||||||
|
|
||||||
|
return StorageSerializers.object.write(json);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSetting = <T extends Setting = Setting>(id: SettingIds) => {
|
||||||
|
const settings = useSettings();
|
||||||
|
|
||||||
|
const setting: Ref<T> = ref<T>(
|
||||||
|
settings.value.find((s) => s.id === id) as T,
|
||||||
|
) as unknown as Ref<T>;
|
||||||
|
|
||||||
|
watch(settings, (newSettings) => {
|
||||||
|
setting.value = newSettings.find((s) => s.id === id) as T;
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(setting, (newSetting) => {
|
||||||
|
settings.value = settings.value.map((s) =>
|
||||||
|
s.id === id ? newSetting : s,
|
||||||
|
) as Settings;
|
||||||
|
});
|
||||||
|
|
||||||
|
return setting;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSetting = <T extends Setting = Setting>(id: SettingIds) => {
|
||||||
|
return settings.find((s) => s.id === id) as T;
|
||||||
|
};
|
||||||
|
|
@ -45,44 +45,20 @@
|
||||||
import { OverlayScrollbarsComponent } from "#imports";
|
import { OverlayScrollbarsComponent } from "#imports";
|
||||||
const { width } = useWindowSize();
|
const { width } = useWindowSize();
|
||||||
|
|
||||||
const { n, o_i_d_c } = useMagicKeys();
|
const { n } = useMagicKeys();
|
||||||
|
const activeElement = useActiveElement();
|
||||||
|
const notUsingInput = computed(
|
||||||
|
() =>
|
||||||
|
activeElement.value?.tagName !== "INPUT" &&
|
||||||
|
activeElement.value?.tagName !== "TEXTAREA",
|
||||||
|
);
|
||||||
const identity = useCurrentIdentity();
|
const identity = useCurrentIdentity();
|
||||||
const client = useClient();
|
|
||||||
const providers = useSSOConfig();
|
|
||||||
|
|
||||||
watchEffect(async () => {
|
watchEffect(async () => {
|
||||||
if (n?.value) {
|
if (n?.value && notUsingInput.value) {
|
||||||
// Wait 50ms
|
// Wait 50ms
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
useEvent("composer:open");
|
useEvent("composer:open");
|
||||||
}
|
}
|
||||||
if (o_i_d_c?.value) {
|
|
||||||
useEvent("notification:new", {
|
|
||||||
type: "progress",
|
|
||||||
title: "Linking your account",
|
|
||||||
persistent: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const issuer = providers.value?.providers[0];
|
|
||||||
|
|
||||||
if (!issuer) {
|
|
||||||
console.error("No SSO provider found");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(new URL("/api/v1/sso", client.value.url), {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${identity.value?.tokens.access_token}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
issuer: issuer.id,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const json = await response.json();
|
|
||||||
window.location.href = json.link;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
35
pages/settings/index.vue
Normal file
35
pages/settings/index.vue
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
<template>
|
||||||
|
<SidebarsSettings>
|
||||||
|
<template #behaviour>
|
||||||
|
<SettingsRenderer :setting="setting" v-for="setting of getSettingsForPath(
|
||||||
|
settings,
|
||||||
|
SettingPages.Behaviour,
|
||||||
|
)" :key="setting.id" />
|
||||||
|
</template>
|
||||||
|
<template #appearance>
|
||||||
|
<SettingsRenderer :setting="setting" v-for="setting of getSettingsForPath(
|
||||||
|
settings,
|
||||||
|
SettingPages.Appearance,
|
||||||
|
)" :key="setting.id" />
|
||||||
|
</template>
|
||||||
|
<template #advanced>
|
||||||
|
<SettingsRenderer :setting="setting" v-for="setting of getSettingsForPath(
|
||||||
|
settings,
|
||||||
|
SettingPages.Advanced,
|
||||||
|
)" :key="setting.id" />
|
||||||
|
</template>
|
||||||
|
<template #account>
|
||||||
|
<SettingsProfileEditor />
|
||||||
|
</template>
|
||||||
|
</SidebarsSettings>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { SettingPages, getSettingsForPath } from "~/settings";
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
layout: "app",
|
||||||
|
});
|
||||||
|
|
||||||
|
const settings = useSettings();
|
||||||
|
</script>
|
||||||
71
settings.ts
71
settings.ts
|
|
@ -11,6 +11,7 @@ export type Setting<T = SettingType> = {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
notImplemented?: boolean;
|
||||||
type: T;
|
type: T;
|
||||||
value: T extends SettingType.String | SettingType.Code
|
value: T extends SettingType.String | SettingType.Code
|
||||||
? string
|
? string
|
||||||
|
|
@ -29,30 +30,59 @@ export type Setting<T = SettingType> = {
|
||||||
path: SettingPages;
|
path: SettingPages;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Settings = Setting[];
|
|
||||||
export enum SettingPages {
|
export enum SettingPages {
|
||||||
|
Account = "account",
|
||||||
Behaviour = "behaviour",
|
Behaviour = "behaviour",
|
||||||
Appearance = "appearance",
|
|
||||||
Advanced = "advanced",
|
Advanced = "advanced",
|
||||||
|
Appearance = "appearance",
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getSettingsForPath = (settings: Settings, path: string) =>
|
export const getSettingsForPath = (
|
||||||
settings.filter((setting) => setting.path === path);
|
settingsToFilterIn: Settings,
|
||||||
|
path: SettingPages,
|
||||||
|
) => settingsToFilterIn.filter((setting) => setting.path === path);
|
||||||
|
|
||||||
export const getSettingById = (settings: Settings, id: string) =>
|
export const getSettingById = (settingsToFilterIn: Settings, id: SettingIds) =>
|
||||||
settings.find((setting) => setting.id === id);
|
settingsToFilterIn.find((setting) => setting.id === id);
|
||||||
|
|
||||||
export const settings: Settings = [
|
export const parseFromJson = (json: Record<string, unknown>) => {
|
||||||
|
const finalSettings = structuredClone(settings);
|
||||||
|
|
||||||
|
// Override the default values with the values from the JSON except for the user value
|
||||||
|
for (const setting of finalSettings) {
|
||||||
|
if (setting.id in json) {
|
||||||
|
setting.value = json[setting.id] as (typeof setting)["value"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalSettings;
|
||||||
|
};
|
||||||
|
|
||||||
|
export enum SettingIds {
|
||||||
|
MFM = "mfm",
|
||||||
|
CustomCSS = "custom-css",
|
||||||
|
Theme = "theme",
|
||||||
|
CustomEmojis = "custom-emojis",
|
||||||
|
ShowContentWarning = "show-content-warning",
|
||||||
|
PopupAvatarHover = "popup-avatar-hover",
|
||||||
|
ConfirmDelete = "confirm-delete",
|
||||||
|
ConfirmFollow = "confirm-follow",
|
||||||
|
ConfirmReblog = "confirm-reblog",
|
||||||
|
ConfirmFavourite = "confirm-favourite",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const settings = [
|
||||||
{
|
{
|
||||||
id: "mfm",
|
id: SettingIds.MFM,
|
||||||
title: "Render MFM",
|
title: "Render MFM",
|
||||||
description: "Render Misskey-Flavoured Markdown",
|
description: "Render Misskey-Flavoured Markdown",
|
||||||
type: SettingType.Boolean,
|
type: SettingType.Boolean,
|
||||||
value: false,
|
value: false,
|
||||||
path: SettingPages.Behaviour,
|
path: SettingPages.Behaviour,
|
||||||
|
notImplemented: true,
|
||||||
} as Setting<SettingType.Boolean>,
|
} as Setting<SettingType.Boolean>,
|
||||||
{
|
{
|
||||||
id: "custom-css",
|
id: SettingIds.CustomCSS,
|
||||||
title: "Custom CSS",
|
title: "Custom CSS",
|
||||||
description: "Custom CSS for the UI",
|
description: "Custom CSS for the UI",
|
||||||
type: SettingType.Code,
|
type: SettingType.Code,
|
||||||
|
|
@ -61,7 +91,7 @@ export const settings: Settings = [
|
||||||
path: SettingPages.Appearance,
|
path: SettingPages.Appearance,
|
||||||
} as Setting<SettingType.Code>,
|
} as Setting<SettingType.Code>,
|
||||||
{
|
{
|
||||||
id: "theme",
|
id: SettingIds.Theme,
|
||||||
title: "Theme",
|
title: "Theme",
|
||||||
description: "UI theme",
|
description: "UI theme",
|
||||||
type: SettingType.Enum,
|
type: SettingType.Enum,
|
||||||
|
|
@ -70,7 +100,7 @@ export const settings: Settings = [
|
||||||
path: SettingPages.Appearance,
|
path: SettingPages.Appearance,
|
||||||
} as Setting<SettingType.Enum>,
|
} as Setting<SettingType.Enum>,
|
||||||
{
|
{
|
||||||
id: "custom-emojis",
|
id: SettingIds.CustomEmojis,
|
||||||
title: "Render Custom Emojis",
|
title: "Render Custom Emojis",
|
||||||
description: "Render custom emojis",
|
description: "Render custom emojis",
|
||||||
type: SettingType.Boolean,
|
type: SettingType.Boolean,
|
||||||
|
|
@ -78,7 +108,7 @@ export const settings: Settings = [
|
||||||
path: SettingPages.Behaviour,
|
path: SettingPages.Behaviour,
|
||||||
} as Setting<SettingType.Boolean>,
|
} as Setting<SettingType.Boolean>,
|
||||||
{
|
{
|
||||||
id: "show-content-warning",
|
id: SettingIds.ShowContentWarning,
|
||||||
title: "Show Content Warning",
|
title: "Show Content Warning",
|
||||||
description: "Show content warnings on notes marked sensitive/spoiler",
|
description: "Show content warnings on notes marked sensitive/spoiler",
|
||||||
type: SettingType.Boolean,
|
type: SettingType.Boolean,
|
||||||
|
|
@ -86,43 +116,50 @@ export const settings: Settings = [
|
||||||
path: SettingPages.Behaviour,
|
path: SettingPages.Behaviour,
|
||||||
} as Setting<SettingType.Boolean>,
|
} as Setting<SettingType.Boolean>,
|
||||||
{
|
{
|
||||||
id: "popup-avatar-hover",
|
id: SettingIds.PopupAvatarHover,
|
||||||
title: "Popup Profile Hover",
|
title: "Popup Profile Hover",
|
||||||
description: "Show profile popup when hovering over a user's avatar",
|
description: "Show profile popup when hovering over a user's avatar",
|
||||||
type: SettingType.Boolean,
|
type: SettingType.Boolean,
|
||||||
value: true,
|
value: true,
|
||||||
path: SettingPages.Behaviour,
|
path: SettingPages.Behaviour,
|
||||||
|
notImplemented: true,
|
||||||
} as Setting<SettingType.Boolean>,
|
} as Setting<SettingType.Boolean>,
|
||||||
{
|
{
|
||||||
id: "confirm-delete",
|
id: SettingIds.ConfirmDelete,
|
||||||
title: "Confirm Delete",
|
title: "Confirm Delete",
|
||||||
description: "Confirm before deleting a note",
|
description: "Confirm before deleting a note",
|
||||||
type: SettingType.Boolean,
|
type: SettingType.Boolean,
|
||||||
value: false,
|
value: false,
|
||||||
path: SettingPages.Behaviour,
|
path: SettingPages.Behaviour,
|
||||||
|
notImplemented: true,
|
||||||
} as Setting<SettingType.Boolean>,
|
} as Setting<SettingType.Boolean>,
|
||||||
{
|
{
|
||||||
id: "confirm-follow",
|
id: SettingIds.ConfirmFollow,
|
||||||
title: "Confirm Follow",
|
title: "Confirm Follow",
|
||||||
description: "Confirm before following/unfollowing a user",
|
description: "Confirm before following/unfollowing a user",
|
||||||
type: SettingType.Boolean,
|
type: SettingType.Boolean,
|
||||||
value: false,
|
value: false,
|
||||||
path: SettingPages.Behaviour,
|
path: SettingPages.Behaviour,
|
||||||
|
notImplemented: true,
|
||||||
} as Setting<SettingType.Boolean>,
|
} as Setting<SettingType.Boolean>,
|
||||||
{
|
{
|
||||||
id: "confirm-reblog",
|
id: SettingIds.ConfirmReblog,
|
||||||
title: "Confirm Reblog",
|
title: "Confirm Reblog",
|
||||||
description: "Confirm before reblogging a note",
|
description: "Confirm before reblogging a note",
|
||||||
type: SettingType.Boolean,
|
type: SettingType.Boolean,
|
||||||
value: false,
|
value: false,
|
||||||
path: SettingPages.Behaviour,
|
path: SettingPages.Behaviour,
|
||||||
|
notImplemented: true,
|
||||||
} as Setting<SettingType.Boolean>,
|
} as Setting<SettingType.Boolean>,
|
||||||
{
|
{
|
||||||
id: "confirm-favourite",
|
id: SettingIds.ConfirmFavourite,
|
||||||
title: "Confirm Favourite",
|
title: "Confirm Favourite",
|
||||||
description: "Confirm before favouriting a note",
|
description: "Confirm before favouriting a note",
|
||||||
type: SettingType.Boolean,
|
type: SettingType.Boolean,
|
||||||
value: false,
|
value: false,
|
||||||
path: SettingPages.Behaviour,
|
path: SettingPages.Behaviour,
|
||||||
|
notImplemented: true,
|
||||||
} as Setting<SettingType.Boolean>,
|
} as Setting<SettingType.Boolean>,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export type Settings = typeof settings;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue