feat: Add multi-account support, more options for posts, UI improvements

This commit is contained in:
Jesse Wierzbinski 2024-06-09 17:24:55 -10:00
parent 48954baf06
commit ef9a6f1da4
No known key found for this signature in database
36 changed files with 649 additions and 344 deletions

38
app.vue
View file

@ -13,13 +13,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { convert } from "html-to-text"; import { convert } from "html-to-text";
import "iconify-icon"; import "iconify-icon";
import { nanoid } from "nanoid";
// Use SSR-safe IDs for Headless UI // Use SSR-safe IDs for Headless UI
provideHeadlessUseId(() => useId()); provideHeadlessUseId(() => useId());
const code = useRequestURL().searchParams.get("code"); const code = useRequestURL().searchParams.get("code");
const appData = useAppData(); const appData = useAppData();
const tokenData = useTokenData(); const identity = useCurrentIdentity();
const client = useClient(tokenData); const identities = useIdentities();
const client = useClient();
const instance = useInstance(); const instance = useInstance();
const description = useExtendedDescription(client); const description = useExtendedDescription(client);
@ -56,8 +58,28 @@ if (code) {
code, code,
new URL("/", useRequestURL().origin).toString(), new URL("/", useRequestURL().origin).toString(),
) )
.then((res) => { .then(async (res) => {
tokenData.value = res.data; const tempClient = useClient(res.data).value;
const [accountOutput, instanceOutput] = await Promise.all([
tempClient.verifyAccountCredentials(),
tempClient.getInstance(),
]);
// Get account data
if (
!identities.value.find(
(i) => i.account.id === accountOutput.data.id,
)
)
identity.value = {
id: nanoid(),
tokens: res.data,
account: accountOutput.data,
instance: instanceOutput.data,
permissions: [],
emojis: [],
};
// Remove code from URL // Remove code from URL
window.history.replaceState( window.history.replaceState(
@ -65,10 +87,18 @@ if (code) {
document.title, document.title,
window.location.pathname, window.location.pathname,
); );
// Redirect to home
window.location.pathname = "/";
}); });
} }
} }
useListen("identity:change", (newIdentity) => {
identity.value = newIdentity;
window.location.pathname = "/";
});
useCacheRefresh(client); useCacheRefresh(client);
</script> </script>

BIN
bun.lockb

Binary file not shown.

View file

@ -1,5 +1,5 @@
<template> <template>
<ButtonsBase class="hover:bg-white/20 !rounded-sm !text-left flex flex-row gap-x-3 !ring-0 !p-4 sm:!p-2"> <ButtonsBase class="enabled:hover:bg-white/20 !rounded-sm !text-left flex flex-row gap-x-3 !ring-0 !p-4 sm:!p-2">
<iconify-icon :icon="icon" width="none" class="text-gray-200 size-5" aria-hidden="true" /> <iconify-icon :icon="icon" width="none" class="text-gray-200 size-5" aria-hidden="true" />
<slot /> <slot />
</ButtonsBase> </ButtonsBase>

View file

