mirror of
https://github.com/versia-pub/frontend.git
synced 2025-12-06 00:18:20 +01:00
refactor: ♻️ Rewrite authentication page
This commit is contained in:
parent
1194bc4ffb
commit
c483f35b99
|
|
@ -1,66 +0,0 @@
|
||||||
<template>
|
|
||||||
<slot name="error" v-if="error" v-bind="{ error }">
|
|
||||||
<div id="error" class="grid min-h-screen place-items-center px-6 py-24 sm:py-32 lg:px-8">
|
|
||||||
<div class="text-center prose prose-invert max-w-md w-full">
|
|
||||||
<h1 class="mt-4 text-3xl font-bold tracking-tight text-gray-100 sm:text-5xl">{{ error.title }}
|
|
||||||
</h1>
|
|
||||||
<p class="mt-6 text-base leading-7 text-gray-400" v-html="error.message"></p>
|
|
||||||
<div class="mt-10 grid grid-cols-2 gap-x-6 mx-auto max-w-md">
|
|
||||||
<Button theme="primary" class="w-full" @click="back">Go back</Button>
|
|
||||||
<a href="https://github.com/versia-pub/frontend/issues" target="_blank">
|
|
||||||
<Button theme="secondary" class="w-full">Report an issue</Button>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</slot>
|
|
||||||
<slot v-else />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts" setup>
|
|
||||||
import Button from "~/packages/ui/components/buttons/button.vue";
|
|
||||||
|
|
||||||
const error = ref<{
|
|
||||||
title: string;
|
|
||||||
message: string;
|
|
||||||
code: string;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
useListen("error", (err) => {
|
|
||||||
error.value = err;
|
|
||||||
|
|
||||||
useHead({
|
|
||||||
title: err?.title,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const back = () => {
|
|
||||||
useRouter().back();
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
#error code:not(pre code)::after,
|
|
||||||
#error code:not(pre code)::before {
|
|
||||||
content: ""
|
|
||||||
}
|
|
||||||
|
|
||||||
#error code:not(pre code) {
|
|
||||||
border-radius: .25rem;
|
|
||||||
padding: .25rem .5rem;
|
|
||||||
word-wrap: break-word;
|
|
||||||
background: transparent;
|
|
||||||
background-color: #ffffff0d;
|
|
||||||
-webkit-hyphens: none;
|
|
||||||
hyphens: none;
|
|
||||||
margin-top: 1rem;
|
|
||||||
-moz-tab-size: 4;
|
|
||||||
-o-tab-size: 4;
|
|
||||||
tab-size: 4;
|
|
||||||
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
|
|
||||||
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);
|
|
||||||
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), 0 0 #0000;
|
|
||||||
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
|
|
||||||
--tw-ring-color: hsla(0, 0%, 100%, .1)
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
141
components/oauth/login.vue
Normal file
141
components/oauth/login.vue
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { toTypedSchema } from "@vee-validate/zod";
|
||||||
|
import type { Instance } from "@versia/client";
|
||||||
|
import { Loader } from "lucide-vue-next";
|
||||||
|
import { useForm } from "vee-validate";
|
||||||
|
import * as z from "zod";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
|
||||||
|
const { instance } = defineProps<{
|
||||||
|
instance: Instance;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const isLoading = ref(false);
|
||||||
|
const ssoConfig = computed(() => instance.sso);
|
||||||
|
|
||||||
|
const formSchema = toTypedSchema(
|
||||||
|
z.object({
|
||||||
|
identifier: z
|
||||||
|
.string()
|
||||||
|
.min(3, {
|
||||||
|
message: "Must be at least 3 characters long",
|
||||||
|
})
|
||||||
|
.or(
|
||||||
|
z.string().email({
|
||||||
|
message: "Must be a valid email address",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
password: z.string().min(3, {
|
||||||
|
message: "Must be at least 3 characters long",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
validationSchema: formSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = form.handleSubmit((values) => {
|
||||||
|
console.info("Form submitted!", values);
|
||||||
|
});
|
||||||
|
|
||||||
|
const redirectUrl = new URL("/api/auth/login", `https://${instance.domain}`);
|
||||||
|
|
||||||
|
const params = useUrlSearchParams();
|
||||||
|
|
||||||
|
for (const name of [
|
||||||
|
"redirect_uri",
|
||||||
|
"response_type",
|
||||||
|
"client_id",
|
||||||
|
"scope",
|
||||||
|
"state",
|
||||||
|
]) {
|
||||||
|
if (params[name]) {
|
||||||
|
redirectUrl.searchParams.set(name, params[name] as string);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const issuerRedirectUrl = (issuerId: string) => {
|
||||||
|
const url = new URL("/oauth/sso", useBaseUrl().value);
|
||||||
|
|
||||||
|
for (const name of [
|
||||||
|
"redirect_uri",
|
||||||
|
"response_type",
|
||||||
|
"client_id",
|
||||||
|
"scope",
|
||||||
|
"state",
|
||||||
|
]) {
|
||||||
|
if (params[name]) {
|
||||||
|
url.searchParams.set(name, params[name] as string);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
url.searchParams.set("issuer", issuerId);
|
||||||
|
return url.toString();
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="grid gap-6">
|
||||||
|
<form @submit="onSubmit" method="post" :action="redirectUrl.toString()">
|
||||||
|
<div class="grid gap-6">
|
||||||
|
<FormField v-slot="{ componentField }" name="identifier">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
Email (or username)
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="petergriffin" type="email" auto-capitalize="none"
|
||||||
|
auto-complete="idenfifier" auto-correct="off" :disabled="isLoading"
|
||||||
|
v-bind="componentField" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
<FormField v-slot="{ componentField }" name="password">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
Password
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="hunter2" type="password" auto-capitalize="none"
|
||||||
|
auto-complete="password" auto-correct="off" :disabled="isLoading"
|
||||||
|
v-bind="componentField" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
<Button :disabled="isLoading" type="submit">
|
||||||
|
<Loader v-if="isLoading" class="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Sign In
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div v-if="ssoConfig && ssoConfig.providers.length > 0" class="relative">
|
||||||
|
<div class="absolute inset-0 flex items-center">
|
||||||
|
<span class="w-full border-t" />
|
||||||
|
</div>
|
||||||
|
<div class="relative flex justify-center text-xs uppercase">
|
||||||
|
<span class="bg-background px-2 text-muted-foreground">
|
||||||
|
Or continue with
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="ssoConfig && ssoConfig.providers.length > 0" class="flex flex-col gap-2">
|
||||||
|
<Button as="a" :href="issuerRedirectUrl(provider.id)" variant="outline" type="button" :disabled="isLoading" v-for="provider of ssoConfig.providers">
|
||||||
|
<Loader v-if="isLoading" class="mr-2 animate-spin" />
|
||||||
|
<img crossorigin="anonymous" :src="provider.icon" :alt="`${provider.name}'s logo`"
|
||||||
|
class="size-4 mr-2" />
|
||||||
|
{{ provider.name }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="flex flex-col p-10 gap-4 h-full">
|
|
||||||
<div
|
|
||||||
class="aspect-video shrink-0 w-full rounded ring-white/5 bg-dark-800 shadow overflow-hidden ring-1 hover:ring-2 duration-100">
|
|
||||||
<img class="object-cover w-full h-full duration-150 hover:scale-[102%] ease-in-out"
|
|
||||||
v-if="instance?.banner.url" alt="Instance banner" :src="instance.banner.url" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="prose prose-invert prose-sm">
|
|
||||||
<h1 class="text-center mb-10 mt-5">{{ instance?.title }}</h1>
|
|
||||||
<div v-html="description?.content"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="instance?.contact.account" class="flex flex-col gap-2 mt-auto">
|
|
||||||
<h2 class="text-gray-200 font-semibold uppercase text-xs">Administrator</h2>
|
|
||||||
<SmallCard :account="instance.contact.account" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts" setup>
|
|
||||||
import SmallCard from "../users/SmallCard.vue";
|
|
||||||
|
|
||||||
const instance = useInstance();
|
|
||||||
const description = useExtendedDescription(client);
|
|
||||||
</script>
|
|
||||||
|
|
@ -1,172 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="w-full ring-1 ring-inset ring-white/5 pb-10 bg-dark-800">
|
|
||||||
<Avatar :src="account?.header" :alt="`${account?.acct}'s header image'`"
|
|
||||||
class="w-full aspect-[8/3] border-b border-white/10 bg-dark-700 !rounded-none" />
|
|
||||||
|
|
||||||
<div class="flex items-start justify-between px-4 py-3">
|
|
||||||
<Avatar :src="account?.avatar" :alt="`${account?.acct}'s avatar'`"
|
|
||||||
class="h-32 w-32 -mt-[4.5rem] z-10 shrink-0 rounded ring-2 ring-dark-800" />
|
|
||||||
|
|
||||||
<div class="flex gap-x-2">
|
|
||||||
<Button theme="secondary" v-if="account && account?.id === identity?.account?.id">Edit Profile
|
|
||||||
</Button>
|
|
||||||
<Button theme="secondary" :loading="isLoading" @click="follow()"
|
|
||||||
v-if="account && account?.id !== identity?.account?.id && relationship && !relationship.following && !relationship.requested">
|
|
||||||
<span>Follow</span>
|
|
||||||
</Button>
|
|
||||||
<Button theme="secondary" :loading="isLoading" @click="unfollow()"
|
|
||||||
v-if="account && account?.id !== identity?.account?.id && relationship && relationship.following">
|
|
||||||
<span>Unfollow</span>
|
|
||||||
</Button>
|
|
||||||
<Button theme="secondary" :loading="isLoading" :disabled="true"
|
|
||||||
v-if="account && account?.id !== identity?.account?.id && relationship && !relationship.following && relationship.requested">
|
|
||||||
<span>Requested</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<AccountActionsDropdown v-if="account && relationship" :account="account"
|
|
||||||
:relationship="relationship" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-2 px-4">
|
|
||||||
<h2
|
|
||||||
class="text-xl font-bold text-gray-100 tracking-tight bg-gradient-to-r from-primary-300 via-purple-300 to-indigo-400 text-transparent bg-clip-text">
|
|
||||||
<Skeleton :enabled="skeleton" :min-width="200" :max-width="350" class="h-6">
|
|
||||||
<span v-html="display_name"></span>
|
|
||||||
<iconify-icon v-if="account?.locked" icon="tabler:lock" width="1.25rem" height="1.25rem"
|
|
||||||
class="text-gray-400 cursor-pointer align-text-top ml-1"
|
|
||||||
title="This account manually approves its followers" />
|
|
||||||
</Skeleton>
|
|
||||||
</h2>
|
|
||||||
<span class="text-gray-300 block mt-2">
|
|
||||||
<Skeleton :enabled="skeleton" :min-width="130" :max-width="250">@{{ account?.acct }}</Skeleton>
|
|
||||||
</span>
|
|
||||||
<div class="flex flex-row flex-wrap gap-4 mt-4" v-if="isDeveloper || visibleRoles.length > 0">
|
|
||||||
<Badge v-for="role of visibleRoles" :key="role.id" :name="role.name" :description="role.description"
|
|
||||||
:img="role.icon" />
|
|
||||||
<Badge v-if="isDeveloper" name="Versia Developer" description="This user is a Versia developer."
|
|
||||||
:verified="true" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4 px-4">
|
|
||||||
<Skeleton :enabled="true" v-if="skeleton" class="!h-6" :min-width="50" :max-width="100" width-unit="%"
|
|
||||||
shape="rect" type="content">
|
|
||||||
</Skeleton>
|
|
||||||
<div class="prose prose-invert" v-html="note" v-else></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-3 flex items-center space-x-4 px-4">
|
|
||||||
<div class="flex items-center space-x-1">
|
|
||||||
<Skeleton :enabled="skeleton" :min-width="150" :max-width="150" shape="rect">
|
|
||||||
<iconify-icon icon="tabler:calendar" width="1.25rem" height="1.25rem" class="text-gray-400" />
|
|
||||||
<span class="text-gray-400">Created {{ formattedJoin }}</span>
|
|
||||||
</Skeleton>
|
|
||||||
</div>
|
|
||||||
<div v-if="account?.bot" class="flex items-center space-x-1">
|
|
||||||
<iconify-icon icon="tabler:robot" width="1.25rem" height="1.25rem" class="text-gray-400" />
|
|
||||||
<span class="text-gray-400">Bot</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-3 flex items-center space-x-4 px-4">
|
|
||||||
<div class="cursor-pointer hover:underline space-x-1">
|
|
||||||
<Skeleton :enabled="skeleton" :min-width="100" :max-width="150" shape="rect">
|
|
||||||
<span class="font-bold text-gray-200">{{ account?.statuses_count }}</span>
|
|
||||||
<span class="text-gray-400">Posts</span>
|
|
||||||
</Skeleton>
|
|
||||||
</div>
|
|
||||||
<div class="cursor-pointer hover:underline space-x-1">
|
|
||||||
<Skeleton :enabled="skeleton" :min-width="100" :max-width="150" shape="rect">
|
|
||||||
<span class="font-bold text-gray-200">{{ account?.following_count }}</span>
|
|
||||||
<span class="text-gray-400">Following</span>
|
|
||||||
</Skeleton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="!skeleton && fields && fields.length > 0" class="mt-4 px-4 flex-col flex space-y-3">
|
|
||||||
<div v-for="field of fields" :key="field.name ?? ''" class="flex flex-col gap-1">
|
|
||||||
<span class="text-primary2-500 font-semibold" v-html="field.name"></span>
|
|
||||||
<span class="text-gray-200 prose prose-invert break-all" v-html="field.value"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="skeleton" class="mt-4 px-4 flex-col space-y-3">
|
|
||||||
<div v-for="_ of 3" class="flex flex-col gap-1">
|
|
||||||
<Skeleton :enabled="skeleton" :min-width="10" :max-width="100" width-unit="%" shape="rect">
|
|
||||||
</Skeleton>
|
|
||||||
<Skeleton :enabled="skeleton" :min-width="10" :max-width="100" width-unit="%" shape="rect">
|
|
||||||
</Skeleton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts" setup>
|
|
||||||
import type { Account } from "@versia/client/types";
|
|
||||||
import Avatar from "~/components/avatars/avatar.vue";
|
|
||||||
import Skeleton from "~/components/skeleton/Skeleton.vue";
|
|
||||||
import Button from "~/packages/ui/components/buttons/button.vue";
|
|
||||||
import AccountActionsDropdown from "./AccountActionsDropdown.vue";
|
|
||||||
import Badge from "./Badge.vue";
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
account?: Account;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const account = ref(props.account);
|
|
||||||
|
|
||||||
watch(props, () => {
|
|
||||||
account.value = props.account;
|
|
||||||
});
|
|
||||||
|
|
||||||
const skeleton = computed(() => !account.value);
|
|
||||||
const config = useConfig();
|
|
||||||
const accountId = computed(() => account.value?.id ?? null);
|
|
||||||
const { relationship, isLoading } = useRelationship(client, accountId);
|
|
||||||
|
|
||||||
const follow = () => {
|
|
||||||
if (!(identity.value && account.value && relationship.value)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
relationship.value = {
|
|
||||||
...relationship.value,
|
|
||||||
following: true,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const unfollow = () => {
|
|
||||||
if (!(identity.value && account.value && relationship.value)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
relationship.value = {
|
|
||||||
...relationship.value,
|
|
||||||
following: false,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const formattedJoin = computed(() =>
|
|
||||||
Intl.DateTimeFormat("en-US", {
|
|
||||||
month: "long",
|
|
||||||
year: "numeric",
|
|
||||||
}).format(new Date(account.value?.created_at ?? 0)),
|
|
||||||
);
|
|
||||||
|
|
||||||
const handle = computed(() => {
|
|
||||||
if (!account.value?.acct.includes("@")) {
|
|
||||||
return `${account.value?.acct}@${new URL(useBaseUrl().value).host}`;
|
|
||||||
}
|
|
||||||
return account.value?.acct;
|
|
||||||
});
|
|
||||||
const isDeveloper = computed(() =>
|
|
||||||
config.DEVELOPER_HANDLES.includes(handle.value),
|
|
||||||
);
|
|
||||||
const visibleRoles = computed(
|
|
||||||
() => account.value?.roles.filter((r) => r.visible) ?? [],
|
|
||||||
);
|
|
||||||
|
|
||||||
const { display_name, fields, note } = useParsedAccount(
|
|
||||||
computed(() => account.value),
|
|
||||||
settings,
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
@ -1,164 +0,0 @@
|
||||||
<template>
|
|
||||||
<AdaptiveDropdown>
|
|
||||||
<template #button>
|
|
||||||
<Button theme="secondary" class="h-full">
|
|
||||||
<iconify-icon width="unset" icon="tabler:dots" class="size-5 text-gray-200" aria-hidden="true" />
|
|
||||||
<span class="sr-only">Open menu</span>
|
|
||||||
</Button>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #items>
|
|
||||||
<Menu.ItemGroup>
|
|
||||||
<Menu.Item value="">
|
|
||||||
<ButtonDropdown @click="copyUsername" icon="tabler:at" class="w-full">
|
|
||||||
Copy username
|
|
||||||
</ButtonDropdown>
|
|
||||||
</Menu.Item>
|
|
||||||
<Menu.Item value="">
|
|
||||||
<ButtonDropdown @click="copyId" icon="tabler:hash" class="w-full">
|
|
||||||
Copy ID
|
|
||||||
</ButtonDropdown>
|
|
||||||
</Menu.Item>
|
|
||||||
<Menu.Item value="">
|
|
||||||
<ButtonDropdown @click="copy(JSON.stringify(account, null, 4))" icon="tabler:code" class="w-full">
|
|
||||||
Copy API
|
|
||||||
response
|
|
||||||
</ButtonDropdown>
|
|
||||||
</Menu.Item>
|
|
||||||
</Menu.ItemGroup>
|
|
||||||
<hr class="border-white/10 rounded" />
|
|
||||||
<Menu.ItemGroup>
|
|
||||||
<Menu.Item value="" v-if="isRemoteUser">
|
|
||||||
<ButtonDropdown @click="viewOnRemote" icon="tabler:external-link" class="w-full">
|
|
||||||
Open remote profile
|
|
||||||
</ButtonDropdown>
|
|
||||||
</Menu.Item>
|
|
||||||
<Menu.Item value="">
|
|
||||||
<ButtonDropdown @click="copyUrl" icon="tabler:link" class="w-full">
|
|
||||||
Copy URL
|
|
||||||
</ButtonDropdown>
|
|
||||||
</Menu.Item>
|
|
||||||
</Menu.ItemGroup>
|
|
||||||
<hr class="border-white/10 rounded" v-if="identity && !isMyAccount" />
|
|
||||||
<Menu.ItemGroup v-if="identity && !isMyAccount">
|
|
||||||
<Menu.Item value="">
|
|
||||||
<ButtonDropdown @click="mute" icon="tabler:volume-off" class="w-full" v-if="!relationship.muting">
|
|
||||||
Mute
|
|
||||||
</ButtonDropdown>
|
|
||||||
<ButtonDropdown @click="unmute" icon="tabler:volume-2" class="w-full" v-else>
|
|
||||||
Unmute
|
|
||||||
</ButtonDropdown>
|
|
||||||
</Menu.Item>
|
|
||||||
<Menu.Item value="">
|
|
||||||
<ButtonDropdown @click="block" icon="tabler:shield-x" class="w-full" v-if="!relationship.blocking">
|
|
||||||
Block
|
|
||||||
</ButtonDropdown>
|
|
||||||
<ButtonDropdown @click="unblock" icon="tabler:shield-check" class="w-full" v-else>
|
|
||||||
Unblock
|
|
||||||
</ButtonDropdown>
|
|
||||||
</Menu.Item>
|
|
||||||
</Menu.ItemGroup>
|
|
||||||
<hr class="border-white/10 rounded" v-if="identity && isRemoteUser" />
|
|
||||||
<Menu.ItemGroup v-if="identity && isRemoteUser">
|
|
||||||
<Menu.Item value="">
|
|
||||||
<ButtonDropdown @click="refetch" icon="tabler:refresh" class="w-full">
|
|
||||||
Update remote user information
|
|
||||||
</ButtonDropdown>
|
|
||||||
</Menu.Item>
|
|
||||||
</Menu.ItemGroup>
|
|
||||||
<hr class="border-white/10 rounded" v-if="identity" />
|
|
||||||
<Menu.ItemGroup v-if="identity">
|
|
||||||
<Menu.Item value="">
|
|
||||||
<ButtonDropdown @click="useEvent('account:report', account)" icon="tabler:flag" class="w-full"
|
|
||||||
:disabled="!permissions.includes(RolePermission.ManageOwnReports)">
|
|
||||||
Report
|
|
||||||
</ButtonDropdown>
|
|
||||||
</Menu.Item>
|
|
||||||
<Menu.Item value="" v-if="permissions.includes(RolePermission.ManageAccounts)">
|
|
||||||
<ButtonDropdown icon="tabler:shield-bolt" class="w-full">
|
|
||||||
Open moderation panel
|
|
||||||
</ButtonDropdown>
|
|
||||||
</Menu.Item>
|
|
||||||
</Menu.ItemGroup>
|
|
||||||
</template>
|
|
||||||
</AdaptiveDropdown>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts" setup>
|
|
||||||
import { Menu } from "@ark-ui/vue";
|
|
||||||
import {
|
|
||||||
type Account,
|
|
||||||
type Relationship,
|
|
||||||
RolePermission,
|
|
||||||
} from "@versia/client/types";
|
|
||||||
import ButtonDropdown from "~/components/buttons/button-dropdown.vue";
|
|
||||||
import AdaptiveDropdown from "~/components/dropdowns/AdaptiveDropdown.vue";
|
|
||||||
import Button from "~/packages/ui/components/buttons/button.vue";
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
account: Account;
|
|
||||||
relationship: Relationship;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const relationship = ref(props.relationship);
|
|
||||||
|
|
||||||
const permissions = usePermissions();
|
|
||||||
const isMyAccount = computed(
|
|
||||||
() => identity.value?.account.id === props.account.id,
|
|
||||||
);
|
|
||||||
const isRemoteUser = computed(() => props.account.acct.includes("@"));
|
|
||||||
const { copy } = useClipboard();
|
|
||||||
|
|
||||||
const copyUsername = () => copy(props.account.acct);
|
|
||||||
const copyId = () => copy(props.account.id);
|
|
||||||
const viewOnRemote = () => window.open(props.account.url, "_blank");
|
|
||||||
const copyUrl = () => copy(props.account.url);
|
|
||||||
|
|
||||||
const mute = async () => {
|
|
||||||
const { data } = await client.value.muteAccount(props.account.id);
|
|
||||||
relationship.value = data;
|
|
||||||
useEvent("notification:new", {
|
|
||||||
type: "success",
|
|
||||||
title: "Account muted",
|
|
||||||
description: `You will no longer see notifications from ${props.account.acct}`,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const unmute = async () => {
|
|
||||||
const { data } = await client.value.unmuteAccount(props.account.id);
|
|
||||||
relationship.value = data;
|
|
||||||
useEvent("notification:new", {
|
|
||||||
type: "success",
|
|
||||||
title: "Account unmuted",
|
|
||||||
description: `You will now see notifications from ${props.account.acct}`,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const block = async () => {
|
|
||||||
const { data } = await client.value.blockAccount(props.account.id);
|
|
||||||
relationship.value = data;
|
|
||||||
useEvent("notification:new", {
|
|
||||||
type: "success",
|
|
||||||
title: "Account blocked",
|
|
||||||
description: `You will no longer see content from ${props.account.acct}`,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const unblock = async () => {
|
|
||||||
const { data } = await client.value.unblockAccount(props.account.id);
|
|
||||||
relationship.value = data;
|
|
||||||
useEvent("notification:new", {
|
|
||||||
type: "success",
|
|
||||||
title: "Account unblocked",
|
|
||||||
description: `You will now see content from ${props.account.acct}`,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const refetch = async () => {
|
|
||||||
const { data } = await client.value.refetchAccount(props.account.id);
|
|
||||||
useEvent("account:update", data);
|
|
||||||
useEvent("notification:new", {
|
|
||||||
type: "success",
|
|
||||||
title: "Remote user information updated",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
<template>
|
|
||||||
<Tooltip.Root @update:open="(o) => open = o" :open="open" :open-delay="0">
|
|
||||||
<Tooltip.Trigger><span
|
|
||||||
class="inline-flex items-center px-2 py-1 gap-x-2 rounded text-sm font-medium bg-dark-300 text-primary2-200 ring-white/5 ring-1">
|
|
||||||
<svg viewBox="0 0 22 22" v-if="verified" aria-label="Verified account" role="img"
|
|
||||||
class="size-4 fill-primary-500">
|
|
||||||
<g>
|
|
||||||
<path
|
|
||||||
d="M20.396 11c-.018-.646-.215-1.275-.57-1.816-.354-.54-.852-.972-1.438-1.246.223-.607.27-1.264.14-1.897-.131-.634-.437-1.218-.882-1.687-.47-.445-1.053-.75-1.687-.882-.633-.13-1.29-.083-1.897.14-.273-.587-.704-1.086-1.245-1.44S11.647 1.62 11 1.604c-.646.017-1.273.213-1.813.568s-.969.854-1.24 1.44c-.608-.223-1.267-.272-1.902-.14-.635.13-1.22.436-1.69.882-.445.47-.749 1.055-.878 1.688-.13.633-.08 1.29.144 1.896-.587.274-1.087.705-1.443 1.245-.356.54-.555 1.17-.574 1.817.02.647.218 1.276.574 1.817.356.54.856.972 1.443 1.245-.224.606-.274 1.263-.144 1.896.13.634.433 1.218.877 1.688.47.443 1.054.747 1.687.878.633.132 1.29.084 1.897-.136.274.586.705 1.084 1.246 1.439.54.354 1.17.551 1.816.569.647-.016 1.276-.213 1.817-.567s.972-.854 1.245-1.44c.604.239 1.266.296 1.903.164.636-.132 1.22-.447 1.68-.907.46-.46.776-1.044.908-1.681s.075-1.299-.165-1.903c.586-.274 1.084-.705 1.439-1.246.354-.54.551-1.17.569-1.816zM9.662 14.85l-3.429-3.428 1.293-1.302 2.072 2.072 4.4-4.794 1.347 1.246z">
|
|
||||||
</path>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
<img v-else-if="img" :src="img" alt="" class="size-4 rounded-sm" />
|
|
||||||
<iconify-icon v-else-if="icon" :icon="icon" width="none" class="text-primary2-200 size-4"
|
|
||||||
aria-hidden="true" />
|
|
||||||
<span>{{ name }}</span>
|
|
||||||
</span></Tooltip.Trigger>
|
|
||||||
<Tooltip.Positioner>
|
|
||||||
<transition enter-active-class="transition ease-in duration-100"
|
|
||||||
enter-from-class="transform opacity-0 translate-y-full sm:translate-y-0 scale-95"
|
|
||||||
enter-to-class="transform translate-y-0 opacity-100 scale-100"
|
|
||||||
leave-active-class="transition ease-out duration-75" leave-from-class="transform opacity-100 scale-100"
|
|
||||||
leave-to-class="transform opacity-0 scale-95">
|
|
||||||
<Tooltip.Content v-if="open"
|
|
||||||
class="rounded px-4 py-2 text-sm bg-dark-400 text-gray-200 ring-1 ring-white/10 shadow-xl">
|
|
||||||
<svg viewBox="0 0 22 22" v-if="verified" aria-label="Verified account" role="img"
|
|
||||||
class="size-4 fill-primary-500 inline mb-0.5">
|
|
||||||
<g>
|
|
||||||
<path
|
|
||||||
d="M20.396 11c-.018-.646-.215-1.275-.57-1.816-.354-.54-.852-.972-1.438-1.246.223-.607.27-1.264.14-1.897-.131-.634-.437-1.218-.882-1.687-.47-.445-1.053-.75-1.687-.882-.633-.13-1.29-.083-1.897.14-.273-.587-.704-1.086-1.245-1.44S11.647 1.62 11 1.604c-.646.017-1.273.213-1.813.568s-.969.854-1.24 1.44c-.608-.223-1.267-.272-1.902-.14-.635.13-1.22.436-1.69.882-.445.47-.749 1.055-.878 1.688-.13.633-.08 1.29.144 1.896-.587.274-1.087.705-1.443 1.245-.356.54-.555 1.17-.574 1.817.02.647.218 1.276.574 1.817.356.54.856.972 1.443 1.245-.224.606-.274 1.263-.144 1.896.13.634.433 1.218.877 1.688.47.443 1.054.747 1.687.878.633.132 1.29.084 1.897-.136.274.586.705 1.084 1.246 1.439.54.354 1.17.551 1.816.569.647-.016 1.276-.213 1.817-.567s.972-.854 1.245-1.44c.604.239 1.266.296 1.903.164.636-.132 1.22-.447 1.68-.907.46-.46.776-1.044.908-1.681s.075-1.299-.165-1.903c.586-.274 1.084-.705 1.439-1.246.354-.54.551-1.17.569-1.816zM9.662 14.85l-3.429-3.428 1.293-1.302 2.072 2.072 4.4-4.794 1.347 1.246z">
|
|
||||||
</path>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
{{ description }}
|
|
||||||
</Tooltip.Content>
|
|
||||||
</transition>
|
|
||||||
</Tooltip.Positioner>
|
|
||||||
</Tooltip.Root>
|
|
||||||
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts" setup>
|
|
||||||
import { Tooltip } from "@ark-ui/vue";
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
name: string;
|
|
||||||
description?: string | null;
|
|
||||||
icon?: string | null;
|
|
||||||
img?: string | null;
|
|
||||||
verified?: boolean;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const open = ref(false);
|
|
||||||
</script>
|
|
||||||
|
|
@ -1,125 +0,0 @@
|
||||||
<template>
|
|
||||||
<HoverCard.Root :positioning="{
|
|
||||||
placement: 'bottom',
|
|
||||||
strategy: 'fixed',
|
|
||||||
}" v-if="isEnabled.value" v-model:open="open" :open-delay="1000">
|
|
||||||
<HoverCard.Trigger :as-child="true">
|
|
||||||
<slot />
|
|
||||||
</HoverCard.Trigger>
|
|
||||||
<Teleport to="body" v-if="account">
|
|
||||||
<Transition enter-active-class="transition duration-300 ease-in-out" enter-from-class="opacity-0"
|
|
||||||
enter-to-class="opacity-100" leave-active-class="duration-200 ease-in-out"
|
|
||||||
leave-from-class="opacity-100" leave-to-class="opacity-0">
|
|
||||||
<div class="fixed bg-black/70 inset-0 z-10 pointer-events-none" v-if="open"></div>
|
|
||||||
</Transition>
|
|
||||||
<HoverCard.Positioner>
|
|
||||||
<HoverCard.Content
|
|
||||||
class="bg-dark-700 pb-4 w-96 z-20 overflow-y-auto rounded overflow-x-hidden ring-1 ring-white/20 shadow-xl max-h-[60vh] text-sm">
|
|
||||||
<Avatar :src="account.header" :alt="`${account.acct}'s header image'`"
|
|
||||||
class="w-full aspect-[8/3] border-b border-white/10 bg-dark-700 !rounded-none" />
|
|
||||||
|
|
||||||
<div class="flex items-start justify-between px-4 py-3">
|
|
||||||
<Avatar :src="account.avatar" :alt="`${account.acct}'s avatar'`"
|
|
||||||
class="h-32 w-32 -mt-[4.5rem] z-10 shrink-0 rounded ring-2 ring-dark-200" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-2 px-4">
|
|
||||||
<h2
|
|
||||||
class="text-xl font-bold text-gray-100 tracking-tight bg-gradient-to-r from-primary-300 via-purple-300 to-indigo-400 text-transparent bg-clip-text">
|
|
||||||
<span v-html="display_name"></span>
|
|
||||||
<iconify-icon v-if="account.locked" icon="tabler:lock" width="1.25rem" height="1.25rem"
|
|
||||||
class="text-gray-400 cursor-pointer align-text-top ml-1"
|
|
||||||
title="This account manually approves its followers" />
|
|
||||||
</h2>
|
|
||||||
<span class="text-gray-300 block mt-2">
|
|
||||||
@{{ account.acct }}
|
|
||||||
</span>
|
|
||||||
<div class="flex flex-row flex-wrap gap-4 mt-4" v-if="isDeveloper || visibleRoles.length > 0">
|
|
||||||
<Badge v-for="role of visibleRoles" :key="role.id" :name="role.name"
|
|
||||||
:description="role.description" :img="role.icon" />
|
|
||||||
<Badge v-if="isDeveloper" name="Versia Developer"
|
|
||||||
description="This user is a Versia developer." :verified="true" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4 px-4">
|
|
||||||
<div class="prose prose-invert prose-sm" v-html="note"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-3 flex items-center space-x-4 px-4">
|
|
||||||
<div class="flex items-center space-x-1">
|
|
||||||
<iconify-icon icon="tabler:calendar" width="1.25rem" height="1.25rem"
|
|
||||||
class="text-gray-400" />
|
|
||||||
<span class="text-gray-400">Created {{ formattedJoin }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="account.bot" class="flex items-center space-x-1">
|
|
||||||
<iconify-icon icon="tabler:robot" width="1.25rem" height="1.25rem" class="text-gray-400" />
|
|
||||||
<span class="text-gray-400">Bot</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-3 flex items-center space-x-4 px-4">
|
|
||||||
<div class="cursor-pointer hover:underline space-x-1">
|
|
||||||
<span class="font-bold text-gray-200">{{ account.statuses_count }}</span>
|
|
||||||
<span class="text-gray-400">Posts</span>
|
|
||||||
</div>
|
|
||||||
<div class="cursor-pointer hover:underline space-x-1">
|
|
||||||
<span class="font-bold text-gray-200">{{ account.following_count }}</span>
|
|
||||||
<span class="text-gray-400">Following</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="fields && fields.length > 0" class="mt-4 px-4 flex-col flex space-y-3">
|
|
||||||
<div v-for="field of fields" :key="field.name ?? ''" class="flex flex-col gap-1">
|
|
||||||
<span class="text-primary2-500 font-semibold" v-html="field.name"></span>
|
|
||||||
<span class="text-gray-200 prose prose-invert prose-sm break-all"
|
|
||||||
v-html="field.value"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</HoverCard.Content>
|
|
||||||
</HoverCard.Positioner>
|
|
||||||
</Teleport>
|
|
||||||
</HoverCard.Root>
|
|
||||||
<slot v-else />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { HoverCard } from "@ark-ui/vue";
|
|
||||||
import type { Account } from "@versia/client/types";
|
|
||||||
import Avatar from "~/components/avatars/avatar.vue";
|
|
||||||
import { SettingIds } from "~/settings";
|
|
||||||
import Badge from "./Badge.vue";
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
account?: Account;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const config = useConfig();
|
|
||||||
const isEnabled = ref({ value: false }); // useSetting(SettingIds.PopupAvatarHover);
|
|
||||||
const open = ref(false);
|
|
||||||
|
|
||||||
const formattedJoin = computed(() =>
|
|
||||||
Intl.DateTimeFormat("en-US", {
|
|
||||||
month: "long",
|
|
||||||
year: "numeric",
|
|
||||||
}).format(new Date(props.account?.created_at ?? 0)),
|
|
||||||
);
|
|
||||||
|
|
||||||
const handle = computed(() => {
|
|
||||||
if (!props.account?.acct.includes("@")) {
|
|
||||||
return `${props.account?.acct}@${new URL(useBaseUrl().value).host}`;
|
|
||||||
}
|
|
||||||
return props.account?.acct;
|
|
||||||
});
|
|
||||||
const isDeveloper = computed(() =>
|
|
||||||
config.DEVELOPER_HANDLES.includes(handle.value),
|
|
||||||
);
|
|
||||||
const visibleRoles = computed(
|
|
||||||
() => props.account?.roles.filter((r) => r.visible) ?? [],
|
|
||||||
);
|
|
||||||
|
|
||||||
const { display_name, fields, note } = useParsedAccount(
|
|
||||||
computed(() => props.account),
|
|
||||||
settings,
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
16
components/ui/form/FormControl.vue
Normal file
16
components/ui/form/FormControl.vue
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { Slot } from "radix-vue";
|
||||||
|
import { useFormField } from "./useFormField";
|
||||||
|
|
||||||
|
const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Slot
|
||||||
|
:id="formItemId"
|
||||||
|
:aria-describedby="!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`"
|
||||||
|
:aria-invalid="!!error"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</Slot>
|
||||||
|
</template>
|
||||||
20
components/ui/form/FormDescription.vue
Normal file
20
components/ui/form/FormDescription.vue
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { HTMLAttributes } from "vue";
|
||||||
|
import { useFormField } from "./useFormField";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { formDescriptionId } = useFormField();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<p
|
||||||
|
:id="formDescriptionId"
|
||||||
|
:class="cn('text-sm text-muted-foreground', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
19
components/ui/form/FormItem.vue
Normal file
19
components/ui/form/FormItem.vue
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useId } from "radix-vue";
|
||||||
|
import { type HTMLAttributes, provide } from "vue";
|
||||||
|
import { FORM_ITEM_INJECTION_KEY } from "./injectionKeys";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const id = useId();
|
||||||
|
provide(FORM_ITEM_INJECTION_KEY, id);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="cn('space-y-2', props.class)">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
23
components/ui/form/FormLabel.vue
Normal file
23
components/ui/form/FormLabel.vue
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { LabelProps } from "radix-vue";
|
||||||
|
import type { HTMLAttributes } from "vue";
|
||||||
|
import { Label } from "~/components/ui/label";
|
||||||
|
import { useFormField } from "./useFormField";
|
||||||
|
|
||||||
|
const props = defineProps<LabelProps & { class?: HTMLAttributes["class"] }>();
|
||||||
|
|
||||||
|
const { error, formItemId } = useFormField();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Label
|
||||||
|
:class="cn(
|
||||||
|
error && 'text-destructive',
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
:for="formItemId"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</Label>
|
||||||
|
</template>
|
||||||
16
components/ui/form/FormMessage.vue
Normal file
16
components/ui/form/FormMessage.vue
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ErrorMessage } from "vee-validate";
|
||||||
|
import { toValue } from "vue";
|
||||||
|
import { useFormField } from "./useFormField";
|
||||||
|
|
||||||
|
const { name, formMessageId } = useFormField();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ErrorMessage
|
||||||
|
:id="formMessageId"
|
||||||
|
as="p"
|
||||||
|
:name="toValue(name)"
|
||||||
|
class="text-sm font-medium text-destructive"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
7
components/ui/form/index.ts
Normal file
7
components/ui/form/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
export { default as FormControl } from "./FormControl.vue";
|
||||||
|
export { default as FormDescription } from "./FormDescription.vue";
|
||||||
|
export { default as FormItem } from "./FormItem.vue";
|
||||||
|
export { default as FormLabel } from "./FormLabel.vue";
|
||||||
|
export { default as FormMessage } from "./FormMessage.vue";
|
||||||
|
export { FORM_ITEM_INJECTION_KEY } from "./injectionKeys";
|
||||||
|
export { Field as FormField, Form } from "vee-validate";
|
||||||
3
components/ui/form/injectionKeys.ts
Normal file
3
components/ui/form/injectionKeys.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
import type { InjectionKey } from "vue";
|
||||||
|
|
||||||
|
export const FORM_ITEM_INJECTION_KEY = Symbol() as InjectionKey<string>;
|
||||||
37
components/ui/form/useFormField.ts
Normal file
37
components/ui/form/useFormField.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import {
|
||||||
|
FieldContextKey,
|
||||||
|
useFieldError,
|
||||||
|
useIsFieldDirty,
|
||||||
|
useIsFieldTouched,
|
||||||
|
useIsFieldValid,
|
||||||
|
} from "vee-validate";
|
||||||
|
import { inject } from "vue";
|
||||||
|
import { FORM_ITEM_INJECTION_KEY } from "./injectionKeys";
|
||||||
|
|
||||||
|
export function useFormField() {
|
||||||
|
const fieldContext = inject(FieldContextKey);
|
||||||
|
const fieldItemContext = inject(FORM_ITEM_INJECTION_KEY);
|
||||||
|
|
||||||
|
if (!fieldContext) {
|
||||||
|
throw new Error("useFormField should be used within <FormField>");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name } = fieldContext;
|
||||||
|
const id = fieldItemContext;
|
||||||
|
|
||||||
|
const fieldState = {
|
||||||
|
valid: useIsFieldValid(name),
|
||||||
|
isDirty: useIsFieldDirty(name),
|
||||||
|
isTouched: useIsFieldTouched(name),
|
||||||
|
error: useFieldError(name),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
formItemId: `${id}-form-item`,
|
||||||
|
formDescriptionId: `${id}-form-item-description`,
|
||||||
|
formMessageId: `${id}-form-item-message`,
|
||||||
|
...fieldState,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -19,5 +19,5 @@ const modelValue = useVModel(props, "modelValue", emits, {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<input v-model="modelValue" :class="cn('flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50', props.class)">
|
<input v-model="modelValue" :class="cn('flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground !outline-none focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50', props.class)">
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,16 @@ import type { Account } from "@versia/client/types";
|
||||||
export const useAccountFromAcct = (
|
export const useAccountFromAcct = (
|
||||||
client: MaybeRef<Client | null>,
|
client: MaybeRef<Client | null>,
|
||||||
acct: string,
|
acct: string,
|
||||||
): Ref<Account | null> => {
|
): { account: Ref<Account | null>; isLoading: Ref<boolean> } => {
|
||||||
const output = ref(null as Account | null);
|
const output = ref(null as Account | null);
|
||||||
|
const isLoading = ref(true);
|
||||||
|
|
||||||
ref(client)
|
ref(client)
|
||||||
.value?.lookupAccount(acct)
|
.value?.lookupAccount(acct)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
|
isLoading.value = false;
|
||||||
output.value = res.data;
|
output.value = res.data;
|
||||||
});
|
});
|
||||||
|
|
||||||
return output;
|
return { account: output, isLoading };
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
<template>
|
<template>
|
||||||
<main class="from-dark-600 to-dark-900 bg-gradient-to-tl min-h-dvh pb-20 md:pb-0">
|
<slot />
|
||||||
<Navigation />
|
<ComposerDialog />
|
||||||
<slot />
|
|
||||||
</main>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import Navigation from "~/components/sidebars/navigation.vue";
|
import ComposerDialog from "~/components/composer/dialog.vue";
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -19,6 +19,11 @@ export default defineNuxtConfig({
|
||||||
components: {
|
components: {
|
||||||
dirs: [],
|
dirs: [],
|
||||||
},
|
},
|
||||||
|
tailwindcss: {
|
||||||
|
// Don't inject the default @tailwind utilities CSS
|
||||||
|
// To avoid conflicts with our own styles
|
||||||
|
cssPath: false,
|
||||||
|
},
|
||||||
future: {
|
future: {
|
||||||
compatibilityVersion: 4,
|
compatibilityVersion: 4,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@
|
||||||
"shiki": "^1.24.0",
|
"shiki": "^1.24.0",
|
||||||
"tailwind-merge": "^2.5.5",
|
"tailwind-merge": "^2.5.5",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"vee-validate": "^4.14.7",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
"vue-router": "^4.5.0",
|
"vue-router": "^4.5.0",
|
||||||
"vue-sonner": "^1.3.0",
|
"vue-sonner": "^1.3.0",
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,25 @@
|
||||||
<template>
|
<template>
|
||||||
<ErrorBoundary>
|
<div class="mx-auto max-w-2xl w-full space-y-2">
|
||||||
<div class="mx-auto max-w-2xl w-full space-y-2">
|
<div v-if="isLoading" class="p-4 flex items-center justify-center h-48">
|
||||||
<TimelineScroller v-if="account">
|
<Loader class="size-8 animate-spin" />
|
||||||
<AccountProfile :account="account" />
|
|
||||||
<AccountTimeline v-if="accountId" :id="accountId" :key="accountId" />
|
|
||||||
</TimelineScroller>
|
|
||||||
</div>
|
</div>
|
||||||
</ErrorBoundary>
|
<TimelineScroller v-else-if="account">
|
||||||
|
<AccountProfile :account="account" />
|
||||||
|
<AccountTimeline v-if="accountId" :id="accountId" :key="accountId" />
|
||||||
|
</TimelineScroller>
|
||||||
|
<Card v-else class="shadow-none bg-transparent border-none p-4">
|
||||||
|
<CardHeader class="text-center gap-y-4">
|
||||||
|
<CardTitle class="text-">Account not found.</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Check for typos or try again later.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import ErrorBoundary from "~/components/errors/ErrorBoundary.vue";
|
import { Loader } from "lucide-vue-next";
|
||||||
import AccountProfile from "~/components/profiles/profile.vue";
|
import AccountProfile from "~/components/profiles/profile.vue";
|
||||||
import AccountTimeline from "~/components/timelines/account.vue";
|
import AccountTimeline from "~/components/timelines/account.vue";
|
||||||
import TimelineScroller from "~/components/timelines/timeline-scroller.vue";
|
import TimelineScroller from "~/components/timelines/timeline-scroller.vue";
|
||||||
|
|
@ -24,7 +33,7 @@ const username = (route.params.username as string).startsWith("@")
|
||||||
? (route.params.username as string).substring(1)
|
? (route.params.username as string).substring(1)
|
||||||
: (route.params.username as string);
|
: (route.params.username as string);
|
||||||
|
|
||||||
const account = useAccountFromAcct(client, username);
|
const { account, isLoading } = useAccountFromAcct(client, username);
|
||||||
const accountId = computed(() => account.value?.id ?? undefined);
|
const accountId = computed(() => account.value?.id ?? undefined);
|
||||||
|
|
||||||
useServerSeoMeta({
|
useServerSeoMeta({
|
||||||
|
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
<template>
|
|
||||||
<ButtonsDemo />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import ButtonsDemo from "~/packages/ui/demo/buttons-demo.vue";
|
|
||||||
|
|
||||||
definePageMeta({
|
|
||||||
layout: "app",
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
@ -1,165 +1,59 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Client } from "@versia/client";
|
||||||
|
import { Loader } from "lucide-vue-next";
|
||||||
|
import UserAuthForm from "~/components/oauth/login.vue";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { NuxtLink } from "#components";
|
||||||
|
|
||||||
|
const {
|
||||||
|
public: { apiHost },
|
||||||
|
} = useRuntimeConfig();
|
||||||
|
|
||||||
|
const host = new URL(apiHost).host;
|
||||||
|
const instance = useInstanceFromClient(new Client(new URL(useBaseUrl().value)));
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex min-h-screen relative flex-col gap-10 justify-center py-12 px-8">
|
<div
|
||||||
<img crossorigin="anonymous" src="https://cdn.versia.pub/branding/icon.svg" alt="Versia logo"
|
class="container relative flex h-svh flex-col items-center justify-center md:flex-row lg:max-w-none lg:px-0">
|
||||||
class="mx-auto hidden md:inline-block h-20 ring-1 ring-white/20 rounded" />
|
<Button :as="NuxtLink" href="/register" variant="link" class="absolute right-4 top-4 md:right-8 md:top-8">
|
||||||
<div v-if="validUrlParameters" class="mx-auto w-full max-w-md">
|
Register
|
||||||
<VeeForm class="flex flex-col gap-y-6" method="POST" :validation-schema="schema"
|
</Button>
|
||||||
:action="redirectUrl.toString()">
|
<div class="relative hidden h-full flex-col bg-muted p-10 text-white dark:border-r lg:flex grow">
|
||||||
<h1 class="font-bold text-2xl text-gray-50 text-center tracking-tight">Login to your account</h1>
|
<div class="absolute inset-0 bg-zinc-900" />
|
||||||
|
<div class="relative z-20 flex items-center text-lg font-medium">
|
||||||
<div v-if="params.error" class="ring-1 ring-white/10 rounded p-4 bg-red-500 text-white">
|
<img crossorigin="anonymous" :src="instance?.thumbnail.url || 'https://cdn.versia.pub/branding/icon.svg'" alt="Versia logo"
|
||||||
<h2 class="font-bold text-lg">An error occured</h2>
|
class="size-10 mr-4" />
|
||||||
<span class="text-sm">{{ params.error_description }}</span>
|
{{ instance?.title }}
|
||||||
</div>
|
</div>
|
||||||
|
<!-- <div class="relative z-20 mt-auto">
|
||||||
<VeeField name="identifier" as="div" v-slot="{ errorMessage, field }" validate-on-change>
|
<blockquote class="space-y-2">
|
||||||
<Field>
|
<p class="text-lg">
|
||||||
<LabelAndError>
|
“This library has saved me countless hours of work and
|
||||||
<Label for="identifier">Username or Email</Label>
|
helped me deliver stunning designs to my clients faster than
|
||||||
<FieldError v-if="errorMessage">{{ errorMessage }}</FieldError>
|
ever before.”
|
||||||
</LabelAndError>
|
</p>
|
||||||
<TextInput id="identifier" placeholder="joemama" autocomplete="email username" required
|
<footer class="text-sm">
|
||||||
v-bind="field" :is-invalid="!!errorMessage" />
|
Sofia Davis
|
||||||
</Field>
|
</footer>
|
||||||
</VeeField>
|
</blockquote>
|
||||||
|
</div> -->
|
||||||
<VeeField name="password" as="div" v-slot="{ errorMessage, field }" validate-on-change>
|
|
||||||
<Field>
|
|
||||||
<LabelAndError>
|
|
||||||
<Label for="password">Password</Label>
|
|
||||||
<FieldError v-if="errorMessage">{{ errorMessage }}</FieldError>
|
|
||||||
</LabelAndError>
|
|
||||||
<PasswordInput id="password" placeholder="hunter2" autocomplete="current-password" required
|
|
||||||
v-bind="field" :is-invalid="!!errorMessage" />
|
|
||||||
</Field>
|
|
||||||
</VeeField>
|
|
||||||
|
|
||||||
<div v-if="ssoConfig && ssoConfig.providers.length > 0" class="w-full space-y-3">
|
|
||||||
<div
|
|
||||||
class="flex items-center text-center w-full after:border-b after:border-dark-200 after:flex-1 after:ml-2 before:border-b before:border-dark-200 before:flex-1 before:mr-2">
|
|
||||||
<h2 class="text-sm text-gray-200 font-semibold">Or sign in with</h2>
|
|
||||||
</div>
|
|
||||||
<div class="grid md:grid-cols-2 md:[&:has(>:last-child:nth-child(1))]:grid-cols-1 gap-4 w-full">
|
|
||||||
<a v-for="provider of ssoConfig.providers" :key="provider.id"
|
|
||||||
:href="issuerRedirectUrl(provider.id)">
|
|
||||||
<Button theme="secondary" class="flex flex-row w-full items-center justify-center gap-3">
|
|
||||||
<img crossorigin="anonymous" :src="provider.icon" :alt="`${provider.name}'s logo'`"
|
|
||||||
class="w-6 h-6" />
|
|
||||||
<div class="flex flex-col gap-0 justify-center">
|
|
||||||
<h3 class="font-bold">{{ provider.name }}</h3>
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="text-xs text-gray-300">
|
|
||||||
You are signing in to <strong>{{ hostname }}</strong>. If you did not intend to sign in
|
|
||||||
here, please close this page.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Button theme="primary" type="submit" class="w-full">Sign in</Button>
|
|
||||||
</VeeForm>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="mx-auto max-w-md">
|
<div class="lg:p-8 w-full max-w-xl">
|
||||||
<h1 class="text-2xl font-bold tracking-tight text-gray-50 sm:text-4xl">Invalid access
|
<div class="mx-auto flex w-full flex-col justify-center space-y-10 sm:w-[350px]">
|
||||||
parameters
|
<div class="flex flex-col space-y-2 text-center">
|
||||||
</h1>
|
<h1 class="text-2xl font-semibold tracking-tight">
|
||||||
<p class="mt-6 text-lg leading-8 text-gray-300">This page should be accessed
|
Log in to your account.
|
||||||
through a valid OAuth2 authorization request. Please use a <strong class="font-bold">Mastodon
|
</h1>
|
||||||
API</strong> client to access this page.
|
<p class="text-sm text-muted-foreground">
|
||||||
</p>
|
Enter your credentials for <code>{{ host }}</code>.
|
||||||
<p class="mt-6 text-lg leading-8 text-gray-300">Here are some recommended clients:</p>
|
</p>
|
||||||
<ul class="w-full flex flex-col gap-3 mt-4">
|
</div>
|
||||||
<li v-for="client of useConfig().RECOMMENDED_CLIENTS" :key="client.name" class="w-full">
|
<UserAuthForm v-if="instance" :instance="instance" />
|
||||||
<a :href="client.link" target="_blank"
|
<div v-else class="p-4 flex items-center justify-center h-48">
|
||||||
class="rounded-sm ring-2 ring-white/10 px-4 py-2 w-full flex flex-row gap-3 items-center">
|
<Loader class="size-8 animate-spin" />
|
||||||
<img crossorigin="anonymous" :src="client.icon" :alt="`${client.name}'s logo'`"
|
</div>
|
||||||
class="h-10 w-10" />
|
</div>
|
||||||
<div class="flex flex-col justify-between items-start">
|
|
||||||
<h2 class="font-bold text-gray-100">{{ client.name }}</h2>
|
|
||||||
<span class="underline text-primary2-700">{{ client.link }}</span>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<p class="mt-6 text-lg leading-8 text-gray-300">
|
|
||||||
Many other clients exist, but <strong class="font-bold">they have not been tested for
|
|
||||||
compatibility</strong>. Bug reports are nevertheless welcome.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p class="mt-6 text-lg leading-8 text-gray-300">
|
|
||||||
Found a problem? Report it on <a href="https://github.com/versia-pub/server/issues/new/choose"
|
|
||||||
target="_blank" class="underline text-primary2-700">the issue tracker</a>.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { toTypedSchema } from "@vee-validate/zod";
|
|
||||||
import { Client } from "@versia/client";
|
|
||||||
import { z } from "zod";
|
|
||||||
import FieldError from "~/components/inputs/field-error.vue";
|
|
||||||
import Field from "~/components/inputs/field.vue";
|
|
||||||
import LabelAndError from "~/components/inputs/label-and-error.vue";
|
|
||||||
import Label from "~/components/inputs/label.vue";
|
|
||||||
import PasswordInput from "~/components/inputs/password-input.vue";
|
|
||||||
import TextInput from "~/components/inputs/text-input.vue";
|
|
||||||
import Button from "~/packages/ui/components/buttons/button.vue";
|
|
||||||
|
|
||||||
const schema = toTypedSchema(
|
|
||||||
z.object({
|
|
||||||
identifier: z.string().min(3).or(z.string().email()),
|
|
||||||
password: z.string().min(3),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const hostname = useRequestURL().hostname;
|
|
||||||
const params = useUrlSearchParams();
|
|
||||||
|
|
||||||
const validUrlParameters =
|
|
||||||
params.redirect_uri &&
|
|
||||||
params.response_type &&
|
|
||||||
params.client_id &&
|
|
||||||
params.scope;
|
|
||||||
|
|
||||||
const instance = useInstanceFromClient(new Client(new URL(useBaseUrl().value)));
|
|
||||||
|
|
||||||
const ssoConfig = computed(() => instance.value?.sso);
|
|
||||||
|
|
||||||
const redirectUrl = new URL("/api/auth/login", useBaseUrl().value);
|
|
||||||
|
|
||||||
if (params.redirect_uri) {
|
|
||||||
redirectUrl.searchParams.set("redirect_uri", params.redirect_uri as string);
|
|
||||||
}
|
|
||||||
if (params.response_type) {
|
|
||||||
redirectUrl.searchParams.set(
|
|
||||||
"response_type",
|
|
||||||
params.response_type as string,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (params.client_id) {
|
|
||||||
redirectUrl.searchParams.set("client_id", params.client_id as string);
|
|
||||||
}
|
|
||||||
if (params.scope) {
|
|
||||||
redirectUrl.searchParams.set("scope", params.scope as string);
|
|
||||||
}
|
|
||||||
if (params.state) {
|
|
||||||
redirectUrl.searchParams.set("state", params.state as string);
|
|
||||||
}
|
|
||||||
|
|
||||||
const issuerRedirectUrl = (issuerId: string) => {
|
|
||||||
const url = new URL("/oauth/sso", useBaseUrl().value);
|
|
||||||
params.redirect_uri &&
|
|
||||||
url.searchParams.set("redirect_uri", params.redirect_uri as string);
|
|
||||||
params.response_type &&
|
|
||||||
url.searchParams.set("response_type", params.response_type as string);
|
|
||||||
params.client_id &&
|
|
||||||
url.searchParams.set("client_id", params.client_id as string);
|
|
||||||
params.scope && url.searchParams.set("scope", params.scope as string);
|
|
||||||
params.state && url.searchParams.set("state", params.state as string);
|
|
||||||
url.searchParams.set("issuer", issuerId);
|
|
||||||
return url.toString();
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
@ -12,8 +12,7 @@ const art = `
|
||||||
* Join development at https://github.com/versia-pub
|
* Join development at https://github.com/versia-pub
|
||||||
*
|
*
|
||||||
* With ❤️ from us:
|
* With ❤️ from us:
|
||||||
* - @jessew@social.lysand.org
|
* - @jessew@beta.versia.social
|
||||||
* - @aprl@social.lysand.org
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default defineNitroPlugin((nitroApp) => {
|
export default defineNitroPlugin((nitroApp) => {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
|
|
||||||
|
@tailwind components;
|
||||||
|
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--background: 0 0% 100%;
|
--background: 0 0% 100%;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue