mirror of
https://github.com/versia-pub/frontend.git
synced 2026-03-13 03:29:16 +01:00
feat: ✨ Add profile viewer
This commit is contained in:
parent
a6c5093cf5
commit
1194bc4ffb
19 changed files with 466 additions and 47 deletions
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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue