mirror of
https://github.com/versia-pub/frontend.git
synced 2025-12-06 08:28:20 +01:00
feat: ✨ Add multi-account support, more options for posts, UI improvements
This commit is contained in:
parent
48954baf06
commit
ef9a6f1da4
38
app.vue
38
app.vue
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
92
components/sidebars/account-picker.vue
Normal file
92
components/sidebars/account-picker.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -14,8 +14,7 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const tokenData = useTokenData();
|
||||
const client = useClient(tokenData);
|
||||
const client = useClient();
|
||||
|
||||
const isLoading = ref(true);
|
||||
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +0,0 @@
|
|||
import type { Emoji } from "~/types/mastodon/emoji";
|
||||
|
||||
export const useCustomEmojis = () => {
|
||||
// Cache in localStorage
|
||||
return useLocalStorage<Emoji[]>("lysand:custom_emojis", []);
|
||||
};
|
||||
|
|
@ -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
119
composables/Identities.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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,
|
||||
});
|
||||
};
|
||||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 ?? "",
|
||||
);
|
||||
|
||||
|
|
|
|||
5
composables/Permissions.ts
Normal file
5
composables/Permissions.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export const usePermissions = () => {
|
||||
const identity = useCurrentIdentity();
|
||||
|
||||
return computed(() => identity.value?.permissions ?? []);
|
||||
};
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +0,0 @@
|
|||
export const useSignedIn = () => {
|
||||
const tokenData = useTokenData();
|
||||
return computed(
|
||||
() => tokenData.value !== null && !!tokenData.value.access_token,
|
||||
);
|
||||
};
|
||||
|
|
@ -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,
|
||||
});
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in a new issue