@ -71,7 +71,7 @@ const { input: content } = useTextareaAutosize({
const { Control_Enter, Command_Enter, Control_Alt } = useMagicKeys(); const { Control_Enter, Command_Enter, Control_Alt } = useMagicKeys();
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 me = useMe(); const identity = useCurrentIdentity();
const cw = ref(false); const cw = ref(false);
const cwContent = ref(""); const cwContent = ref("");
const markdown = ref(true); const markdown = ref(true);
@ -151,7 +151,7 @@ onMounted(() => {
useListen("composer:reply", (note: Status) => { useListen("composer:reply", (note: Status) => {
respondingTo.value = note; respondingTo.value = note;
respondingType.value = "reply"; respondingType.value = "reply";
if (note.account.id !== me.value?.id) if (note.account.id !== identity.value?.account.id)
content.value = `@${note.account.acct} `; content.value = `@${note.account.acct} `;
textarea.value?.focus(); textarea.value?.focus();
}); });
@ -159,7 +159,7 @@ onMounted(() => {
useListen("composer:quote", (note: Status) => { useListen("composer:quote", (note: Status) => {
respondingTo.value = note; respondingTo.value = note;
respondingType.value = "quote"; respondingType.value = "quote";
if (note.account.id !== me.value?.id) if (note.account.id !== identity.value?.account.id)
content.value = `@${note.account.acct} `; content.value = `@${note.account.acct} `;
textarea.value?.focus(); textarea.value?.focus();
}); });
@ -175,7 +175,7 @@ onMounted(() => {
})); }));
// Fetch source // Fetch source
const source = await client.value?.getStatusSource(note.id); const source = await client.value.getStatusSource(note.id);
if (source?.data) { if (source?.data) {
respondingTo.value = note; respondingTo.value = note;
@ -205,12 +205,11 @@ const canSubmit = computed(
(content.value?.trim().length > 0 || files.value.length > 0) && (content.value?.trim().length > 0 || files.value.length > 0) &&
content.value?.trim().length <= characterLimit.value, content.value?.trim().length <= characterLimit.value,
); );
const tokenData = useTokenData(); const client = useClient();
const client = useClient(tokenData);
const send = async () => { const send = async () => {
loading.value = true; loading.value = true;
if (!tokenData.value || !client.value) { if (!identity.value || !client.value) {
throw new Error("Not authenticated"); throw new Error("Not authenticated");
} }

View file

@ -10,14 +10,14 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import type { LysandClient } from "@lysand-org/client";
import { distance } from "fastest-levenshtein"; import { distance } from "fastest-levenshtein";
import type { UnwrapRef } from "vue"; import type { CustomEmoji } from "~/composables/Identities";
const props = defineProps<{ const props = defineProps<{
currentlyTypingEmoji: string | null; currentlyTypingEmoji: string | null;
}>(); }>();
const emojiRefs = ref<Element[]>([]); const emojiRefs = ref<Element[]>([]);
const customEmojis = useCustomEmojis();
const { Tab, ArrowRight, ArrowLeft, Enter } = useMagicKeys({ const { Tab, ArrowRight, ArrowLeft, Enter } = useMagicKeys({
passive: false, passive: false,
onEventFired(e) { onEventFired(e) {
@ -28,12 +28,15 @@ const { Tab, ArrowRight, ArrowLeft, Enter } = useMagicKeys({
e.preventDefault(); e.preventDefault();
}, },
}); });
const topEmojis = ref<UnwrapRef<typeof customEmojis> | null>(null); const identity = useCurrentIdentity();
const topEmojis = ref<CustomEmoji[] | null>(null);
const selectedEmojiIndex = ref<number | null>(null); const selectedEmojiIndex = ref<number | null>(null);
watchEffect(() => { watchEffect(() => {
if (!identity.value) return;
if (props.currentlyTypingEmoji !== null) if (props.currentlyTypingEmoji !== null)
topEmojis.value = customEmojis.value topEmojis.value = identity.value.emojis
.map((emoji) => ({ .map((emoji) => ({
...emoji, ...emoji,
distance: distance( distance: distance(

View file

@ -2,7 +2,7 @@
<div> <div>
<input type="file" ref="fileInput" @change="handleFileInput" style="display: none" multiple /> <input type="file" ref="fileInput" @change="handleFileInput" style="display: none" multiple />
<div class="flex flex-row gap-2 overflow-x-auto *:shrink-0 p-1 mb-4" v-if="files.length > 0"> <div class="flex flex-row gap-2 overflow-x-auto *:shrink-0 p-1 mb-4" v-if="files.length > 0">
<div v-for="(data) in files.toReversed()" :key="data.id" role="button" tabindex="0" <div v-for="(data) in files" :key="data.id" role="button" tabindex="0"
:class="['size-28 bg-dark-800 rounded flex items-center relative justify-center ring-1 ring-white/20 overflow-hidden', data.progress !== 1.0 && 'animate-pulse']" :class="['size-28 bg-dark-800 rounded flex items-center relative justify-center ring-1 ring-white/20 overflow-hidden', data.progress !== 1.0 && 'animate-pulse']"
@keydown.enter="removeFile(data.id)"> @keydown.enter="removeFile(data.id)">
<template v-if="data.file.type.startsWith('image/')"> <template v-if="data.file.type.startsWith('image/')">
@ -73,8 +73,7 @@ const files = defineModel<
required: true, required: true,
}); });
const tokenData = useTokenData(); const client = useClient();
const client = useClient(tokenData);
const fileInput = ref<HTMLInputElement | null>(null); const fileInput = ref<HTMLInputElement | null>(null);
const openFilePicker = () => { const openFilePicker = () => {
@ -165,7 +164,7 @@ const uploadFile = async (file: File) => {
return data; return data;
}); });
client.value?.uploadMedia(file).then((response) => { client.value.uploadMedia(file).then((response) => {
const attachment = response.data; const attachment = response.data;
files.value = files.value.map((data) => { files.value = files.value.map((data) => {

View file

@ -19,7 +19,7 @@
<Dialog.Content class="overflow-y-auto w-full max-h-full md:py-16"> <Dialog.Content class="overflow-y-auto w-full max-h-full md:py-16">
<div <div
class="relative overflow-hidden max-w-xl mx-auto rounded-lg bg-dark-700 ring-1 ring-dark-800 text-left shadow-xl transition-all w-full"> class="relative overflow-hidden max-w-xl mx-auto rounded-lg bg-dark-700 ring-1 ring-dark-800 text-left shadow-xl transition-all w-full">
<Composer v-if="instance" :instance="instance" /> <Composer v-if="instance" :instance="instance as any" />
</div> </div>
</Dialog.Content> </Dialog.Content>
</HeadlessTransitionChild> </HeadlessTransitionChild>
@ -32,6 +32,8 @@
<script lang="ts" setup> <script lang="ts" setup>
import { Dialog } from "@ark-ui/vue"; import { Dialog } from "@ark-ui/vue";
const open = ref(false); const open = ref(false);
const identity = useCurrentIdentity();
useListen("note:reply", async (note) => { useListen("note:reply", async (note) => {
open.value = true; open.value = true;
await nextTick(); await nextTick();
@ -48,11 +50,10 @@ useListen("note:edit", async (note) => {
useEvent("composer:edit", note); useEvent("composer:edit", note);
}); });
useListen("composer:open", () => { useListen("composer:open", () => {
if (tokenData.value) open.value = true; if (identity.value) open.value = true;
}); });
useListen("composer:close", () => { useListen("composer:close", () => {
open.value = false; open.value = false;
}); });
const tokenData = useTokenData();
const instance = useInstance(); const instance = useInstance();
</script> </script>

View file

@ -39,20 +39,18 @@ const id = useId();
// HACK: Fix the menu children not reacting to touch events as click for some reason // HACK: Fix the menu children not reacting to touch events as click for some reason
const registerClickHandlers = () => { const registerClickHandlers = () => {
const targetElement = document.querySelector(`.${id}`); const targetElements = document.querySelectorAll(`.${id} [data-part=item]`);
if (targetElement) { for (const el of targetElements) {
for (const el of targetElement.children) { el.addEventListener("touchstart", (e) => {
el.addEventListener("touchstart", (e) => { e.stopPropagation();
e.stopPropagation(); e.preventDefault();
e.preventDefault(); // Click all element children
// Click all element children for (const elChild of Array.from(el.children)) {
for (const elChild of Array.from(el.children)) { if (elChild instanceof HTMLElement) {
if (elChild instanceof HTMLElement) { elChild.click();
elChild.click();
}
} }
}); }
} });
} }
}; };

View file

@ -1,27 +1,27 @@
<template> <template>
<ClientOnly> <ClientOnly>
<div v-if="me" class="bg-dark-800 p-6 my-5 rounded ring-1 ring-white/5"> <div v-if="identity" class="bg-dark-800 z-0 p-6 my-5 relative overflow-hidden rounded ring-1 ring-white/5">
<div class="sm:flex sm:items-center sm:justify-between"> <div class="sm:flex sm:items-center sm:justify-between gap-3">
<div class="sm:flex sm:space-x-5"> <div class="sm:flex sm:space-x-5 grow">
<AvatarsCentered :src="me.avatar" <AvatarsCentered :src="identity.account.avatar"
class="mx-auto shrink-0 size-20 rounded overflow-hidden ring-1 ring-white/10" /> class="mx-auto shrink-0 size-20 rounded overflow-hidden ring-1 ring-white/10" />
<div class="mt-4 text-center sm:mt-0 sm:pt-1 sm:text-left"> <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">
<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="useParsedContent(me.display_name, []).value"></p> v-html="useParsedContent(identity.account.display_name, []).value"></p>
<p class="text-sm font-medium text-gray-500">@{{ me.acct }}</p>
</div> </div>
</div> </div>
<div class="mt-5 flex justify-center sm:mt-0"> <!-- <div class="mt-5 flex justify-center sm:mt-0">
<ButtonsSecondary @click="useEvent('composer:open')"> <ButtonsSecondary @click="useEvent('composer:open')">
Compose Compose
</ButtonsSecondary> </ButtonsSecondary>
</div> </div> -->
</div> </div>
</div> </div>
</ClientOnly> </ClientOnly>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
const me = useMe(); const identity = useCurrentIdentity();
</script> </script>

View file

@ -1,43 +1,46 @@
<template> <template>
<div aria-live="assertive" class="pointer-events-none fixed inset-0 flex items-end px-4 py-6 sm:items-start sm:p-6"> <Teleport to="body">
<div class="flex w-full flex-col items-center space-y-4 sm:items-end"> <div aria-live="assertive"
<!-- Notification panel, dynamically insert this into the live region when it needs to be displayed --> class="pointer-events-none fixed inset-0 flex items-end px-4 pt-6 pb-24 sm:pb-6 sm:items-start sm:p-6 z-50">
<TransitionGroup enter-active-class="transform ease-out duration-300 transition" <div class="flex w-full flex-col items-center space-y-4 sm:items-end">
enter-from-class="translate-y-2 opacity-0 sm:translate-y-0 sm:translate-x-2" <!-- Notification panel, dynamically insert this into the live region when it needs to be displayed -->
enter-to-class="translate-y-0 opacity-100 sm:translate-x-0" <TransitionGroup enter-active-class="transform ease-out duration-300 transition"
leave-active-class="transition transform ease-in duration-100" enter-from-class="translate-y-2 opacity-0 sm:translate-y-0 sm:translate-x-2"
leave-from-class="translate-y-0 opacity-100 sm:translate-x-0" enter-to-class="translate-y-0 opacity-100 sm:translate-x-0"
leave-to-class="translate-y-2 opacity-0 sm:translate-y-0 sm:translate-x-2"> leave-active-class="transition transform ease-in duration-100"
<div v-for="notification in notifications" :key="notification.id" leave-from-class="translate-y-0 opacity-100 sm:translate-x-0"
class="pointer-events-auto w-full max-w-sm overflow-hidden rounded-lg bg-dark-500 shadow-lg ring-1 ring-white/10"> leave-to-class="translate-y-2 opacity-0 sm:translate-y-0 sm:translate-x-2">
<div class="p-4"> <div v-for="notification in notifications" :key="notification.id"
<div class="flex items-start"> class="pointer-events-auto w-full max-w-sm overflow-hidden rounded-lg bg-dark-500 shadow-lg ring-1 ring-white/10">
<div class="shrink-0 h-6 w-6"> <div class="p-4">
<iconify-icon v-if="notification.type === 'success'" icon="tabler:check" height="none" <div class="flex items-start">
class="h-6 w-6 text-green-400" aria-hidden="true" /> <div class="shrink-0 h-6 w-6">
<iconify-icon v-else-if="notification.type === 'error'" icon="tabler:alert-triangle" <iconify-icon v-if="notification.type === 'success'" icon="tabler:check"
height="none" class="h-6 w-6 text-red-400" aria-hidden="true" /> height="none" class="h-6 w-6 text-green-400" aria-hidden="true" />
<iconify-icon v-else-if="notification.type === 'progress'" icon="tabler:loader" <iconify-icon v-else-if="notification.type === 'error'" icon="tabler:alert-triangle"
height="none" class="h-6 w-6 text-pink-500 animate-spin" aria-hidden="true" /> height="none" class="h-6 w-6 text-red-400" aria-hidden="true" />
</div> <iconify-icon v-else-if="notification.type === 'progress'" icon="tabler:loader"
<div class="ml-3 w-0 flex-1 pt-0.5"> height="none" class="h-6 w-6 text-pink-500 animate-spin" aria-hidden="true" />
<p class="text-sm font-semibold text-gray-50">{{ notification.title }}</p> </div>
<p class="mt-1 text-sm text-gray-400" v-if="notification.message">{{ <div class="ml-3 w-0 flex-1 pt-0.5">
notification.message }}</p> <p class="text-sm font-semibold text-gray-50">{{ notification.title }}</p>
</div> <p class="mt-1 text-sm text-gray-400" v-if="notification.message">{{
<div class="ml-4 flex flex-shrink-0"> notification.message }}</p>
<button type="button" title="Close this notification" </div>
@click="notifications.splice(notifications.indexOf(notification), 1); notification.onDismiss?.()" <div class="ml-4 flex flex-shrink-0">
class="inline-flex rounded-md text-gray-400 hover:text-gray-300 duration-200"> <button type="button" title="Close this notification"
<iconify-icon icon="tabler:x" class="h-5 w-5" aria-hidden="true" /> @click="notifications.splice(notifications.indexOf(notification), 1); notification.onDismiss?.()"
</button> class="inline-flex rounded-md text-gray-400 hover:text-gray-300 duration-200">
<iconify-icon icon="tabler:x" class="h-5 w-5" aria-hidden="true" />
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </TransitionGroup>
</TransitionGroup> </div>
</div> </div>
</div> </Teleport>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>

View file

@ -0,0 +1,92 @@
<template>
<DropdownsAdaptiveDropdown>
<template #button>
<slot>
<div class="rounded text-left flex flex-row gap-x-2 hover:scale-[95%] duration-100"
v-if="currentIdentity">
<div class="shrink-0">
<AvatarsCentered class="size-12 rounded ring-1 ring-white/5"
:src="currentIdentity.account.avatar" :alt="`${currentIdentity.account.acct}'s avatar'`" />
</div>
<div class="flex flex-col items-start p-1 justify-around grow overflow-hidden">
<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">
{{
currentIdentity.account.display_name }}
</div>
</div>
<span class="text-gray-400 text-xs line-clamp-1 break-all w-full">
Change account
</span>
</div>
</div>
<ButtonsBase v-else class="flex flex-row text-left items-center justify-start gap-3 text-lg hover:ring-1 ring-white/10
overflow-hidden h-12 w-full duration-200">
<iconify-icon icon="tabler:login" class="shrink-0 text-2xl" />
<span class="pr-28 line-clamp-1">Sign In</span>
</ButtonsBase>
</slot>
</template>
<template #items>
<div class="p-2">
<h3 class="text-gray-400 text-xs text-center md:text-left uppercase font-semibold">Switch to account
</h3>
</div>
<div class="px-2 py-4 md:py-2 flex flex-col gap-3 max-w-[100vw]">
<Menu.Item value="" v-for="identity of identities" class="hover:scale-[95%] duration-100">
<div class="flex flex-row gap-x-4">
<div class="shrink-0" data-part="item" @click="useEvent('identity:change', identity)">
<AvatarsCentered class="h-12 w-12 rounded ring-1 ring-white/5"
:src="identity.account.avatar" :alt="`${identity.account.acct}'s avatar'`" />
</div>
<div data-part="item" class="flex flex-col items-start justify-around grow overflow-hidden"
@click="useEvent('identity:change', identity)">
<div class="flex flex-row items-center justify-between w-full">
<div class="font-semibold text-gray-200 line-clamp-1 break-all">
{{
identity.account.display_name }}
</div>
</div>
<span class="text-gray-400 text-sm line-clamp-1 break-all w-full">
@{{
identity.account.acct
}}
</span>
</div>
<button data-part="item"
class="shrink-0 ml-6 size-12 ring-white/5 ring-1 flex items-center justify-center rounded"
@click="$emit('signOut', identity.id)">
<iconify-icon icon="tabler:logout" class="size-6 text-gray-200" width="none" />
</button>
</div>
</Menu.Item>
<Menu.Item value="">
<button @click="$emit('signIn')" 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:user-plus" 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">
Add new account
</div>
</div>
</button>
</Menu.Item>
</div>
</template>
</DropdownsAdaptiveDropdown>
</template>
<script lang="ts" setup>
import { Menu } from "@ark-ui/vue";
const identities = useIdentities();
const currentIdentity = useCurrentIdentity();
defineEmits<{
signIn: [];
signOut: [identityId: string];
}>();
</script>

View file

@ -1,6 +1,6 @@
<template> <template>
<aside <aside
class="fixed h-dvh z-20 md:flex hidden flex-col p-4 bg-dark-800 gap-10 max-w-20 hover:max-w-72 duration-200 group ring-1 ring-dark-500" class="fixed h-dvh z-10 md:flex hidden flex-col p-4 bg-dark-800 gap-10 max-w-20 hover:max-w-72 duration-200 group ring-1 ring-dark-500"
role="complementary"> role="complementary">
<NuxtLink href="/"> <NuxtLink href="/">
<img crossorigin="anonymous" class="size-11 rounded ring-1 ring-white/10 hover:scale-105 duration-200" <img crossorigin="anonymous" class="size-11 rounded ring-1 ring-white/10 hover:scale-105 duration-200"
@ -25,28 +25,18 @@
<h3 class="font-semibold text-gray-300 text-xs uppercase opacity-0 group-hover:opacity-100 duration-200"> <h3 class="font-semibold text-gray-300 text-xs uppercase opacity-0 group-hover:opacity-100 duration-200">
Account</h3> Account</h3>
<ClientOnly> <ClientOnly>
<ButtonsBase v-if="tokenData" @click="signOut().finally(() => loadingAuth = false)" <SidebarsAccountPicker @sign-in="signIn().finally(() => loadingAuth = false)" />
:loading="loadingAuth" <NuxtLink href="/register" v-if="!identity">
class="flex flex-row text-left items-center justify-start gap-3 text-lg hover:ring-1 ring-white/10 overflow-hidden h-12 w-full duration-200">
<iconify-icon icon="tabler:logout" class="shrink-0 text-2xl" />
<span class="pr-28 line-clamp-1">Sign Out</span>
</ButtonsBase>
<ButtonsBase v-else @click="signIn().finally(() => loadingAuth = false)" :loading="loadingAuth"
class="flex flex-row text-left items-center justify-start gap-3 text-lg hover:ring-1 ring-white/10 overflow-hidden h-12 w-full duration-200">
<iconify-icon icon="tabler:login" class="shrink-0 text-2xl" />
<span class="pr-28 line-clamp-1">Sign In</span>
</ButtonsBase>
<NuxtLink href="/register" v-if="!tokenData">
<ButtonsBase <ButtonsBase
class="flex flex-row text-left items-center justify-start gap-3 text-lg hover:ring-1 ring-white/10 overflow-hidden h-12 w-full duration-200"> class="flex flex-row text-left items-center justify-start gap-3 text-lg hover:ring-1 ring-white/10 overflow-hidden h-12 w-full duration-200">
<iconify-icon icon="tabler:certificate" class="shrink-0 text-2xl" /> <iconify-icon icon="tabler:certificate" class="shrink-0 text-2xl" />
<span class="pr-28 line-clamp-1">Register</span> <span class="pr-28 line-clamp-1">Register</span>
</ButtonsBase> </ButtonsBase>
</NuxtLink> </NuxtLink>
<h3 v-if="tokenData" <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>
<ButtonsBase v-if="tokenData" @click="compose" title="Open composer (shortcut: n)" <ButtonsBase v-if="identity" @click="compose" title="Open composer (shortcut: n)"
class="flex flex-row text-left items-center justify-start gap-3 text-lg hover:ring-1 ring-white/10 bg-gradient-to-tr from-pink-300 via-purple-300 to-indigo-400 overflow-hidden h-12 w-full duration-200"> class="flex flex-row text-left items-center justify-start gap-3 text-lg hover:ring-1 ring-white/10 bg-gradient-to-tr from-pink-300 via-purple-300 to-indigo-400 overflow-hidden h-12 w-full duration-200">
<iconify-icon icon="tabler:writing" class="shrink-0 text-2xl" /> <iconify-icon icon="tabler:writing" class="shrink-0 text-2xl" />
<span class="pr-28 line-clamp-1">Compose</span> <span class="pr-28 line-clamp-1">Compose</span>
@ -98,37 +88,14 @@
<span class="text-xs">Update</span> <span class="text-xs">Update</span>
</button> </button>
</ClientOnly> </ClientOnly>
<DropdownsAdaptiveDropdown v-else> <SidebarsAccountPicker v-else @sign-in="signIn().finally(() => loadingAuth = false)"
<template #button> @sign-out="id => signOut(id).finally(() => loadingAuth = false)">
<button class="flex flex-col items-center justify-center p-2 rounded"> <button class="flex flex-col items-center justify-center p-2 rounded">
<iconify-icon icon="tabler:user" class="text-2xl" /> <iconify-icon icon="tabler:user" class="text-2xl" />
<span class="text-xs">Account</span> <span class="text-xs">Account</span>
</button> </button>
</template> </SidebarsAccountPicker>
<button @click="compose" v-if="identity"
<template #items>
<Menu.Item value="" v-if="tokenData">
<ButtonsDropdownElement icon="tabler:logout" class="w-full"
@click="signOut().finally(() => loadingAuth = false)" :loading="loadingAuth">
Sign Out
</ButtonsDropdownElement>
</Menu.Item>
<Menu.Item value="" v-if="!tokenData">
<ButtonsDropdownElement icon="tabler:login" class="w-full"
@click="signIn().finally(() => loadingAuth = false)" :loading="loadingAuth">
Sign In
</ButtonsDropdownElement>
</Menu.Item>
<Menu.Item value="" v-if="!tokenData">
<NuxtLink href="/register">
<ButtonsDropdownElement icon="tabler:certificate" class="w-full">
Register
</ButtonsDropdownElement>
</NuxtLink>
</Menu.Item>
</template>
</DropdownsAdaptiveDropdown>
<button @click="compose" v-if="tokenData"
class="flex flex-col items-center justify-center p-2 rounded bg-gradient-to-tr from-pink-300/70 via-purple-300/70 to-indigo-400/70"> class="flex flex-col items-center justify-center p-2 rounded bg-gradient-to-tr from-pink-300/70 via-purple-300/70 to-indigo-400/70">
<iconify-icon icon="tabler:writing" class="text-2xl" /> <iconify-icon icon="tabler:writing" class="text-2xl" />
<span class="text-xs">Compose</span> <span class="text-xs">Compose</span>
@ -167,16 +134,16 @@ const timelines = ref([
const visibleTimelines = computed(() => const visibleTimelines = computed(() =>
timelines.value.filter( timelines.value.filter(
(timeline) => !timeline.requiresAuth || tokenData.value, (timeline) => !timeline.requiresAuth || identity.value,
), ),
); );
const loadingAuth = ref(false); const loadingAuth = ref(false);
const appData = useAppData(); const appData = useAppData();
const tokenData = useTokenData(); const identity = useCurrentIdentity();
const identities = useIdentities();
const client = useClient(); const client = useClient();
const me = useMe();
const compose = () => { const compose = () => {
useEvent("composer:open"); useEvent("composer:open");
@ -185,7 +152,7 @@ const compose = () => {
const signIn = async () => { const signIn = async () => {
loadingAuth.value = true; loadingAuth.value = true;
const output = await client.value?.createApp("Lysand", { const output = await client.value.createApp("Lysand", {
scopes: ["read", "write", "follow", "push"], scopes: ["read", "write", "follow", "push"],
redirect_uris: new URL("/", useRequestURL().origin).toString(), redirect_uris: new URL("/", useRequestURL().origin).toString(),
website: useBaseUrl().value, website: useBaseUrl().value,
@ -198,7 +165,7 @@ const signIn = async () => {
appData.value = output.data; appData.value = output.data;
const url = await client.value?.generateAuthUrl( const url = await client.value.generateAuthUrl(
output.data.client_id, output.data.client_id,
output.data.client_secret, output.data.client_secret,
{ {
@ -215,11 +182,20 @@ const signIn = async () => {
window.location.href = url; window.location.href = url;
}; };
const signOut = async () => { const signOut = async (id?: string) => {
loadingAuth.value = true; loadingAuth.value = true;
if (!appData.value || !tokenData.value) { if (!appData.value || !identity.value) {
console.error("No app or token data to sign out"); console.error("No app or identity data to sign out");
return;
}
const identityToRevoke = id
? identities.value.find((i) => i.id === id)
: identity.value;
if (!identityToRevoke) {
console.error("No identity to revoke");
return; return;
} }
@ -227,13 +203,22 @@ const signOut = async () => {
await client.value await client.value
?.revokeToken( ?.revokeToken(
appData.value.client_id, appData.value.client_id,
tokenData.value.access_token, identityToRevoke.tokens.access_token,
tokenData.value.access_token, identityToRevoke.tokens.access_token,
) )
.catch(() => {}); .catch(() => {});
tokenData.value = null; if (id === identity.value.id) {
me.value = null; identity.value = null;
await navigateTo("/"); await navigateTo("/");
return;
}
identities.value = identities.value.filter((i) => i.id !== id);
await useEvent("notification:new", {
type: "success",
title: "Signed out",
message: "Account signed out successfully",
});
}; };
</script> </script>

View file

@ -25,7 +25,7 @@
class="absolute top-2 right-2 p-1 bg-dark-800 ring-1 ring-white/5 text-white text-xs rounded size-8"> class="absolute top-2 right-2 p-1 bg-dark-800 ring-1 ring-white/5 text-white text-xs rounded size-8">
<iconify-icon icon="tabler:alt" width="none" class="size-6" /> <iconify-icon icon="tabler:alt" width="none" class="size-6" />
</Popover.Trigger> </Popover.Trigger>
<Popover.Positioner> <Popover.Positioner class="!z-10">
<Popover.Content class="p-4 bg-dark-400 rounded text-sm ring-1 ring-dark-100 shadow-lg text-gray-300"> <Popover.Content class="p-4 bg-dark-400 rounded text-sm ring-1 ring-dark-100 shadow-lg text-gray-300">
<Popover.Description>{{ attachment.description }}</Popover.Description> <Popover.Description>{{ attachment.description }}</Popover.Description>
</Popover.Content> </Popover.Content>

View file

@ -20,19 +20,19 @@
<div v-if="showInteractions" <div v-if="showInteractions"
class="mt-6 flex flex-row items-stretch disabled:*:opacity-70 [&>button]:max-w-28 disabled:*:cursor-not-allowed relative justify-around text-sm h-10 hover:enabled:[&>button]:bg-dark-800 [&>button]:duration-200 [&>button]:rounded [&>button]:flex [&>button]:flex-1 [&>button]:flex-row [&>button]:items-center [&>button]:justify-center"> class="mt-6 flex flex-row items-stretch disabled:*:opacity-70 [&>button]:max-w-28 disabled:*:cursor-not-allowed relative justify-around text-sm h-10 hover:enabled:[&>button]:bg-dark-800 [&>button]:duration-200 [&>button]:rounded [&>button]:flex [&>button]:flex-1 [&>button]:flex-row [&>button]:items-center [&>button]:justify-center">
<button class="group" @click="outputtedNote && useEvent('note:reply', outputtedNote)" <button class="group" @click="outputtedNote && useEvent('note:reply', outputtedNote)"
:disabled="!isSignedIn"> :disabled="!identity">
<iconify-icon width="1.25rem" height="1.25rem" icon="tabler:arrow-back-up" <iconify-icon width="1.25rem" height="1.25rem" icon="tabler:arrow-back-up"
class="text-gray-200 group-hover:group-enabled:text-blue-600" aria-hidden="true" /> class="text-gray-200 group-hover:group-enabled:text-blue-600" aria-hidden="true" />
<span class="text-gray-400 mt-0.5 ml-2">{{ numberFormat(outputtedNote?.replies_count) }}</span> <span class="text-gray-400 mt-0.5 ml-2">{{ numberFormat(outputtedNote?.replies_count) }}</span>
</button> </button>
<button class="group" @click="likeFn" :disabled="!isSignedIn"> <button class="group" @click="likeFn" :disabled="!identity">
<iconify-icon width="1.25rem" height="1.25rem" icon="tabler:heart" v-if="!outputtedNote?.favourited" <iconify-icon width="1.25rem" height="1.25rem" icon="tabler:heart" v-if="!outputtedNote?.favourited"
class="size-5 text-gray-200 group-hover:group-enabled:text-pink-600" aria-hidden="true" /> class="size-5 text-gray-200 group-hover:group-enabled:text-pink-600" aria-hidden="true" />
<iconify-icon width="1.25rem" height="1.25rem" icon="tabler:heart-filled" v-else <iconify-icon width="1.25rem" height="1.25rem" icon="tabler:heart-filled" v-else
class="size-5 text-pink-600 group-hover:group-enabled:text-gray-200" aria-hidden="true" /> class="size-5 text-pink-600 group-hover:group-enabled:text-gray-200" aria-hidden="true" />
<span class="text-gray-400 mt-0.5 ml-2">{{ numberFormat(outputtedNote?.favourites_count) }}</span> <span class="text-gray-400 mt-0.5 ml-2">{{ numberFormat(outputtedNote?.favourites_count) }}</span>
</button> </button>
<button class="group" @click="reblogFn" :disabled="!isSignedIn"> <button class="group" @click="reblogFn" :disabled="!identity">
<iconify-icon width="1.25rem" height="1.25rem" icon="tabler:repeat" v-if="!outputtedNote?.reblogged" <iconify-icon width="1.25rem" height="1.25rem" icon="tabler:repeat" v-if="!outputtedNote?.reblogged"
class="size-5 text-gray-200 group-hover:group-enabled:text-green-600" aria-hidden="true" /> class="size-5 text-gray-200 group-hover:group-enabled:text-green-600" aria-hidden="true" />
<iconify-icon width="1.25rem" height="1.25rem" icon="tabler:repeat" v-else <iconify-icon width="1.25rem" height="1.25rem" icon="tabler:repeat" v-else
@ -40,7 +40,7 @@
<span class="text-gray-400 mt-0.5 ml-2">{{ numberFormat(outputtedNote?.reblogs_count) }}</span> <span class="text-gray-400 mt-0.5 ml-2">{{ numberFormat(outputtedNote?.reblogs_count) }}</span>
</button> </button>
<button class="group" @click="outputtedNote && useEvent('note:quote', outputtedNote)" <button class="group" @click="outputtedNote && useEvent('note:quote', outputtedNote)"
:disabled="!isSignedIn"> :disabled="!identity">
<iconify-icon width="1.25rem" height="1.25rem" icon="tabler:quote" <iconify-icon width="1.25rem" height="1.25rem" icon="tabler:quote"
class="size-5 text-gray-200 group-hover:group-enabled:text-blue-600" aria-hidden="true" /> class="size-5 text-gray-200 group-hover:group-enabled:text-blue-600" aria-hidden="true" />
<span class="text-gray-400 mt-0.5 ml-2">{{ numberFormat(0) }}</span> <span class="text-gray-400 mt-0.5 ml-2">{{ numberFormat(0) }}</span>
@ -53,30 +53,93 @@
</template> </template>
<template #items> <template #items>
<Menu.Item value="" v-if="isSignedIn && outputtedNote?.account.id === me?.id"> <Menu.ItemGroup>
<ButtonsDropdownElement @click="outputtedNote && useEvent('note:edit', outputtedNote)" <Menu.Item value="" v-if="isMyAccount">
icon="tabler:pencil" class="w-full"> <ButtonsDropdownElement @click="outputtedNote && useEvent('note:edit', outputtedNote)"
Edit icon="tabler:pencil" class="w-full">
</ButtonsDropdownElement> Edit
</Menu.Item> </ButtonsDropdownElement>
<Menu.Item value=""> </Menu.Item>
<ButtonsDropdownElement @click="copy(JSON.stringify(outputtedNote, null, 4))" <Menu.Item value="">
icon="tabler:code" class="w-full"> <ButtonsDropdownElement @click="copy(JSON.stringify(outputtedNote, null, 4))"
Copy API icon="tabler:code" class="w-full">
Response Copy API
</ButtonsDropdownElement> Response
</Menu.Item> </ButtonsDropdownElement>
<Menu.Item value=""> </Menu.Item>
<ButtonsDropdownElement @click="copy(url)" icon="tabler:link" class="w-full"> <Menu.Item value="">
Copy Link <ButtonsDropdownElement @click="copy(url)" icon="tabler:link" class="w-full">
</ButtonsDropdownElement> Copy Link
</Menu.Item> </ButtonsDropdownElement>
<Menu.Item value=""> </Menu.Item>
<ButtonsDropdownElement @click="remove" icon="tabler:backspace" :disabled="!isSignedIn" <Menu.Item value="" v-if="outputtedNote?.url && isRemote">
class="w-full border-r-2 border-red-500"> <ButtonsDropdownElement @click="copy(outputtedNote.url)" icon="tabler:link"
Delete class="w-full">
</ButtonsDropdownElement> Copy Link (Origin)
</Menu.Item> </ButtonsDropdownElement>
</Menu.Item>
<Menu.Item value="" v-if="outputtedNote?.url && isRemote">
<ButtonsDropdownElement @click="openBlank(outputtedNote.url)"
icon="tabler:external-link" class="w-full">
View on Remote
</ButtonsDropdownElement>
</Menu.Item>
<Menu.Item value="" v-if="isMyAccount">
<ButtonsDropdownElement @click="remove" icon="tabler:backspace" :disabled="!identity"
class="w-full border-r-2 border-red-500">
Delete
</ButtonsDropdownElement>
</Menu.Item>
</Menu.ItemGroup>
<hr class="border-white/10 rounded" />
<Menu.ItemGroup>
<Menu.Item value="">
<ButtonsDropdownElement @click="outputtedNote && useEvent('note:reply', outputtedNote)"
icon="tabler:arrow-back-up" class="w-full">
Reply
</ButtonsDropdownElement>
</Menu.Item>
<Menu.Item value="">
<ButtonsDropdownElement @click="likeFn" icon="tabler:heart" class="w-full"
v-if="!outputtedNote?.favourited">
Like
</ButtonsDropdownElement>
<ButtonsDropdownElement @click="likeFn" icon="tabler:heart-filled" class="w-full"
v-else>
Unlike
</ButtonsDropdownElement>
</Menu.Item>
<Menu.Item value="">
<ButtonsDropdownElement @click="reblogFn" icon="tabler:repeat" class="w-full"
v-if="!outputtedNote?.reblogged">
Reblog
</ButtonsDropdownElement>
<ButtonsDropdownElement @click="reblogFn" icon="tabler:repeat" class="w-full" v-else>
Unreblog
</ButtonsDropdownElement>
</Menu.Item>
<Menu.Item value="">
<ButtonsDropdownElement @click="outputtedNote && useEvent('note:quote', outputtedNote)"
icon="tabler:quote" class="w-full">
Quote
</ButtonsDropdownElement>
</Menu.Item>
</Menu.ItemGroup>
<hr class="border-white/10 rounded" />
<Menu.ItemGroup>
<Menu.Item value="">
<ButtonsDropdownElement @click="outputtedNote && useEvent('note:report', outputtedNote)"
icon="tabler:flag" class="w-full"
:disabled="!permissions.includes(RolePermissions.MANAGE_OWN_REPORTS)">
Report
</ButtonsDropdownElement>
</Menu.Item>
<Menu.Item value="" v-if="permissions.includes(RolePermissions.MANAGE_ACCOUNTS)">
<ButtonsDropdownElement icon="tabler:shield-bolt" class="w-full">
Open Moderation Panel
</ButtonsDropdownElement>
</Menu.Item>
</Menu.ItemGroup>
</template> </template>
</DropdownsAdaptiveDropdown> </DropdownsAdaptiveDropdown>
</div> </div>
@ -109,10 +172,8 @@ useListen("composer:send-edit", (note) => {
} }
}); });
const tokenData = useTokenData(); const client = useClient();
const isSignedIn = useSignedIn(); const identity = useCurrentIdentity();
const me = useMe();
const client = useClient(tokenData);
const { const {
loaded, loaded,
note: outputtedNote, note: outputtedNote,
@ -126,7 +187,16 @@ const {
reblogDisplayName, reblogDisplayName,
} = useNoteData(noteRef, client); } = useNoteData(noteRef, client);
const openBlank = (url: string) => window.open(url, "_blank");
const { copy } = useClipboard(); const { copy } = useClipboard();
const isMyAccount = computed(
() => identity.value?.account.id === outputtedNote.value?.account.id,
);
const isRemote = computed(() =>
outputtedNote.value?.account.acct.includes("@"),
);
const permissions = usePermissions();
const numberFormat = (number = 0) => const numberFormat = (number = 0) =>
new Intl.NumberFormat(undefined, { new Intl.NumberFormat(undefined, {
notation: "compact", notation: "compact",
@ -137,7 +207,7 @@ const numberFormat = (number = 0) =>
const likeFn = async () => { const likeFn = async () => {
if (!outputtedNote.value) return; if (!outputtedNote.value) return;
if (outputtedNote.value.favourited) { if (outputtedNote.value.favourited) {
const output = await client.value?.unfavouriteStatus( const output = await client.value.unfavouriteStatus(
outputtedNote.value.id, outputtedNote.value.id,
); );
@ -145,7 +215,7 @@ const likeFn = async () => {
noteRef.value = output.data; noteRef.value = output.data;
} }
} else { } else {
const output = await client.value?.favouriteStatus( const output = await client.value.favouriteStatus(
outputtedNote.value.id, outputtedNote.value.id,
); );
@ -158,7 +228,7 @@ const likeFn = async () => {
const reblogFn = async () => { const reblogFn = async () => {
if (!outputtedNote.value) return; if (!outputtedNote.value) return;
if (outputtedNote.value?.reblogged) { if (outputtedNote.value?.reblogged) {
const output = await client.value?.unreblogStatus( const output = await client.value.unreblogStatus(
outputtedNote.value.id, outputtedNote.value.id,
); );
@ -166,7 +236,7 @@ const reblogFn = async () => {
noteRef.value = output.data; noteRef.value = output.data;
} }
} else { } else {
const output = await client.value?.reblogStatus(outputtedNote.value.id); const output = await client.value.reblogStatus(outputtedNote.value.id);
if (output?.data.reblog) { if (output?.data.reblog) {
noteRef.value = output.data.reblog; noteRef.value = output.data.reblog;

View file

@ -15,7 +15,6 @@ const props = defineProps<{
account_id: string | null; account_id: string | null;
}>(); }>();
const tokenData = useTokenData(); const client = useClient();
const client = useClient(tokenData);
const account = useAccount(client, props.account_id); const account = useAccount(client, props.account_id);
</script> </script>

View file

@ -9,18 +9,18 @@
class="h-32 w-32 -mt-[4.5rem] z-10 shrink-0 rounded ring-2 ring-dark-800" /> class="h-32 w-32 -mt-[4.5rem] z-10 shrink-0 rounded ring-2 ring-dark-800" />
<ClientOnly> <ClientOnly>
<ButtonsSecondary v-if="account && account?.id === me?.id">Edit Profile <ButtonsSecondary v-if="account && account?.id === identity?.account?.id">Edit Profile
</ButtonsSecondary> </ButtonsSecondary>
<ButtonsSecondary :loading="isLoading" @click="follow()" <ButtonsSecondary :loading="isLoading" @click="follow()"
v-if="account && account?.id !== me?.id && relationship && !relationship.following && !relationship.requested"> v-if="account && account?.id !== identity?.account?.id && relationship && !relationship.following && !relationship.requested">
<span>Follow</span> <span>Follow</span>
</ButtonsSecondary> </ButtonsSecondary>
<ButtonsSecondary :loading="isLoading" @click="unfollow()" <ButtonsSecondary :loading="isLoading" @click="unfollow()"
v-if="account && account?.id !== me?.id && relationship && relationship.following"> v-if="account && account?.id !== identity?.account?.id && relationship && relationship.following">
<span>Unfollow</span> <span>Unfollow</span>
</ButtonsSecondary> </ButtonsSecondary>
<ButtonsSecondary :loading="isLoading" :disabled="true" <ButtonsSecondary :loading="isLoading" :disabled="true"
v-if="account && account?.id !== me?.id && relationship && !relationship.following && relationship.requested"> v-if="account && account?.id !== identity?.account?.id && relationship && !relationship.following && relationship.requested">
<span>Requested</span> <span>Requested</span>
</ButtonsSecondary> </ButtonsSecondary>
</ClientOnly> </ClientOnly>
@ -103,14 +103,13 @@ const props = defineProps<{
}>(); }>();
const skeleton = computed(() => !props.account); const skeleton = computed(() => !props.account);
const tokenData = useTokenData(); const identity = useCurrentIdentity();
const me = useMe(); const client = useClient();
const client = useClient(tokenData);
const accountId = computed(() => props.account?.id ?? null); const accountId = computed(() => props.account?.id ?? null);
const { relationship, isLoading } = useRelationship(client, accountId); const { relationship, isLoading } = useRelationship(client, accountId);
const follow = () => { const follow = () => {
if (!tokenData || !props.account || !relationship.value) return; if (!identity.value || !props.account || !relationship.value) return;
relationship.value = { relationship.value = {
...relationship.value, ...relationship.value,
following: true, following: true,
@ -118,7 +117,7 @@ const follow = () => {
}; };
const unfollow = () => { const unfollow = () => {
if (!tokenData || !props.account || !relationship.value) return; if (!identity.value || !props.account || !relationship.value) return;
relationship.value = { relationship.value = {
...relationship.value, ...relationship.value,
following: false, following: false,

View file

@ -1,5 +1,5 @@
<template> <template>
<NuxtLink :href="accountUrl" class="flex flex-row"> <component :is="disableLink ? 'div' : NuxtLink" :href="accountUrl" class="flex flex-row">
<Skeleton :enabled="!account" shape="rect" class="!h-12 w-12"> <Skeleton :enabled="!account" shape="rect" class="!h-12 w-12">
<div class="shrink-0"> <div class="shrink-0">
<AvatarsCentered class="h-12 w-12 rounded ring-1 ring-white/5" :src="account?.avatar" <AvatarsCentered class="h-12 w-12 rounded ring-1 ring-white/5" :src="account?.avatar"
@ -23,14 +23,16 @@
</Skeleton> </Skeleton>
</span> </span>
</div> </div>
</NuxtLink> </component>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import type { Account } from "~/types/mastodon/account"; import type { Account } from "~/types/mastodon/account";
import { NuxtLink } from "#components";
const props = defineProps<{ const props = defineProps<{
account?: Account; account?: Account;
disableLink?: boolean;
}>(); }>();
const accountUrl = props.account && `/@${props.account.acct}`; const accountUrl = props.account && `/@${props.account.acct}`;

View file

@ -3,8 +3,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
const tokenData = useTokenData(); const client = useClient();
const client = useClient(tokenData);
const timelineParameters = ref({}); const timelineParameters = ref({});
const { timeline, loadNext, loadPrev } = useHomeTimeline( const { timeline, loadNext, loadPrev } = useHomeTimeline(
client.value, client.value,

View file

@ -14,8 +14,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
const tokenData = useTokenData(); const client = useClient();
const client = useClient(tokenData);
const isLoading = ref(true); const isLoading = ref(true);

View file

@ -1,28 +1,27 @@
import type { LysandClient } from "@lysand-org/client"; import type { LysandClient } from "@lysand-org/client";
import { type RolePermissions, useCurrentIdentity } from "./Identities";
export const useCacheRefresh = (client: MaybeRef<LysandClient | null>) => { export const useCacheRefresh = (client: MaybeRef<LysandClient | null>) => {
if (process.server) return; if (process.server) return;
const tokenData = useTokenData(); const identity = useCurrentIdentity();
const me = useMe();
const instance = useInstance(); const instance = useInstance();
const customEmojis = useCustomEmojis();
// Refresh custom emojis and instance data and me on every reload // Refresh custom emojis and instance data and me on every reload
watchEffect(async () => { watchEffect(async () => {
console.log("Clearing cache"); console.info("Refreshing emoji, instance and account cache");
if (tokenData.value) { if (identity.value) {
await toValue(client) toValue(client)
?.verifyAccountCredentials() ?.verifyAccountCredentials()
.then((res) => { .then((res) => {
me.value = res.data; if (identity.value) identity.value.account = res.data;
}) })
.catch((err) => { .catch((err) => {
const code = err.response.status; const code = err.response.status;
if (code === 401) { if (code === 401) {
// Reset tokenData // Reset tokenData
tokenData.value = null; identity.value = null;
useEvent("notification:new", { useEvent("notification:new", {
type: "error", type: "error",
title: "Your session has expired", title: "Your session has expired",
@ -32,10 +31,25 @@ export const useCacheRefresh = (client: MaybeRef<LysandClient | null>) => {
} }
}); });
await toValue(client) toValue(client)
?.getInstanceCustomEmojis() ?.getInstanceCustomEmojis()
.then((res) => { .then((res) => {
customEmojis.value = res.data; if (identity.value) identity.value.emojis = res.data;
});
toValue(client)
?.getRoles()
.then((res) => {
const roles = res.data;
// Get all permissions and deduplicate
const permissions = roles
.flatMap((r) => r.permissions)
.filter((p, i, arr) => arr.indexOf(p) === i);
if (identity.value)
identity.value.permissions =
permissions as unknown as RolePermissions[];
}); });
} }

View file

@ -1,6 +0,0 @@
import type { Emoji } from "~/types/mastodon/emoji";
export const useCustomEmojis = () => {
// Cache in localStorage
return useLocalStorage<Emoji[]>("lysand:custom_emojis", []);
};

View file

@ -1,6 +1,7 @@
import mitt from "mitt"; import mitt from "mitt";
import type { Attachment } from "~/types/mastodon/attachment"; import type { Attachment } from "~/types/mastodon/attachment";
import type { Status } from "~/types/mastodon/status"; import type { Status } from "~/types/mastodon/status";
import type { Identity } from "./Identities";
export type NotificationEvent = { export type NotificationEvent = {
type: "error" | "success" | "progress"; type: "error" | "success" | "progress";
@ -19,6 +20,7 @@ type ApplicationEvents = {
"note:reblog": Status; "note:reblog": Status;
"note:unreblog": Status; "note:unreblog": Status;
"note:quote": Status; "note:quote": Status;
"note:report": Status;
"composer:open": undefined; "composer:open": undefined;
"composer:reply": Status; "composer:reply": Status;
"composer:quote": Status; "composer:quote": Status;
@ -28,6 +30,7 @@ type ApplicationEvents = {
"composer:close": undefined; "composer:close": undefined;
"notification:new": NotificationEvent; "notification:new": NotificationEvent;
"attachment:view": Attachment; "attachment:view": Attachment;
"identity:change": Identity;
}; };
const emitter = mitt<ApplicationEvents>(); const emitter = mitt<ApplicationEvents>();

119
composables/Identities.ts Normal file
View file

@ -0,0 +1,119 @@
import type { LysandClient, Token } from "@lysand-org/client";
import { StorageSerializers } from "@vueuse/core";
import type { Account } from "~/types/mastodon/account";
import type { Instance } from "./Instance";
export type Role = Awaited<ReturnType<LysandClient["getRole"]>>["data"];
export enum RolePermissions {
MANAGE_NOTES = "notes",
MANAGE_OWN_NOTES = "owner:note",
VIEW_NOTES = "read:note",
VIEW_NOTE_LIKES = "read:note_likes",
VIEW_NOTE_BOOSTS = "read:note_boosts",
MANAGE_ACCOUNTS = "accounts",
MANAGE_OWN_ACCOUNT = "owner:account",
VIEW_ACCOUNT_FOLLOWS = "read:account_follows",
MANAGE_LIKES = "likes",
MANAGE_OWN_LIKES = "owner:like",
MANAGE_BOOSTS = "boosts",
MANAGE_OWN_BOOSTS = "owner:boost",
VIEW_ACCOUNTS = "read:account",
MANAGE_EMOJIS = "emojis",
VIEW_EMOJIS = "read:emoji",
MANAGE_OWN_EMOJIS = "owner:emoji",
MANAGE_MEDIA = "media",
MANAGE_OWN_MEDIA = "owner:media",
MANAGE_BLOCKS = "blocks",
MANAGE_OWN_BLOCKS = "owner:block",
MANAGE_FILTERS = "filters",
MANAGE_OWN_FILTERS = "owner:filter",
MANAGE_MUTES = "mutes",
MANAGE_OWN_MUTES = "owner:mute",
MANAGE_REPORTS = "reports",
MANAGE_OWN_REPORTS = "owner:report",
MANAGE_SETTINGS = "settings",
MANAGE_OWN_SETTINGS = "owner:settings",
MANAGE_ROLES = "roles",
MANAGE_NOTIFICATIONS = "notifications",
MANAGE_OWN_NOTIFICATIONS = "owner:notification",
MANAGE_FOLLOWS = "follows",
MANAGE_OWN_FOLLOWS = "owner:follow",
MANAGE_OWN_APPS = "owner:app",
SEARCH = "search",
VIEW_PUBLIC_TIMELINES = "public_timelines",
VIEW_PRIVATE_TIMELINES = "private_timelines",
IGNORE_RATE_LIMITS = "ignore_rate_limits",
IMPERSONATE = "impersonate",
MANAGE_INSTANCE = "instance",
MANAGE_INSTANCE_FEDERATION = "instance:federation",
MANAGE_INSTANCE_SETTINGS = "instance:settings",
OAUTH = "oauth",
}
export type CustomEmoji = Awaited<
ReturnType<LysandClient["getInstanceCustomEmojis"]>
>["data"][0];
export type Identity = {
id: string;
tokens: Token;
account: Account;
instance: Instance;
permissions: RolePermissions[];
emojis: CustomEmoji[];
};
export const useIdentities = (): Ref<Identity[]> => {
return useLocalStorage<Identity[]>("lysand:identities", [], {
serializer: StorageSerializers.object,
});
};
export const useCurrentIdentity = (): Ref<Identity | null> => {
const currentId = useLocalStorage<string | null>(
"lysand:identities:current",
null,
);
const identities = useIdentities();
const current = ref(
identities.value.find((i) => i.id === currentId.value) ?? null,
);
watch(identities, (ids) => {
if (ids.length === 0) {
current.value = null;
}
});
watch(
current,
(newCurrent) => {
if (newCurrent) {
currentId.value = newCurrent.id;
// If the identity is updated, update the identity in the list
if (identities.value.find((i) => i.id === newCurrent.id))
identities.value = identities.value.map((i) =>
i.id === newCurrent.id ? newCurrent : i,
);
// If the identity is not in the list, add it
else identities.value.push(newCurrent);
// Force update the identities
identities.value = [...identities.value];
} else {
identities.value = identities.value.filter(
(i) => i.id !== currentId.value,
);
if (identities.value.length > 0) {
currentId.value = identities.value[0].id;
} else {
currentId.value = null;
}
}
},
{ deep: true },
);
return current;
};

View file

@ -1,8 +0,0 @@
import { StorageSerializers } from "@vueuse/core";
import type { Account } from "~/types/mastodon/account";
export const useMe = () => {
return useLocalStorage<Account | null>("lysand:me", null, {
serializer: StorageSerializers.object,
});
};

View file

@ -1,18 +1,18 @@
import { LysandClient, type Token } from "@lysand-org/client"; import { LysandClient, type Token } from "@lysand-org/client";
import { useCurrentIdentity } from "./Identities";
export const useClient = ( export const useClient = (
tokenData?: MaybeRef<Token | null>, customToken: MaybeRef<Token | null> = null,
disableOnServer = false, ): Ref<LysandClient> => {
): Ref<LysandClient | null> => { const identity = useCurrentIdentity();
if (disableOnServer && process.server) {
return ref(null);
}
return computed( return computed(
() => () =>
new LysandClient( new LysandClient(
new URL(useBaseUrl().value), new URL(useBaseUrl().value),
toValue(tokenData)?.access_token, toValue(customToken)?.access_token ??
identity.value?.tokens.access_token ??
undefined,
), ),
); );
}; };

View file

@ -3,7 +3,7 @@ import type { Status } from "~/types/mastodon/status";
export const useNoteData = ( export const useNoteData = (
noteProp: MaybeRef<Status | undefined>, noteProp: MaybeRef<Status | undefined>,
client: Ref<LysandClient | null>, client: Ref<LysandClient>,
) => { ) => {
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);
@ -53,7 +53,7 @@ export const useNoteData = (
); );
const remove = async () => { const remove = async () => {
const result = await client.value?.deleteStatus( const result = await client.value.deleteStatus(
renderedNote.value?.id ?? "", renderedNote.value?.id ?? "",
); );

View file

@ -0,0 +1,5 @@
export const usePermissions = () => {
const identity = useCurrentIdentity();
return computed(() => identity.value?.permissions ?? []);
};

View file

@ -1,5 +1,6 @@
import type { LysandClient } from "@lysand-org/client"; import type { LysandClient } from "@lysand-org/client";
import type { Relationship } from "~/types/mastodon/relationship"; import type { Relationship } from "~/types/mastodon/relationship";
import { useCurrentIdentity } from "./Identities";
export const useRelationship = ( export const useRelationship = (
client: MaybeRef<LysandClient | null>, client: MaybeRef<LysandClient | null>,
@ -8,7 +9,7 @@ export const useRelationship = (
const relationship = ref(null as Relationship | null); const relationship = ref(null as Relationship | null);
const isLoading = ref(false); const isLoading = ref(false);
if (!useSignedIn().value) { if (!useCurrentIdentity().value) {
return { relationship, isLoading }; return { relationship, isLoading };
} }

View file

@ -1,6 +0,0 @@
export const useSignedIn = () => {
const tokenData = useTokenData();
return computed(
() => tokenData.value !== null && !!tokenData.value.access_token,
);
};

View file

@ -1,8 +0,0 @@
import type { Token } from "@lysand-org/client";
import { StorageSerializers } from "@vueuse/core";
export const useTokenData = () => {
return useLocalStorage<Token | null>("lysand:token_data", null, {
serializer: StorageSerializers.object,
});
};

View file

@ -1,12 +1,25 @@
<template> <template>
<div class="from-dark-600 to-dark-900 bg-gradient-to-tl min-h-dvh"> <div class="from-dark-600 to-dark-900 bg-gradient-to-tl relative min-h-dvh">
<svg class="absolute inset-0 h-full w-full stroke-white/10 [mask-image:radial-gradient(100%_100%_at_top_right,white,transparent)]"
aria-hidden="true">
<defs>
<pattern id="983e3e4c-de6d-4c3f-8d64-b9761d1534cc" width="200" height="200" x="50%" y="-1"
patternUnits="userSpaceOnUse">
<path d="M.5 200V.5H200" fill="none"></path>
</pattern>
</defs><svg x="50%" y="-1" class="overflow-visible fill-gray-800/20">
<path d="M-200 0h201v201h-201Z M600 0h201v201h-201Z M-400 600h201v201h-201Z M200 800h201v201h-201Z"
stroke-width="0"></path>
</svg>
<rect width="100%" height="100%" stroke-width="0" fill="url(#983e3e4c-de6d-4c3f-8d64-b9761d1534cc)"></rect>
</svg>
<LazySidebarsNavigation /> <LazySidebarsNavigation />
<div class="relative md:pl-20 min-h-dvh flex flex-row overflow-hidden justify-center xl:justify-between"> <div class="relative md:pl-20 min-h-dvh flex flex-row overflow-hidden justify-center xl:justify-between">
<OverlayScrollbarsComponent :defer="true" class="w-full max-h-dvh overflow-y-auto"> <OverlayScrollbarsComponent :defer="true" class="w-full max-h-dvh overflow-y-auto">
<slot /> <slot />
</OverlayScrollbarsComponent> </OverlayScrollbarsComponent>
<ClientOnly> <ClientOnly>
<LazySidebarsCollapsibleAside v-if="width > 1280 && tokenData" direction="right" <LazySidebarsCollapsibleAside v-if="width > 1280 && identity" direction="right"
class="max-w-md max-h-dvh overflow-y-auto w-full hidden absolute inset-y-0 xl:flex"> class="max-w-md max-h-dvh overflow-y-auto w-full hidden absolute inset-y-0 xl:flex">
<LazyTimelinesTimelineScroller> <LazyTimelinesTimelineScroller>
<LazyTimelinesNotifications /> <LazyTimelinesNotifications />
@ -46,8 +59,8 @@ import { OverlayScrollbarsComponent } from "#imports";
const { width } = useWindowSize(); const { width } = useWindowSize();
const { n, o_i_d_c } = useMagicKeys(); const { n, o_i_d_c } = useMagicKeys();
const tokenData = useTokenData(); const identity = useCurrentIdentity();
const client = useClient(tokenData); const client = useClient();
const providers = useSSOConfig(); const providers = useSSOConfig();
watchEffect(async () => { watchEffect(async () => {
@ -70,19 +83,16 @@ watchEffect(async () => {
return; return;
} }
const response = await fetch( const response = await fetch(new URL("/api/v1/sso", client.value.url), {
new URL("/api/v1/sso", client.value?.url), method: "POST",
{ headers: {
method: "POST", Authorization: `Bearer ${identity.value?.tokens.access_token}`,
headers: { "Content-Type": "application/json",
Authorization: `Bearer ${tokenData.value?.access_token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
issuer: issuer.id,
}),
}, },
); body: JSON.stringify({
issuer: issuer.id,
}),
});
const json = await response.json(); const json = await response.json();
window.location.href = json.link; window.location.href = json.link;

View file

@ -30,7 +30,7 @@
}, },
"dependencies": { "dependencies": {
"@ark-ui/vue": "^3.3.1", "@ark-ui/vue": "^3.3.1",
"@lysand-org/client": "^0.1.3", "@lysand-org/client": "^0.1.6",
"@nuxt/fonts": "^0.7.0", "@nuxt/fonts": "^0.7.0",
"@tailwindcss/typography": "^0.5.13", "@tailwindcss/typography": "^0.5.13",
"@vee-validate/nuxt": "^4.13.1", "@vee-validate/nuxt": "^4.13.1",

View file

@ -15,7 +15,7 @@ definePageMeta({
}); });
const route = useRoute(); const route = useRoute();
const client = useClient(undefined, true); const client = useClient();
const username = (route.params.username as string).replace("@", ""); const username = (route.params.username as string).replace("@", "");
const accounts = useAccountSearch(client, username); const accounts = useAccountSearch(client, username);

View file

@ -1,7 +1,7 @@
<template> <template>
<ClientOnly> <ClientOnly>
<div class="mx-auto max-w-2xl w-full"> <div class="mx-auto max-w-2xl w-full">
<div class="shrink-0 p-10 h-dvh" v-if="!tokenData"> <div class="shrink-0 p-10 h-dvh" v-if="!identity">
<button type="button" <button type="button"
class="relative block h-full w-full rounded-lg border-2 border-dashed border-dark-300 p-12 text-center"> class="relative block h-full w-full rounded-lg border-2 border-dashed border-dark-300 p-12 text-center">
<iconify-icon name="tabler:notification" width="3rem" height="3rem" class="mx-auto text-gray-400" /> <iconify-icon name="tabler:notification" width="3rem" height="3rem" class="mx-auto text-gray-400" />
@ -26,5 +26,5 @@
definePageMeta({ definePageMeta({
layout: "app", layout: "app",
}); });
const tokenData = useTokenData(); const identity = useCurrentIdentity();
</script> </script>

View file

@ -67,8 +67,8 @@ import { toTypedSchema } from "@vee-validate/zod";
import { z } from "zod"; import { z } from "zod";
import LoginInput from "../../components/LoginInput.vue"; import LoginInput from "../../components/LoginInput.vue";
const tokenData = useTokenData(); const identity = useCurrentIdentity();
tokenData.value = null; identity.value = null;
const schema = toTypedSchema( const schema = toTypedSchema(
z z

View file

@ -1,86 +1,89 @@
<template> <template>
<div class="flex min-h-screen flex-col justify-center px-6 py-12 gap-10 lg:px-8 relative"> <ClientOnly>
<img crossorigin="anonymous" src="https://cdn.lysand.org/logo-long-dark.webp" alt="Lysand logo" <div class="flex min-h-screen flex-col justify-center px-6 py-12 gap-10 lg:px-8 relative">
class="mx-auto h-24 hidden md:block" /> <img crossorigin="anonymous" src="https://cdn.lysand.org/logo-long-dark.webp" alt="Lysand logo"
<div v-if="instance && instance.registrations" class="mx-auto w-full max-w-md"> class="mx-auto h-24 hidden md:block" />
<div v-if="Object.keys(errors).length > 0" <div v-if="instance && instance.registrations" class="mx-auto w-full max-w-md">
class="ring-1 ring-white/10 rounded p-4 bg-red-500 text-white mb-10"> <div v-if="Object.keys(errors).length > 0"
<h2 class="font-bold text-lg">Error</h2> class="ring-1 ring-white/10 rounded p-4 bg-red-500 text-white mb-10">
<span class="text-sm">{{ errors.error }}</span> <h2 class="font-bold text-lg">Error</h2>
<span class="text-sm">{{ errors.error }}</span>
</div>
<VeeForm class="space-y-6" @submit="register as any" :validation-schema="schema">
<h1 class="font-bold text-2xl text-gray-50 text-center tracking-tight">Register</h1>
<VeeField name="email" as="div" v-slot="{ errors, field }" validate-on-change>
<LoginInput label="Email" placeholder="contact@cpluspatch.com" type="email" autocomplete="email"
required :is-invalid="errors.length > 0" v-bind="field" :disabled="isLoading" />
<VeeErrorMessage name="email" as="p" class="mt-2 text-sm text-red-600" v-slot="{ message }">
{{ message }}
</VeeErrorMessage>
</VeeField>
<VeeField name="username" as="div" v-slot="{ errors, field }" validate-on-change>
<LoginInput label="Username" placeholder="thespeedy" type="text" autocomplete="username"
required :is-invalid="errors.length > 0" v-bind="field" :disabled="isLoading" />
<VeeErrorMessage name="username" as="p" class="mt-2 text-sm text-red-600" v-slot="{ message }">
{{ message }} (must only contain lowercase letters, numbers and underscores)
</VeeErrorMessage>
</VeeField>
<VeeField name="password" as="div" v-slot="{ errors, field }" validate-on-change>
<LoginInput label="Password" placeholder="hunter2" type="password"
autocomplete="current-password" required :is-invalid="errors.length > 0" v-bind="field"
:disabled="isLoading" />
<VeeErrorMessage name="password" as="p" class="mt-2 text-sm text-red-600" v-slot="{ message }">
{{ message }}
</VeeErrorMessage>
</VeeField>
<VeeField name="password2" as="div" v-slot="{ errors, field }" validate-on-change>
<LoginInput label="Confirm password" placeholder="hunter2" type="password"
autocomplete="current-password" required :is-invalid="errors.length > 0" v-bind="field"
:disabled="isLoading" />
<VeeErrorMessage name="password2" as="p" class="mt-2 text-sm text-red-600" v-slot="{ message }">
{{ message }}
</VeeErrorMessage>
</VeeField>
<VeeField name="reason" as="div" v-slot="{ errors }" validate-on-change>
<label for="reason" class="block text-sm font-medium leading-6 text-gray-50">Why do you want to
join?</label>
<div class="mt-2">
<textarea rows="4" required :is-invalid="errors.length > 0" name="reason"
:disabled="isLoading" placeholder="Brief text (optional)"
class="block w-full disabled:opacity-70 disabled:hover:cursor-wait bg-dark-500 rounded-md border-0 py-1.5 text-gray-50 shadow-sm ring-1 ring-inset ring-white/10 placeholder:text-gray-500 focus:ring-2 focus:ring-inset focus:ring-pink-600 sm:text-sm sm:leading-6" />
</div>
<VeeErrorMessage name="reason" as="p" class="mt-2 text-sm text-red-600" v-slot="{ message }">
{{ message }}
</VeeErrorMessage>
</VeeField>
<VeeField name="tos" as="div" v-slot="{ errors, field }" validate-on-change>
<input type="checkbox" :disabled="isLoading" name="tos"
class="rounded disabled:hover:cursor-wait mr-1 align-middle mb-0.5 text-pink-700 !ring-0 !outline-none"
required />
<span class="text-sm text-gray-100">I agree to the terms and conditions of this server <a
class="underline font-bold" target="_blank" :href="'#'">available here</a></span>
<VeeErrorMessage name="tos" as="p" class="mt-2 text-sm text-red-600" v-slot="{ message }">
{{ message }}
</VeeErrorMessage>
</VeeField>
<ButtonsPrimary type="submit" class="w-full" :disabled="isLoading">{{ isLoading ? "Registering..." :
"Register" }}</ButtonsPrimary>
</VeeForm>
</div>
<div v-else>
<h1 class="text-2xl font-bold tracking-tight text-gray-50 sm:text-4xl text-center">Registrations are
disabled
</h1>
<p class="mt-6 text-lg leading-8 text-gray-200 text-center">Ask this instance's admin to enable them in
config!
</p>
</div> </div>
<VeeForm class="space-y-6" @submit="register as any" :validation-schema="schema">
<h1 class="font-bold text-2xl text-gray-50 text-center tracking-tight">Register</h1>
<VeeField name="email" as="div" v-slot="{ errors, field }" validate-on-change>
<LoginInput label="Email" placeholder="contact@cpluspatch.com" type="email" autocomplete="email"
required :is-invalid="errors.length > 0" v-bind="field" :disabled="isLoading" />
<VeeErrorMessage name="email" as="p" class="mt-2 text-sm text-red-600" v-slot="{ message }">
{{ message }}
</VeeErrorMessage>
</VeeField>
<VeeField name="username" as="div" v-slot="{ errors, field }" validate-on-change>
<LoginInput label="Username" placeholder="thespeedy" type="text" autocomplete="username" required
:is-invalid="errors.length > 0" v-bind="field" :disabled="isLoading" />
<VeeErrorMessage name="username" as="p" class="mt-2 text-sm text-red-600" v-slot="{ message }">
{{ message }} (must only contain lowercase letters, numbers and underscores)
</VeeErrorMessage>
</VeeField>
<VeeField name="password" as="div" v-slot="{ errors, field }" validate-on-change>
<LoginInput label="Password" placeholder="hunter2" type="password" autocomplete="current-password"
required :is-invalid="errors.length > 0" v-bind="field" :disabled="isLoading" />
<VeeErrorMessage name="password" as="p" class="mt-2 text-sm text-red-600" v-slot="{ message }">
{{ message }}
</VeeErrorMessage>
</VeeField>
<VeeField name="password2" as="div" v-slot="{ errors, field }" validate-on-change>
<LoginInput label="Confirm password" placeholder="hunter2" type="password"
autocomplete="current-password" required :is-invalid="errors.length > 0" v-bind="field"
:disabled="isLoading" />
<VeeErrorMessage name="password2" as="p" class="mt-2 text-sm text-red-600" v-slot="{ message }">
{{ message }}
</VeeErrorMessage>
</VeeField>
<VeeField name="reason" as="div" v-slot="{ errors }" validate-on-change>
<label for="reason" class="block text-sm font-medium leading-6 text-gray-50">Why do you want to
join?</label>
<div class="mt-2">
<textarea rows="4" required :is-invalid="errors.length > 0" name="reason" :disabled="isLoading"
placeholder="Brief text (optional)"
class="block w-full disabled:opacity-70 disabled:hover:cursor-wait bg-dark-500 rounded-md border-0 py-1.5 text-gray-50 shadow-sm ring-1 ring-inset ring-white/10 placeholder:text-gray-500 focus:ring-2 focus:ring-inset focus:ring-pink-600 sm:text-sm sm:leading-6" />
</div>
<VeeErrorMessage name="reason" as="p" class="mt-2 text-sm text-red-600" v-slot="{ message }">
{{ message }}
</VeeErrorMessage>
</VeeField>
<VeeField name="tos" as="div" v-slot="{ errors, field }" validate-on-change>
<input type="checkbox" :disabled="isLoading" name="tos"
class="rounded disabled:hover:cursor-wait mr-1 align-middle mb-0.5 text-pink-700 !ring-0 !outline-none"
required />
<span class="text-sm text-gray-100">I agree to the terms and conditions of this server <a
class="underline font-bold" target="_blank" :href="'#'">available here</a></span>
<VeeErrorMessage name="tos" as="p" class="mt-2 text-sm text-red-600" v-slot="{ message }">
{{ message }}
</VeeErrorMessage>
</VeeField>
<ButtonsPrimary type="submit" class="w-full" :disabled="isLoading">{{ isLoading ? "Registering..." :
"Register" }}</ButtonsPrimary>
</VeeForm>
</div> </div>
<div v-else> </ClientOnly>
<h1 class="text-2xl font-bold tracking-tight text-gray-50 sm:text-4xl text-center">Registrations are
disabled
</h1>
<p class="mt-6 text-lg leading-8 text-gray-200 text-center">Ask this instance's admin to enable them in
config!
</p>
</div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">