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">
import { convert } from "html-to-text";
import "iconify-icon";
import { nanoid } from "nanoid";
// Use SSR-safe IDs for Headless UI
provideHeadlessUseId(() => useId());
const code = useRequestURL().searchParams.get("code");
const appData = useAppData();
const tokenData = useTokenData();
const client = useClient(tokenData);
const identity = useCurrentIdentity();
const identities = useIdentities();
const client = useClient();
const instance = useInstance();
const description = useExtendedDescription(client);
@ -56,8 +58,28 @@ if (code) {
code,
new URL("/", useRequestURL().origin).toString(),
)
.then((res) => {
tokenData.value = res.data;
.then(async (res) => {
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
window.history.replaceState(
@ -65,10 +87,18 @@ if (code) {
document.title,
window.location.pathname,
);
// Redirect to home
window.location.pathname = "/";
});
}
}
useListen("identity:change", (newIdentity) => {
identity.value = newIdentity;
window.location.pathname = "/";
});
useCacheRefresh(client);
</script>

BIN
bun.lockb

Binary file not shown.

View file

@ -1,5 +1,5 @@
<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" />
<slot />
</ButtonsBase>

View file

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

View file

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

View file

@ -2,7 +2,7 @@
<div>
<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 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']"
@keydown.enter="removeFile(data.id)">
<template v-if="data.file.type.startsWith('image/')">
@ -73,8 +73,7 @@ const files = defineModel<
required: true,
});
const tokenData = useTokenData();
const client = useClient(tokenData);
const client = useClient();
const fileInput = ref<HTMLInputElement | null>(null);
const openFilePicker = () => {
@ -165,7 +164,7 @@ const uploadFile = async (file: File) => {
return data;
});
client.value?.uploadMedia(file).then((response) => {
client.value.uploadMedia(file).then((response) => {
const attachment = response.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">
<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">
<Composer v-if="instance" :instance="instance" />
<Composer v-if="instance" :instance="instance as any" />
</div>
</Dialog.Content>
</HeadlessTransitionChild>
@ -32,6 +32,8 @@
<script lang="ts" setup>
import { Dialog } from "@ark-ui/vue";
const open = ref(false);
const identity = useCurrentIdentity();
useListen("note:reply", async (note) => {
open.value = true;
await nextTick();
@ -48,11 +50,10 @@ useListen("note:edit", async (note) => {
useEvent("composer:edit", note);
});
useListen("composer:open", () => {
if (tokenData.value) open.value = true;
if (identity.value) open.value = true;
});
useListen("composer:close", () => {
open.value = false;
});
const tokenData = useTokenData();
const instance = useInstance();
</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
const registerClickHandlers = () => {
const targetElement = document.querySelector(`.${id}`);
if (targetElement) {
for (const el of targetElement.children) {
el.addEventListener("touchstart", (e) => {
e.stopPropagation();
e.preventDefault();
// Click all element children
for (const elChild of Array.from(el.children)) {
if (elChild instanceof HTMLElement) {
elChild.click();
}
const targetElements = document.querySelectorAll(`.${id} [data-part=item]`);
for (const el of targetElements) {
el.addEventListener("touchstart", (e) => {
e.stopPropagation();
e.preventDefault();
// Click all element children
for (const elChild of Array.from(el.children)) {
if (elChild instanceof HTMLElement) {
elChild.click();
}
});
}
}
});
}
};

View file

@ -1,27 +1,27 @@
<template>
<ClientOnly>
<div v-if="me" class="bg-dark-800 p-6 my-5 rounded ring-1 ring-white/5">
<div class="sm:flex sm:items-center sm:justify-between">
<div class="sm:flex sm:space-x-5">
<AvatarsCentered :src="me.avatar"
<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 gap-3">
<div class="sm:flex sm:space-x-5 grow">
<AvatarsCentered :src="identity.account.avatar"
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-xl font-bold text-gray-50 sm:text-2xl line-clamp-1"
v-html="useParsedContent(me.display_name, []).value"></p>
<p class="text-sm font-medium text-gray-500">@{{ me.acct }}</p>
v-html="useParsedContent(identity.account.display_name, []).value"></p>
</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')">
Compose
</ButtonsSecondary>
</div>
</div> -->
</div>
</div>
</ClientOnly>
</template>
<script lang="ts" setup>
const me = useMe();
const identity = useCurrentIdentity();
</script>

View file

@ -1,43 +1,46 @@
<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">
<div class="flex w-full flex-col items-center space-y-4 sm:items-end">
<!-- Notification panel, dynamically insert this into the live region when it needs to be displayed -->
<TransitionGroup enter-active-class="transform ease-out duration-300 transition"
enter-from-class="translate-y-2 opacity-0 sm:translate-y-0 sm:translate-x-2"
enter-to-class="translate-y-0 opacity-100 sm:translate-x-0"
leave-active-class="transition transform ease-in duration-100"
leave-from-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">
<div v-for="notification in notifications" :key="notification.id"
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="p-4">
<div class="flex items-start">
<div class="shrink-0 h-6 w-6">
<iconify-icon v-if="notification.type === 'success'" icon="tabler:check" height="none"
class="h-6 w-6 text-green-400" aria-hidden="true" />
<iconify-icon v-else-if="notification.type === 'error'" icon="tabler:alert-triangle"
height="none" class="h-6 w-6 text-red-400" aria-hidden="true" />
<iconify-icon v-else-if="notification.type === 'progress'" icon="tabler:loader"
height="none" class="h-6 w-6 text-pink-500 animate-spin" aria-hidden="true" />
</div>
<div class="ml-3 w-0 flex-1 pt-0.5">
<p class="text-sm font-semibold text-gray-50">{{ notification.title }}</p>
<p class="mt-1 text-sm text-gray-400" v-if="notification.message">{{
notification.message }}</p>
</div>
<div class="ml-4 flex flex-shrink-0">
<button type="button" title="Close this notification"
@click="notifications.splice(notifications.indexOf(notification), 1); notification.onDismiss?.()"
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>
<Teleport to="body">
<div aria-live="assertive"
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">
<div class="flex w-full flex-col items-center space-y-4 sm:items-end">
<!-- Notification panel, dynamically insert this into the live region when it needs to be displayed -->
<TransitionGroup enter-active-class="transform ease-out duration-300 transition"
enter-from-class="translate-y-2 opacity-0 sm:translate-y-0 sm:translate-x-2"
enter-to-class="translate-y-0 opacity-100 sm:translate-x-0"
leave-active-class="transition transform ease-in duration-100"
leave-from-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">
<div v-for="notification in notifications" :key="notification.id"
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="p-4">
<div class="flex items-start">
<div class="shrink-0 h-6 w-6">
<iconify-icon v-if="notification.type === 'success'" icon="tabler:check"
height="none" class="h-6 w-6 text-green-400" aria-hidden="true" />
<iconify-icon v-else-if="notification.type === 'error'" icon="tabler:alert-triangle"
height="none" class="h-6 w-6 text-red-400" aria-hidden="true" />
<iconify-icon v-else-if="notification.type === 'progress'" icon="tabler:loader"
height="none" class="h-6 w-6 text-pink-500 animate-spin" aria-hidden="true" />
</div>
<div class="ml-3 w-0 flex-1 pt-0.5">
<p class="text-sm font-semibold text-gray-50">{{ notification.title }}</p>
<p class="mt-1 text-sm text-gray-400" v-if="notification.message">{{
notification.message }}</p>
</div>
<div class="ml-4 flex flex-shrink-0">
<button type="button" title="Close this notification"
@click="notifications.splice(notifications.indexOf(notification), 1); notification.onDismiss?.()"
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>
</TransitionGroup>
</TransitionGroup>
</div>
</div>
</div>
</Teleport>
</template>
<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>
<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">
<NuxtLink href="/">
<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">
Account</h3>
<ClientOnly>
<ButtonsBase v-if="tokenData" @click="signOut().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: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">
<SidebarsAccountPicker @sign-in="signIn().finally(() => loadingAuth = false)" />
<NuxtLink href="/register" v-if="!identity">
<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">
<iconify-icon icon="tabler:certificate" class="shrink-0 text-2xl" />
<span class="pr-28 line-clamp-1">Register</span>
</ButtonsBase>
</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">
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">
<iconify-icon icon="tabler:writing" class="shrink-0 text-2xl" />
<span class="pr-28 line-clamp-1">Compose</span>
@ -98,37 +88,14 @@
<span class="text-xs">Update</span>
</button>
</ClientOnly>
<DropdownsAdaptiveDropdown v-else>
<template #button>
<button class="flex flex-col items-center justify-center p-2 rounded">
<iconify-icon icon="tabler:user" class="text-2xl" />
<span class="text-xs">Account</span>
</button>
</template>
<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"
<SidebarsAccountPicker v-else @sign-in="signIn().finally(() => loadingAuth = false)"
@sign-out="id => signOut(id).finally(() => loadingAuth = false)">
<button class="flex flex-col items-center justify-center p-2 rounded">
<iconify-icon icon="tabler:user" class="text-2xl" />
<span class="text-xs">Account</span>
</button>
</SidebarsAccountPicker>
<button @click="compose" v-if="identity"
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" />
<span class="text-xs">Compose</span>
@ -167,16 +134,16 @@ const timelines = ref([
const visibleTimelines = computed(() =>
timelines.value.filter(
(timeline) => !timeline.requiresAuth || tokenData.value,
(timeline) => !timeline.requiresAuth || identity.value,
),
);
const loadingAuth = ref(false);
const appData = useAppData();
const tokenData = useTokenData();
const identity = useCurrentIdentity();
const identities = useIdentities();
const client = useClient();
const me = useMe();
const compose = () => {
useEvent("composer:open");
@ -185,7 +152,7 @@ const compose = () => {
const signIn = async () => {
loadingAuth.value = true;
const output = await client.value?.createApp("Lysand", {
const output = await client.value.createApp("Lysand", {
scopes: ["read", "write", "follow", "push"],
redirect_uris: new URL("/", useRequestURL().origin).toString(),
website: useBaseUrl().value,
@ -198,7 +165,7 @@ const signIn = async () => {
appData.value = output.data;
const url = await client.value?.generateAuthUrl(
const url = await client.value.generateAuthUrl(
output.data.client_id,
output.data.client_secret,
{
@ -215,11 +182,20 @@ const signIn = async () => {
window.location.href = url;
};
const signOut = async () => {
const signOut = async (id?: string) => {
loadingAuth.value = true;
if (!appData.value || !tokenData.value) {
console.error("No app or token data to sign out");
if (!appData.value || !identity.value) {
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;
}
@ -227,13 +203,22 @@ const signOut = async () => {
await client.value
?.revokeToken(
appData.value.client_id,
tokenData.value.access_token,
tokenData.value.access_token,
identityToRevoke.tokens.access_token,
identityToRevoke.tokens.access_token,
)
.catch(() => {});
tokenData.value = null;
me.value = null;
await navigateTo("/");
if (id === identity.value.id) {
identity.value = null;
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>

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">
<iconify-icon icon="tabler:alt" width="none" class="size-6" />
</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.Description>{{ attachment.description }}</Popover.Description>
</Popover.Content>

View file

@ -20,19 +20,19 @@
<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">
<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"
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>
</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"
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
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>
</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"
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
@ -40,7 +40,7 @@
<span class="text-gray-400 mt-0.5 ml-2">{{ numberFormat(outputtedNote?.reblogs_count) }}</span>
</button>
<button class="group" @click="outputtedNote && useEvent('note:quote', outputtedNote)"
:disabled="!isSignedIn">
:disabled="!identity">
<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" />
<span class="text-gray-400 mt-0.5 ml-2">{{ numberFormat(0) }}</span>
@ -53,30 +53,93 @@
</template>
<template #items>
<Menu.Item value="" v-if="isSignedIn && outputtedNote?.account.id === me?.id">
<ButtonsDropdownElement @click="outputtedNote && useEvent('note:edit', outputtedNote)"
icon="tabler:pencil" class="w-full">
Edit
</ButtonsDropdownElement>
</Menu.Item>
<Menu.Item value="">
<ButtonsDropdownElement @click="copy(JSON.stringify(outputtedNote, null, 4))"
icon="tabler:code" class="w-full">
Copy API
Response
</ButtonsDropdownElement>
</Menu.Item>
<Menu.Item value="">
<ButtonsDropdownElement @click="copy(url)" icon="tabler:link" class="w-full">
Copy Link
</ButtonsDropdownElement>
</Menu.Item>
<Menu.Item value="">
<ButtonsDropdownElement @click="remove" icon="tabler:backspace" :disabled="!isSignedIn"
class="w-full border-r-2 border-red-500">
Delete
</ButtonsDropdownElement>
</Menu.Item>
<Menu.ItemGroup>
<Menu.Item value="" v-if="isMyAccount">
<ButtonsDropdownElement @click="outputtedNote && useEvent('note:edit', outputtedNote)"
icon="tabler:pencil" class="w-full">
Edit
</ButtonsDropdownElement>
</Menu.Item>
<Menu.Item value="">
<ButtonsDropdownElement @click="copy(JSON.stringify(outputtedNote, null, 4))"
icon="tabler:code" class="w-full">
Copy API
Response
</ButtonsDropdownElement>
</Menu.Item>
<Menu.Item value="">
<ButtonsDropdownElement @click="copy(url)" icon="tabler:link" class="w-full">
Copy Link
</ButtonsDropdownElement>
</Menu.Item>
<Menu.Item value="" v-if="outputtedNote?.url && isRemote">
<ButtonsDropdownElement @click="copy(outputtedNote.url)" icon="tabler:link"
class="w-full">
Copy Link (Origin)
</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>
</DropdownsAdaptiveDropdown>
</div>
@ -109,10 +172,8 @@ useListen("composer:send-edit", (note) => {
}
});
const tokenData = useTokenData();
const isSignedIn = useSignedIn();
const me = useMe();
const client = useClient(tokenData);
const client = useClient();
const identity = useCurrentIdentity();
const {
loaded,
note: outputtedNote,
@ -126,7 +187,16 @@ const {
reblogDisplayName,
} = useNoteData(noteRef, client);
const openBlank = (url: string) => window.open(url, "_blank");
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) =>
new Intl.NumberFormat(undefined, {
notation: "compact",
@ -137,7 +207,7 @@ const numberFormat = (number = 0) =>
const likeFn = async () => {
if (!outputtedNote.value) return;
if (outputtedNote.value.favourited) {
const output = await client.value?.unfavouriteStatus(
const output = await client.value.unfavouriteStatus(
outputtedNote.value.id,
);
@ -145,7 +215,7 @@ const likeFn = async () => {
noteRef.value = output.data;
}
} else {
const output = await client.value?.favouriteStatus(
const output = await client.value.favouriteStatus(
outputtedNote.value.id,
);
@ -158,7 +228,7 @@ const likeFn = async () => {
const reblogFn = async () => {
if (!outputtedNote.value) return;
if (outputtedNote.value?.reblogged) {
const output = await client.value?.unreblogStatus(
const output = await client.value.unreblogStatus(
outputtedNote.value.id,
);
@ -166,7 +236,7 @@ const reblogFn = async () => {
noteRef.value = output.data;
}
} else {
const output = await client.value?.reblogStatus(outputtedNote.value.id);
const output = await client.value.reblogStatus(outputtedNote.value.id);
if (output?.data.reblog) {
noteRef.value = output.data.reblog;

View file

@ -15,7 +15,6 @@ const props = defineProps<{
account_id: string | null;
}>();
const tokenData = useTokenData();
const client = useClient(tokenData);
const client = useClient();
const account = useAccount(client, props.account_id);
</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" />
<ClientOnly>
<ButtonsSecondary v-if="account && account?.id === me?.id">Edit Profile
<ButtonsSecondary v-if="account && account?.id === identity?.account?.id">Edit Profile
</ButtonsSecondary>
<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>
</ButtonsSecondary>
<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>
</ButtonsSecondary>
<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>
</ButtonsSecondary>
</ClientOnly>
@ -103,14 +103,13 @@ const props = defineProps<{
}>();
const skeleton = computed(() => !props.account);
const tokenData = useTokenData();
const me = useMe();
const client = useClient(tokenData);
const identity = useCurrentIdentity();
const client = useClient();
const accountId = computed(() => props.account?.id ?? null);
const { relationship, isLoading } = useRelationship(client, accountId);
const follow = () => {
if (!tokenData || !props.account || !relationship.value) return;
if (!identity.value || !props.account || !relationship.value) return;
relationship.value = {
...relationship.value,
following: true,
@ -118,7 +117,7 @@ const follow = () => {
};
const unfollow = () => {
if (!tokenData || !props.account || !relationship.value) return;
if (!identity.value || !props.account || !relationship.value) return;
relationship.value = {
...relationship.value,
following: false,

View file

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

View file

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

View file

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

View file

@ -1,28 +1,27 @@
import type { LysandClient } from "@lysand-org/client";
import { type RolePermissions, useCurrentIdentity } from "./Identities";
export const useCacheRefresh = (client: MaybeRef<LysandClient | null>) => {
if (process.server) return;
const tokenData = useTokenData();
const me = useMe();
const identity = useCurrentIdentity();
const instance = useInstance();
const customEmojis = useCustomEmojis();
// Refresh custom emojis and instance data and me on every reload
watchEffect(async () => {
console.log("Clearing cache");
if (tokenData.value) {
await toValue(client)
console.info("Refreshing emoji, instance and account cache");
if (identity.value) {
toValue(client)
?.verifyAccountCredentials()
.then((res) => {
me.value = res.data;
if (identity.value) identity.value.account = res.data;
})
.catch((err) => {
const code = err.response.status;
if (code === 401) {
// Reset tokenData
tokenData.value = null;
identity.value = null;
useEvent("notification:new", {
type: "error",
title: "Your session has expired",
@ -32,10 +31,25 @@ export const useCacheRefresh = (client: MaybeRef<LysandClient | null>) => {
}
});
await toValue(client)
toValue(client)
?.getInstanceCustomEmojis()
.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 type { Attachment } from "~/types/mastodon/attachment";
import type { Status } from "~/types/mastodon/status";
import type { Identity } from "./Identities";
export type NotificationEvent = {
type: "error" | "success" | "progress";
@ -19,6 +20,7 @@ type ApplicationEvents = {
"note:reblog": Status;
"note:unreblog": Status;
"note:quote": Status;
"note:report": Status;
"composer:open": undefined;
"composer:reply": Status;
"composer:quote": Status;
@ -28,6 +30,7 @@ type ApplicationEvents = {
"composer:close": undefined;
"notification:new": NotificationEvent;
"attachment:view": Attachment;
"identity:change": Identity;
};
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 { useCurrentIdentity } from "./Identities";
export const useClient = (
tokenData?: MaybeRef<Token | null>,
disableOnServer = false,
): Ref<LysandClient | null> => {
if (disableOnServer && process.server) {
return ref(null);
}
customToken: MaybeRef<Token | null> = null,
): Ref<LysandClient> => {
const identity = useCurrentIdentity();
return computed(
() =>
new LysandClient(
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 = (
noteProp: MaybeRef<Status | undefined>,
client: Ref<LysandClient | null>,
client: Ref<LysandClient>,
) => {
const isReply = computed(() => !!toValue(noteProp)?.in_reply_to_id);
const isQuote = computed(() => !!toValue(noteProp)?.quote);
@ -53,7 +53,7 @@ export const useNoteData = (
);
const remove = async () => {
const result = await client.value?.deleteStatus(
const result = await client.value.deleteStatus(
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 { Relationship } from "~/types/mastodon/relationship";
import { useCurrentIdentity } from "./Identities";
export const useRelationship = (
client: MaybeRef<LysandClient | null>,
@ -8,7 +9,7 @@ export const useRelationship = (
const relationship = ref(null as Relationship | null);
const isLoading = ref(false);
if (!useSignedIn().value) {
if (!useCurrentIdentity().value) {
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>
<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 />
<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">
<slot />
</OverlayScrollbarsComponent>
<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">
<LazyTimelinesTimelineScroller>
<LazyTimelinesNotifications />
@ -46,8 +59,8 @@ import { OverlayScrollbarsComponent } from "#imports";
const { width } = useWindowSize();
const { n, o_i_d_c } = useMagicKeys();
const tokenData = useTokenData();
const client = useClient(tokenData);
const identity = useCurrentIdentity();
const client = useClient();
const providers = useSSOConfig();
watchEffect(async () => {
@ -70,19 +83,16 @@ watchEffect(async () => {
return;
}
const response = await fetch(
new URL("/api/v1/sso", client.value?.url),
{
method: "POST",
headers: {
Authorization: `Bearer ${tokenData.value?.access_token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
issuer: issuer.id,
}),
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;

View file

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

View file

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

View file

@ -1,7 +1,7 @@
<template>
<ClientOnly>
<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"
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" />
@ -26,5 +26,5 @@
definePageMeta({
layout: "app",
});
const tokenData = useTokenData();
const identity = useCurrentIdentity();
</script>

View file

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

View file

@ -1,86 +1,89 @@
<template>
<div class="flex min-h-screen flex-col justify-center px-6 py-12 gap-10 lg:px-8 relative">
<img crossorigin="anonymous" src="https://cdn.lysand.org/logo-long-dark.webp" alt="Lysand logo"
class="mx-auto h-24 hidden md:block" />
<div v-if="instance && instance.registrations" class="mx-auto w-full max-w-md">
<div v-if="Object.keys(errors).length > 0"
class="ring-1 ring-white/10 rounded p-4 bg-red-500 text-white mb-10">
<h2 class="font-bold text-lg">Error</h2>
<span class="text-sm">{{ errors.error }}</span>
<ClientOnly>
<div class="flex min-h-screen flex-col justify-center px-6 py-12 gap-10 lg:px-8 relative">
<img crossorigin="anonymous" src="https://cdn.lysand.org/logo-long-dark.webp" alt="Lysand logo"
class="mx-auto h-24 hidden md:block" />
<div v-if="instance && instance.registrations" class="mx-auto w-full max-w-md">
<div v-if="Object.keys(errors).length > 0"
class="ring-1 ring-white/10 rounded p-4 bg-red-500 text-white mb-10">
<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>
<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>
</ClientOnly>
</template>
<script setup lang="ts">