mirror of
https://github.com/versia-pub/frontend.git
synced 2025-12-06 08:28:20 +01:00
feat: ✨ Add profile viewer
This commit is contained in:
parent
a6c5093cf5
commit
1194bc4ffb
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
130
components/profiles/profile-actions.vue
Normal file
130
components/profiles/profile-actions.vue
Normal 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>
|
||||
36
components/profiles/profile-badge.vue
Normal file
36
components/profiles/profile-badge.vue
Normal 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>
|
||||
69
components/profiles/profile-content.vue
Normal file
69
components/profiles/profile-content.vue
Normal 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>
|
||||
16
components/profiles/profile-fields.vue
Normal file
16
components/profiles/profile-fields.vue
Normal 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>
|
||||
24
components/profiles/profile-header.vue
Normal file
24
components/profiles/profile-header.vue
Normal 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>
|
||||
39
components/profiles/profile-stats.vue
Normal file
39
components/profiles/profile-stats.vue
Normal 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>
|
||||
·
|
||||
<div>
|
||||
<span class="text-primary font-semibold">{{ followerCount }}</span> Followers
|
||||
</div>
|
||||
·
|
||||
<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>
|
||||
97
components/profiles/profile.vue
Normal file
97
components/profiles/profile.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
11
components/ui/aspect-ratio/AspectRatio.vue
Normal file
11
components/ui/aspect-ratio/AspectRatio.vue
Normal 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>
|
||||
1
components/ui/aspect-ratio/index.ts
Normal file
1
components/ui/aspect-ratio/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default as AspectRatio } from "./AspectRatio.vue";
|
||||
17
composables/AccountAcct.ts
Normal file
17
composables/AccountAcct.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Reference in a new issue