feat: Add profile viewer

This commit is contained in:
Jesse Wierzbinski 2024-12-02 16:07:52 +01:00
parent a6c5093cf5
commit 1194bc4ffb
No known key found for this signature in database
19 changed files with 466 additions and 47 deletions

BIN
bun.lockb

Binary file not shown.

View file

@ -1,11 +1,11 @@
<template>
<div class="rounded flex flex-row items-center gap-3">
<NuxtLink :href="url" :class="cn('relative size-14', smallLayout && 'size-8')">
<Avatar :class="cn('size-14 rounded-md border border-card', smallLayout && 'size-8')">
<Avatar shape="square" :class="cn('size-14 border border-card', smallLayout && 'size-8')">
<AvatarImage :src="avatar" alt="" />
<AvatarFallback class="rounded-lg"> AA </AvatarFallback>
</Avatar>
<Avatar v-if="cornerAvatar" class="size-6 rounded border absolute -bottom-1 -right-1">
<Avatar shape="square" v-if="cornerAvatar" class="size-6 border absolute -bottom-1 -right-1">
<AvatarImage :src="cornerAvatar" alt="" />
<AvatarFallback class="rounded-lg"> AA </AvatarFallback>
</Avatar>

View file

@ -6,7 +6,6 @@ import {
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
@ -14,8 +13,9 @@ import {
Code,
Delete,
ExternalLink,
Flag,
Hash,
Link,
MessageSquare,
Pencil,
Trash,
} from "lucide-vue-next";
@ -86,27 +86,29 @@ const _delete = async () => {
<DropdownMenuItem v-if="authorIsMe" as="button" @click="emit('edit')">
<Pencil class="mr-2 size-4" />
<span>Edit</span>
<DropdownMenuShortcut>E</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem as="button" @click="copyText(apiNoteString)">
<Code class="mr-2 size-4" />
<span>Copy API data</span>
<DropdownMenuShortcut>B</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem as="button" @click="copyText(noteId)">
<Hash class="mr-2 size-4" />
<span>Copy ID</span>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem as="button" @click="copyText(url)">
<Link class="mr-2 size-4" />
<span>Copy link</span>
<DropdownMenuShortcut>S</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem as="button" v-if="isRemote" @click="copyText(remoteUrl)">
<Link class="mr-2 size-4" />
<span>Copy link (origin)</span>
<DropdownMenuShortcut>K</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem as="a" v-if="isRemote" target="_blank" rel="noopener noreferrer" :href="remoteUrl">
<ExternalLink class="mr-2 size-4" />
<span>Open on remote</span>
<DropdownMenuShortcut>F</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator v-if="authorIsMe" />
@ -118,13 +120,12 @@ const _delete = async () => {
<DropdownMenuItem as="button" @click="_delete">
<Trash class="mr-2 size-4" />
<span>Delete</span>
<DropdownMenuShortcut>D</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator v-if="loggedIn && !authorIsMe" />
<DropdownMenuGroup v-if="loggedIn && !authorIsMe">
<DropdownMenuItem as="button" :disabled="true">
<MessageSquare class="mr-2 size-4" />
<Flag class="mr-2 size-4" />
<span>Report</span>
</DropdownMenuItem>
<DropdownMenuItem as="button" @click="blockUser(authorId)">

View file

@ -1,7 +1,7 @@
<template>
<NuxtLink :href="url" class="rounded border hover:bg-muted duration-100 text-sm flex flex-row items-center gap-2 px-2 py-1 mb-4">
<Repeat class="size-4 text-primary" />
<Avatar class="size-6 rounded border">
<Avatar shape="square" class="size-6 border">
<AvatarImage :src="avatar" alt="" />
<AvatarFallback class="rounded-lg"> AA </AvatarFallback>
</Avatar>

View file

@ -1,7 +1,7 @@
<template>
<div v-if="relationship?.requested_by !== false" class="flex flex-row items-center gap-3 p-4">
<NuxtLink class="relative size-10">
<Avatar class="size-10 rounded border border-border">
<Avatar shape="square" class="size-10 border border-border">
<AvatarImage :src="follower.avatar" alt="" />
<AvatarFallback class="rounded-lg"> AA </AvatarFallback>
</Avatar>
@ -43,6 +43,7 @@
import type { Account } from "@versia/client/types";
import { Check, Loader, X } from "lucide-vue-next";
import { toast } from "vue-sonner";
import CopyableText from "~/components/notes/copyable-text.vue";
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
import { Button } from "~/components/ui/button";

View file

@ -1,14 +1,14 @@
<template>
<Card>
<Collapsible>
<Collapsible :default-open="true">
<Tooltip>
<TooltipTrigger :as-child="true">
<CardHeader v-if="notification.account"
class="flex-row items-center gap-2 px-4 py-2 border-b border-border">
<component :is="icon" class="size-5 shrink-0" />
<Avatar class="size-6 rounded-md border border-card">
<Avatar shape="square" class="size-6 border border-card">
<AvatarImage :src="notification.account.avatar" alt="" />
<AvatarFallback class="rounded-lg"> AA </AvatarFallback>
<AvatarFallback> AA </AvatarFallback>
</Avatar>
<span class="font-semibold">{{
notification.account.display_name

View file

@ -0,0 +1,130 @@
<template>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<slot />
</DropdownMenuTrigger>
<DropdownMenuContent class="w-56">
<DropdownMenuLabel>Profile Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem as="button" @click="copyText(account.username)">
<AtSign class="mr-2 size-4" />
<span>Copy username</span>
</DropdownMenuItem>
<DropdownMenuItem as="button" @click="copyText(JSON.stringify(account, null, 4))">
<Code class="mr-2 size-4" />
<span>Copy API data</span>
</DropdownMenuItem>
<DropdownMenuItem as="button" @click="copyText(account.id)">
<Hash class="mr-2 size-4" />
<span>Copy ID</span>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem as="button" @click="copyText(url)">
<Link class="mr-2 size-4" />
<span>Copy link</span>
</DropdownMenuItem>
<DropdownMenuItem as="button" @click="copyText(account.url)">
<Link class="mr-2 size-4" />
<span>Copy link (origin)</span>
</DropdownMenuItem>
<DropdownMenuItem as="a" v-if="isRemote" target="_blank" rel="noopener noreferrer" :href="account.url">
<ExternalLink class="mr-2 size-4" />
<span>Open on remote</span>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator v-if="isLoggedIn && !isMe" />
<DropdownMenuGroup v-if="isLoggedIn && !isMe">
<DropdownMenuItem as="button" @click="muteUser(account.id)">
<VolumeX class="mr-2 size-4" />
<span>Mute</span>
</DropdownMenuItem>
<DropdownMenuItem as="button" @click="blockUser(account.id)">
<Ban class="mr-2 size-4" />
<span>Block</span>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator v-if="isRemote" />
<DropdownMenuGroup v-if="isRemote">
<DropdownMenuItem as="button" @click="refresh">
<RefreshCw class="mr-2 size-4" />
<span>Refresh</span>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator v-if="isLoggedIn && !isMe" />
<DropdownMenuGroup v-if="isLoggedIn && !isMe">
<DropdownMenuItem as="button" :disabled="true">
<Flag class="mr-2 size-4" />
<span>Report</span>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</template>
<script lang="ts" setup>
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { Account } from "@versia/client/types";
import {
AtSign,
Ban,
Code,
ExternalLink,
Flag,
Hash,
Link,
RefreshCw,
VolumeX,
} from "lucide-vue-next";
import { toast } from "vue-sonner";
const { account } = defineProps<{
account: Account;
}>();
const isMe = identity.value?.account.id === account.id;
const isLoggedIn = !!identity.value;
const { copy } = useClipboard();
const copyText = (text: string) => {
copy(text);
toast.success("Copied to clipboard");
};
const url = wrapUrl(`/@${account.acct}`);
const isRemote = account.acct.includes("@");
const muteUser = async (userId: string) => {
const id = toast.loading("Muting user...");
await client.value.muteAccount(userId);
toast.dismiss(id);
toast.success("User muted");
};
const blockUser = async (userId: string) => {
const id = toast.loading("Blocking user...");
await client.value.blockAccount(userId);
toast.dismiss(id);
toast.success("User blocked");
};
const refresh = async () => {
const id = toast.loading("Requesting refresh...");
await client.value.refetchAccount(account.id);
toast.dismiss(id);
toast.success("Account refreshed");
};
</script>

View file

@ -0,0 +1,36 @@
<template>
<Tooltip>
<TooltipTrigger :as-child="true">
<Badge variant="outline" class="gap-1">
<svg viewBox="0 0 22 22" v-if="verified" aria-hidden="true" class="size-4 fill-secondary-foreground">
<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="icon" :src="icon" alt="" class="size-4 rounded-sm" />
{{ name }}
</Badge>
</TooltipTrigger>
<TooltipContent v-if="description">
<p>{{ description }}</p>
</TooltipContent>
</Tooltip>
</template>
<script lang="ts" setup>
import { Badge } from "~/components/ui/badge";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "~/components/ui/tooltip";
defineProps<{
name: string;
description?: string;
icon?: string;
verified?: boolean;
}>();
</script>

View file

@ -0,0 +1,69 @@
<template>
<div :class="['prose prose-sm block relative dark:prose-invert duration-200 !max-w-full break-words prose-a:no-underline prose-a:hover:underline', $style.content]" v-html="content">
</div>
</template>
<script lang="ts" setup>
const { content } = defineProps<{
content: string;
}>();
</script>
<style module>
.content pre:has(code) {
word-wrap: normal;
background: transparent;
background-color: #ffffff0d;
border-radius: .25rem;
hyphens: none;
margin-top: 1rem;
overflow-x: auto;
padding: .75rem 1rem;
tab-size: 4;
white-space: pre;
word-break: normal;
word-spacing: normal;
--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)
}
.content pre code {
display: block;
padding: 0
}
.content code:not(pre code)::after,
.content code:not(pre code)::before {
content: ""
}
.content ol li input[type=checkbox],
.content ul li input[type=checkbox] {
border-radius:.25rem;
margin-bottom:0.2rem;
margin-right:.5rem;
margin-top:0;
vertical-align: middle;
--tw-text-opacity:1;
color: var(--theme-primary-400);
}
.content code:not(pre code) {
border-radius: .25rem;
padding: .25rem .5rem;
word-wrap: break-word;
background: transparent;
background-color: #ffffff0d;
hyphens: none;
margin-top: 1rem;
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>

View file

@ -0,0 +1,16 @@
<template>
<div class="flex flex-col gap-y-4">
<div v-for="field in fields" :key="field.name" class="flex flex-col gap-1">
<h3 class="font-semibold text-sm">{{ field.name }}</h3>
<div v-html="field.value" class="prose prose-sm prose-zinc dark:prose-invert"></div>
</div>
</div>
</template>
<script lang="ts" setup>
import type { Field } from "@versia/client/types";
defineProps<{
fields: Field[];
}>();
</script>

View file

@ -0,0 +1,24 @@
<template>
<CardHeader class="p-0 relative">
<div class="bg-muted rounded overflow-hidden h-48 md:h-72 w-full">
<img :src="header" alt="" class="object-cover w-full h-full" />
<!-- Shadow overlay at the bottom -->
<div class="absolute bottom-0 w-full h-1/3 bg-gradient-to-b from-black/0 to-black/40"></div>
</div>
<div class="absolute bottom-0 translate-y-1/3 left-4 flex flex-row items-start gap-2">
<Avatar shape="square" size="lg" class="border">
<AvatarImage :src="avatar" alt="" />
<AvatarFallback>AA</AvatarFallback>
</Avatar>
</div>
</CardHeader>
</template>
<script lang="ts" setup>
import { CardHeader } from "~/components/ui/card";
defineProps<{
header: string;
avatar: string;
}>();
</script>

View file

@ -0,0 +1,39 @@
<template>
<div class="flex flex-col gap-2">
<div class="flex flex-row flex-wrap gap-2 *:flex *:items-center *:gap-1 *:text-muted-foreground">
<div>
<CalendarDays class="size-4" />
Joined <span class="text-primary font-semibold">{{ formattedCreationDate }}</span>
</div>
</div>
<div class="flex flex-row flex-wrap gap-2 *:flex *:items-center *:gap-1 *:text-muted-foreground">
<div>
<span class="text-primary font-semibold">{{ noteCount }}</span> Notes
</div>
&middot;
<div>
<span class="text-primary font-semibold">{{ followerCount }}</span> Followers
</div>
&middot;
<div>
<span class="text-primary font-semibold">{{ followingCount }}</span> Following
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { CalendarDays } from "lucide-vue-next";
const { creationDate } = defineProps<{
creationDate: Date;
noteCount: number;
followerCount: number;
followingCount: number;
}>();
const formattedCreationDate = new Intl.DateTimeFormat("en-US", {
month: "long",
year: "numeric",
}).format(creationDate);
</script>

View file

@ -0,0 +1,97 @@
<template>
<Card>
<ProfileHeader :header="account.header" :avatar="account.avatar" />
<CardContent class="pt-3 gap-4 flex flex-col">
<div class="flex flex-row justify-end gap-2">
<Button variant="secondary" :disabled="isLoading || relationship?.requested" v-if="!isMe"
@click="relationship?.following ? unfollow() : follow()">
<Loader v-if="isLoading" class="animate-spin" />
<span v-else>
{{ relationship?.following ? "Unfollow" : relationship?.requested ? "Requested" : "Follow" }}
</span>
</Button>
<ProfileActions :account="account">
<Button variant="secondary" size="icon">
<Ellipsis />
</Button>
</ProfileActions>
</div>
<div class="flex flex-col -mt-1 gap-2 justify-center">
<CardTitle class="">
{{ account.display_name }}
</CardTitle>
<CopyableText :text="account.acct">
<span
class="font-semibold bg-gradient-to-tr from-pink-700 dark:from-indigo-400 via-purple-700 dark:via-purple-400 to-indigo-700 dark:to-indigo-400 text-transparent bg-clip-text">
@{{ username }}
</span>
<span class="text-muted-foreground">{{ instance && "@" }}{{ instance }}</span>
</CopyableText>
</div>
<div class="flex flex-row flex-wrap gap-2 -mx-2" v-if="isDeveloper || account.bot || roles.length > 0">
<ProfileBadge v-if="isDeveloper" name="Versia Developer" description="This user is a Versia developer."
:verified="true" />
<ProfileBadge v-if="account.bot" name="Automated"
description="This account is not operated as living entity." />
<ProfileBadge v-for="role in roles" :key="role.id" :name="role.name" :description="role.description"
:icon="role.icon" />
</div>
<ProfileContent :content="account.note" />
</CardContent>
<CardFooter class="flex-col items-start gap-4">
<ProfileStats :creation-date="new Date(account.created_at || 0)" :follower-count="account.followers_count"
:following-count="account.following_count" :note-count="account.statuses_count" />
<Separator v-if="account.fields.length > 0" />
<ProfileFields v-if="account.fields.length > 0" :fields="account.fields" />
</CardFooter>
</Card>
</template>
<script lang="ts" setup>
import type { Account } from "@versia/client/types";
import { Ellipsis, Loader } from "lucide-vue-next";
import { toast } from "vue-sonner";
import CopyableText from "~/components/notes/copyable-text.vue";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardFooter, CardTitle } from "~/components/ui/card";
import { Separator } from "~/components/ui/separator";
import ProfileActions from "./profile-actions.vue";
import ProfileBadge from "./profile-badge.vue";
import ProfileContent from "./profile-content.vue";
import ProfileFields from "./profile-fields.vue";
import ProfileHeader from "./profile-header.vue";
import ProfileStats from "./profile-stats.vue";
const { account } = defineProps<{
account: Account;
}>();
const config = useConfig();
const { relationship, isLoading } = useRelationship(client, account.id);
const isMe = identity.value?.account.id === account.id;
const [username, instance] = account.acct.split("@");
const roles = account.roles.filter((r) => r.visible);
// Get user handle in username@instance format
const handle = account.acct.includes("@")
? account.acct
: `${account.acct}@${identity.value?.instance.domain ?? window.location.host}`;
const isDeveloper = config.DEVELOPER_HANDLES.includes(handle);
const follow = async () => {
const id = toast.loading("Following user...");
const { data } = await client.value.followAccount(account.id);
toast.dismiss(id);
relationship.value = data;
toast.success("User followed");
};
const unfollow = async () => {
const id = toast.loading("Unfollowing user...");
const { data } = await client.value.unfollowAccount(account.id);
toast.dismiss(id);
relationship.value = data;
toast.success("User unfollowed");
};
</script>

View file

@ -194,7 +194,7 @@ const instance = useInstance();
<DropdownMenuTrigger as-child>
<SidebarMenuButton size="lg"
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground">
<Avatar class="h-8 w-8 rounded-lg">
<Avatar shape="square" class="size-8">
<AvatarImage :src="identity?.account.avatar" alt="" />
<AvatarFallback class="rounded-lg"> AA </AvatarFallback>
</Avatar>
@ -211,7 +211,7 @@ const instance = useInstance();
side="bottom" align="end" :side-offset="4">
<DropdownMenuLabel class="p-0 font-normal">
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar class="h-8 w-8 rounded-lg">
<Avatar shape="square" class="size-8">
<AvatarImage :src="identity?.account.avatar" alt="" />
<AvatarFallback class="rounded-lg"> AA </AvatarFallback>
</Avatar>

View file

@ -0,0 +1,11 @@
<script setup lang="ts">
import { AspectRatio, type AspectRatioProps } from "radix-vue";
const props = defineProps<AspectRatioProps>();
</script>
<template>
<AspectRatio v-bind="props">
<slot />
</AspectRatio>
</template>

View file

@ -0,0 +1 @@
export { default as AspectRatio } from "./AspectRatio.vue";

View file

@ -0,0 +1,17 @@
import type { Client } from "@versia/client";
import type { Account } from "@versia/client/types";
export const useAccountFromAcct = (
client: MaybeRef<Client | null>,
acct: string,
): Ref<Account | null> => {
const output = ref(null as Account | null);
ref(client)
.value?.lookupAccount(acct)
.then((res) => {
output.value = res.data;
});
return output;
};

View file

@ -35,7 +35,7 @@
"@tailwindcss/typography": "^0.5.15",
"@vee-validate/nuxt": "^4.14.7",
"@vee-validate/zod": "^4.14.7",
"@versia/client": "^0.1.2",
"@versia/client": "0.1.3",
"@vite-pwa/nuxt": "^0.10.6",
"@vueuse/core": "^12.0.0",
"@vueuse/nuxt": "^12.0.0",

View file

@ -1,8 +1,8 @@
<template>
<ErrorBoundary>
<div class="mx-auto max-w-2xl w-full">
<TimelineScroller>
<AccountProfile :account="account ?? undefined" />
<div class="mx-auto max-w-2xl w-full space-y-2">
<TimelineScroller v-if="account">
<AccountProfile :account="account" />
<AccountTimeline v-if="accountId" :id="accountId" :key="accountId" />
</TimelineScroller>
</div>
@ -10,9 +10,8 @@
</template>
<script setup lang="ts">
import type { Account } from "@versia/client/types";
import ErrorBoundary from "~/components/errors/ErrorBoundary.vue";
import AccountProfile from "~/components/social-elements/users/Account.vue";
import AccountProfile from "~/components/profiles/profile.vue";
import AccountTimeline from "~/components/timelines/account.vue";
import TimelineScroller from "~/components/timelines/timeline-scroller.vue";
@ -25,29 +24,7 @@ const username = (route.params.username as string).startsWith("@")
? (route.params.username as string).substring(1)
: (route.params.username as string);
const accounts = useAccountSearch(client, username);
watch(accounts, (newValue) => {
if (Array.isArray(newValue)) {
if (
!newValue.find(
(account) =>
account.acct.toLowerCase() === username.toLowerCase(),
)
) {
useEvent("error", {
title: "Account not found",
message: `The account <code>@${username}</code> does not exist.`,
code: "ERR_ACCOUNT_NOT_FOUND",
});
}
}
});
const account = computed<Account | null>(
() =>
accounts.value?.find(
(account) => account.acct.toLowerCase() === username.toLowerCase(),
) ?? null,
);
const account = useAccountFromAcct(client, username);
const accountId = computed(() => account.value?.id ?? undefined);
useServerSeoMeta({