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>
|
<template>
|
||||||
<div class="rounded flex flex-row items-center gap-3">
|
<div class="rounded flex flex-row items-center gap-3">
|
||||||
<NuxtLink :href="url" :class="cn('relative size-14', smallLayout && 'size-8')">
|
<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="" />
|
<AvatarImage :src="avatar" alt="" />
|
||||||
<AvatarFallback class="rounded-lg"> AA </AvatarFallback>
|
<AvatarFallback class="rounded-lg"> AA </AvatarFallback>
|
||||||
</Avatar>
|
</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="" />
|
<AvatarImage :src="cornerAvatar" alt="" />
|
||||||
<AvatarFallback class="rounded-lg"> AA </AvatarFallback>
|
<AvatarFallback class="rounded-lg"> AA </AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import {
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuShortcut,
|
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import {
|
import {
|
||||||
|
|
@ -14,8 +13,9 @@ import {
|
||||||
Code,
|
Code,
|
||||||
Delete,
|
Delete,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
|
Flag,
|
||||||
|
Hash,
|
||||||
Link,
|
Link,
|
||||||
MessageSquare,
|
|
||||||
Pencil,
|
Pencil,
|
||||||
Trash,
|
Trash,
|
||||||
} from "lucide-vue-next";
|
} from "lucide-vue-next";
|
||||||
|
|
@ -86,27 +86,29 @@ const _delete = async () => {
|
||||||
<DropdownMenuItem v-if="authorIsMe" as="button" @click="emit('edit')">
|
<DropdownMenuItem v-if="authorIsMe" as="button" @click="emit('edit')">
|
||||||
<Pencil class="mr-2 size-4" />
|
<Pencil class="mr-2 size-4" />
|
||||||
<span>Edit</span>
|
<span>Edit</span>
|
||||||
<DropdownMenuShortcut>⇧⌘E</DropdownMenuShortcut>
|
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem as="button" @click="copyText(apiNoteString)">
|
<DropdownMenuItem as="button" @click="copyText(apiNoteString)">
|
||||||
<Code class="mr-2 size-4" />
|
<Code class="mr-2 size-4" />
|
||||||
<span>Copy API data</span>
|
<span>Copy API data</span>
|
||||||
<DropdownMenuShortcut>⌘B</DropdownMenuShortcut>
|
|
||||||
</DropdownMenuItem>
|
</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)">
|
<DropdownMenuItem as="button" @click="copyText(url)">
|
||||||
<Link class="mr-2 size-4" />
|
<Link class="mr-2 size-4" />
|
||||||
<span>Copy link</span>
|
<span>Copy link</span>
|
||||||
<DropdownMenuShortcut>⌘S</DropdownMenuShortcut>
|
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem as="button" v-if="isRemote" @click="copyText(remoteUrl)">
|
<DropdownMenuItem as="button" v-if="isRemote" @click="copyText(remoteUrl)">
|
||||||
<Link class="mr-2 size-4" />
|
<Link class="mr-2 size-4" />
|
||||||
<span>Copy link (origin)</span>
|
<span>Copy link (origin)</span>
|
||||||
<DropdownMenuShortcut>⌘K</DropdownMenuShortcut>
|
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem as="a" v-if="isRemote" target="_blank" rel="noopener noreferrer" :href="remoteUrl">
|
<DropdownMenuItem as="a" v-if="isRemote" target="_blank" rel="noopener noreferrer" :href="remoteUrl">
|
||||||
<ExternalLink class="mr-2 size-4" />
|
<ExternalLink class="mr-2 size-4" />
|
||||||
<span>Open on remote</span>
|
<span>Open on remote</span>
|
||||||
<DropdownMenuShortcut>⌘F</DropdownMenuShortcut>
|
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
<DropdownMenuSeparator v-if="authorIsMe" />
|
<DropdownMenuSeparator v-if="authorIsMe" />
|
||||||
|
|
@ -118,13 +120,12 @@ const _delete = async () => {
|
||||||
<DropdownMenuItem as="button" @click="_delete">
|
<DropdownMenuItem as="button" @click="_delete">
|
||||||
<Trash class="mr-2 size-4" />
|
<Trash class="mr-2 size-4" />
|
||||||
<span>Delete</span>
|
<span>Delete</span>
|
||||||
<DropdownMenuShortcut>⌘D</DropdownMenuShortcut>
|
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
<DropdownMenuSeparator v-if="loggedIn && !authorIsMe" />
|
<DropdownMenuSeparator v-if="loggedIn && !authorIsMe" />
|
||||||
<DropdownMenuGroup v-if="loggedIn && !authorIsMe">
|
<DropdownMenuGroup v-if="loggedIn && !authorIsMe">
|
||||||
<DropdownMenuItem as="button" :disabled="true">
|
<DropdownMenuItem as="button" :disabled="true">
|
||||||
<MessageSquare class="mr-2 size-4" />
|
<Flag class="mr-2 size-4" />
|
||||||
<span>Report</span>
|
<span>Report</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem as="button" @click="blockUser(authorId)">
|
<DropdownMenuItem as="button" @click="blockUser(authorId)">
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<template>
|
<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">
|
<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" />
|
<Repeat class="size-4 text-primary" />
|
||||||
<Avatar class="size-6 rounded border">
|
<Avatar shape="square" class="size-6 border">
|
||||||
<AvatarImage :src="avatar" alt="" />
|
<AvatarImage :src="avatar" alt="" />
|
||||||
<AvatarFallback class="rounded-lg"> AA </AvatarFallback>
|
<AvatarFallback class="rounded-lg"> AA </AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<template>
|
<template>
|
||||||
<div v-if="relationship?.requested_by !== false" class="flex flex-row items-center gap-3 p-4">
|
<div v-if="relationship?.requested_by !== false" class="flex flex-row items-center gap-3 p-4">
|
||||||
<NuxtLink class="relative size-10">
|
<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="" />
|
<AvatarImage :src="follower.avatar" alt="" />
|
||||||
<AvatarFallback class="rounded-lg"> AA </AvatarFallback>
|
<AvatarFallback class="rounded-lg"> AA </AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
|
@ -43,6 +43,7 @@
|
||||||
import type { Account } from "@versia/client/types";
|
import type { Account } from "@versia/client/types";
|
||||||
import { Check, Loader, X } from "lucide-vue-next";
|
import { Check, Loader, X } from "lucide-vue-next";
|
||||||
import { toast } from "vue-sonner";
|
import { toast } from "vue-sonner";
|
||||||
|
import CopyableText from "~/components/notes/copyable-text.vue";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
<template>
|
<template>
|
||||||
<Card>
|
<Card>
|
||||||
<Collapsible>
|
<Collapsible :default-open="true">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger :as-child="true">
|
<TooltipTrigger :as-child="true">
|
||||||
<CardHeader v-if="notification.account"
|
<CardHeader v-if="notification.account"
|
||||||
class="flex-row items-center gap-2 px-4 py-2 border-b border-border">
|
class="flex-row items-center gap-2 px-4 py-2 border-b border-border">
|
||||||
<component :is="icon" class="size-5 shrink-0" />
|
<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="" />
|
<AvatarImage :src="notification.account.avatar" alt="" />
|
||||||
<AvatarFallback class="rounded-lg"> AA </AvatarFallback>
|
<AvatarFallback> AA </AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<span class="font-semibold">{{
|
<span class="font-semibold">{{
|
||||||
notification.account.display_name
|
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>
|
<DropdownMenuTrigger as-child>
|
||||||
<SidebarMenuButton size="lg"
|
<SidebarMenuButton size="lg"
|
||||||
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground">
|
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="" />
|
<AvatarImage :src="identity?.account.avatar" alt="" />
|
||||||
<AvatarFallback class="rounded-lg"> AA </AvatarFallback>
|
<AvatarFallback class="rounded-lg"> AA </AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
|
@ -211,7 +211,7 @@ const instance = useInstance();
|
||||||
side="bottom" align="end" :side-offset="4">
|
side="bottom" align="end" :side-offset="4">
|
||||||
<DropdownMenuLabel class="p-0 font-normal">
|
<DropdownMenuLabel class="p-0 font-normal">
|
||||||
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
<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="" />
|
<AvatarImage :src="identity?.account.avatar" alt="" />
|
||||||
<AvatarFallback class="rounded-lg"> AA </AvatarFallback>
|
<AvatarFallback class="rounded-lg"> AA </AvatarFallback>
|
||||||
</Avatar>
|
</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",
|
"@tailwindcss/typography": "^0.5.15",
|
||||||
"@vee-validate/nuxt": "^4.14.7",
|
"@vee-validate/nuxt": "^4.14.7",
|
||||||
"@vee-validate/zod": "^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",
|
"@vite-pwa/nuxt": "^0.10.6",
|
||||||
"@vueuse/core": "^12.0.0",
|
"@vueuse/core": "^12.0.0",
|
||||||
"@vueuse/nuxt": "^12.0.0",
|
"@vueuse/nuxt": "^12.0.0",
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
<template>
|
<template>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<div class="mx-auto max-w-2xl w-full">
|
<div class="mx-auto max-w-2xl w-full space-y-2">
|
||||||
<TimelineScroller>
|
<TimelineScroller v-if="account">
|
||||||
<AccountProfile :account="account ?? undefined" />
|
<AccountProfile :account="account" />
|
||||||
<AccountTimeline v-if="accountId" :id="accountId" :key="accountId" />
|
<AccountTimeline v-if="accountId" :id="accountId" :key="accountId" />
|
||||||
</TimelineScroller>
|
</TimelineScroller>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -10,9 +10,8 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Account } from "@versia/client/types";
|
|
||||||
import ErrorBoundary from "~/components/errors/ErrorBoundary.vue";
|
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 AccountTimeline from "~/components/timelines/account.vue";
|
||||||
import TimelineScroller from "~/components/timelines/timeline-scroller.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).substring(1)
|
||||||
: (route.params.username as string);
|
: (route.params.username as string);
|
||||||
|
|
||||||
const accounts = useAccountSearch(client, username);
|
const account = useAccountFromAcct(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 accountId = computed(() => account.value?.id ?? undefined);
|
const accountId = computed(() => account.value?.id ?? undefined);
|
||||||
|
|
||||||
useServerSeoMeta({
|
useServerSeoMeta({
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue