mirror of
https://github.com/versia-pub/frontend.git
synced 2025-12-06 08:28:20 +01:00
feat: ✨ Implement profile hover cards
This commit is contained in:
parent
b4709dc00f
commit
650d916062
|
|
@ -1,15 +1,28 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="rounded flex flex-row items-center gap-3">
|
<div class="rounded flex flex-row items-center gap-3">
|
||||||
|
<HoverCard v-model:open="popupOpen" @update:open="() => {
|
||||||
|
if (!enableHoverCard.value) {
|
||||||
|
popupOpen = false;
|
||||||
|
}
|
||||||
|
}">
|
||||||
|
<HoverCardTrigger :as-child="true">
|
||||||
<NuxtLink :href="urlAsPath" :class="cn('relative size-14', smallLayout && 'size-8')">
|
<NuxtLink :href="urlAsPath" :class="cn('relative size-14', smallLayout && 'size-8')">
|
||||||
<Avatar :class="cn('size-14 border border-card', smallLayout && 'size-8')" :src="avatar" :name="displayName" />
|
<Avatar :class="cn('size-14 border border-card', smallLayout && 'size-8')" :src="author.avatar"
|
||||||
|
:name="author.display_name" />
|
||||||
<Avatar v-if="cornerAvatar" class="size-6 border absolute -bottom-1 -right-1" :src="cornerAvatar" />
|
<Avatar v-if="cornerAvatar" class="size-6 border absolute -bottom-1 -right-1" :src="cornerAvatar" />
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<div :class="cn('flex flex-col gap-0.5 justify-center flex-1 text-left leading-tight', smallLayout && 'text-sm')">
|
</HoverCardTrigger>
|
||||||
<span class="truncate font-semibold" v-render-emojis="emojis">{{
|
<HoverCardContent class="w-96">
|
||||||
displayName
|
<SmallCard :account="author" />
|
||||||
|
</HoverCardContent>
|
||||||
|
</HoverCard>
|
||||||
|
<div
|
||||||
|
:class="cn('flex flex-col gap-0.5 justify-center flex-1 text-left leading-tight', smallLayout && 'text-sm')">
|
||||||
|
<span class="truncate font-semibold" v-render-emojis="author.emojis">{{
|
||||||
|
author.display_name
|
||||||
}}</span>
|
}}</span>
|
||||||
<span class="truncate text-sm tracking-tight">
|
<span class="truncate text-sm tracking-tight">
|
||||||
<CopyableText :text="acct">
|
<CopyableText :text="author.acct">
|
||||||
<span
|
<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">
|
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 }}
|
@{{ username }}
|
||||||
|
|
@ -21,7 +34,8 @@
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-1 justify-center items-end" v-if="!smallLayout">
|
<div class="flex flex-col gap-1 justify-center items-end" v-if="!smallLayout">
|
||||||
<NuxtLink :href="noteUrlAsPath" class="text-xs text-muted-foreground" :title="visibilities[visibility].text">
|
<NuxtLink :href="noteUrlAsPath" class="text-xs text-muted-foreground"
|
||||||
|
:title="visibilities[visibility].text">
|
||||||
<component :is="visibilities[visibility].icon" class="size-5" />
|
<component :is="visibilities[visibility].icon" class="size-5" />
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -30,31 +44,35 @@
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { Emoji, StatusVisibility } from "@versia/client/types";
|
import type { Account, StatusVisibility } from "@versia/client/types";
|
||||||
import type {
|
import type {
|
||||||
UseTimeAgoMessages,
|
UseTimeAgoMessages,
|
||||||
UseTimeAgoUnitNamesDefault,
|
UseTimeAgoUnitNamesDefault,
|
||||||
} from "@vueuse/core";
|
} from "@vueuse/core";
|
||||||
import { AtSign, Globe, Lock, LockOpen } from "lucide-vue-next";
|
import { AtSign, Globe, Lock, LockOpen } from "lucide-vue-next";
|
||||||
|
import { SettingIds } from "~/settings";
|
||||||
import Avatar from "../profiles/avatar.vue";
|
import Avatar from "../profiles/avatar.vue";
|
||||||
|
import SmallCard from "../profiles/small-card.vue";
|
||||||
|
import {
|
||||||
|
HoverCard,
|
||||||
|
HoverCardContent,
|
||||||
|
HoverCardTrigger,
|
||||||
|
} from "../ui/hover-card";
|
||||||
import CopyableText from "./copyable-text.vue";
|
import CopyableText from "./copyable-text.vue";
|
||||||
|
|
||||||
const { acct, createdAt, url, noteUrl } = defineProps<{
|
const { createdAt, noteUrl, author, authorUrl } = defineProps<{
|
||||||
avatar: string;
|
|
||||||
cornerAvatar?: string;
|
cornerAvatar?: string;
|
||||||
acct: string;
|
|
||||||
displayName: string;
|
|
||||||
emojis: Emoji[];
|
|
||||||
visibility: StatusVisibility;
|
visibility: StatusVisibility;
|
||||||
url: string;
|
|
||||||
noteUrl: string;
|
noteUrl: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
smallLayout?: boolean;
|
smallLayout?: boolean;
|
||||||
|
author: Account;
|
||||||
|
authorUrl: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const [username, instance] = acct.split("@");
|
const [username, instance] = author.acct.split("@");
|
||||||
const digitRegex = /\d/;
|
const digitRegex = /\d/;
|
||||||
const urlAsPath = new URL(url).pathname;
|
const urlAsPath = new URL(authorUrl).pathname;
|
||||||
const noteUrlAsPath = new URL(noteUrl).pathname;
|
const noteUrlAsPath = new URL(noteUrl).pathname;
|
||||||
const timeAgo = useTimeAgo(createdAt, {
|
const timeAgo = useTimeAgo(createdAt, {
|
||||||
messages: {
|
messages: {
|
||||||
|
|
@ -75,6 +93,8 @@ const fullTime = new Intl.DateTimeFormat("en-US", {
|
||||||
dateStyle: "medium",
|
dateStyle: "medium",
|
||||||
timeStyle: "short",
|
timeStyle: "short",
|
||||||
}).format(createdAt);
|
}).format(createdAt);
|
||||||
|
const enableHoverCard = useSetting(SettingIds.PopupAvatarHover);
|
||||||
|
const popupOpen = ref(false);
|
||||||
|
|
||||||
const visibilities = {
|
const visibilities = {
|
||||||
public: {
|
public: {
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,10 @@
|
||||||
<CardHeader class="pb-4" as="header">
|
<CardHeader class="pb-4" as="header">
|
||||||
<ReblogHeader v-if="note.reblog" :avatar="note.account.avatar" :display-name="note.account.display_name"
|
<ReblogHeader v-if="note.reblog" :avatar="note.account.avatar" :display-name="note.account.display_name"
|
||||||
:url="reblogAccountUrl" :emojis="note.account.emojis" />
|
:url="reblogAccountUrl" :emojis="note.account.emojis" />
|
||||||
<Header :avatar="noteToUse.account.avatar" :corner-avatar="note.reblog ? note.account.avatar : undefined"
|
<Header :author="noteToUse.account" :author-url="accountUrl"
|
||||||
:note-url="url" :acct="noteToUse.account.acct" :display-name="noteToUse.account.display_name"
|
:corner-avatar="note.reblog ? note.account.avatar : undefined" :note-url="url"
|
||||||
:visibility="noteToUse.visibility" :url="accountUrl" :created-at="new Date(noteToUse.created_at)"
|
:visibility="noteToUse.visibility" :created-at="new Date(noteToUse.created_at)"
|
||||||
:small-layout="smallLayout" :emojis="noteToUse.account.emojis" />
|
:small-layout="smallLayout" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Content :content="noteToUse.content" :quote="note.quote ?? undefined"
|
<Content :content="noteToUse.content" :quote="note.quote ?? undefined"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-y-4">
|
<div class="flex flex-col gap-y-4">
|
||||||
<div v-for="field in fields" :key="field.name" class="flex flex-col gap-1">
|
<div v-for="field in fields" :key="field.name" class="flex flex-col gap-1 break-words">
|
||||||
<h3 class="font-semibold text-sm" v-render-emojis="emojis">{{ field.name }}</h3>
|
<h3 class="font-semibold text-sm" v-render-emojis="emojis">{{ field.name }}</h3>
|
||||||
<div v-html="field.value" class="prose prose-sm prose-zinc dark:prose-invert" v-render-emojis="emojis"></div>
|
<div v-html="field.value" class="prose prose-sm prose-zinc dark:prose-invert" v-render-emojis="emojis"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
41
components/profiles/small-card.vue
Normal file
41
components/profiles/small-card.vue
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
<template>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="bg-muted rounded overflow-hidden h-32 w-full">
|
||||||
|
<img :src="account.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 left-1/2 translate-y-1/3 -translate-x-1/2 flex flex-row items-start gap-2">
|
||||||
|
<Avatar size="base" class="border" :src="account.avatar" :name="account.display_name" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col justify-center items-center mt-8">
|
||||||
|
<span class="font-semibold" v-render-emojis="account.emojis">
|
||||||
|
{{ account.display_name }}
|
||||||
|
</span>
|
||||||
|
<CopyableText :text="account.acct" class="text-sm">
|
||||||
|
<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>
|
||||||
|
<ProfileContent :content="account.note" :emojis="account.emojis" class="mt-4 max-h-72 overflow-y-auto" />
|
||||||
|
<Separator v-if="account.fields.length > 0" class="mt-4" />
|
||||||
|
<ProfileFields v-if="account.fields.length > 0" :fields="account.fields" :emojis="account.emojis" class="mt-4 max-h-48 overflow-y-auto" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { Account } from "@versia/client/types";
|
||||||
|
import CopyableText from "../notes/copyable-text.vue";
|
||||||
|
import Avatar from "./avatar.vue";
|
||||||
|
import ProfileContent from "./profile-content.vue";
|
||||||
|
import ProfileFields from "./profile-fields.vue";
|
||||||
|
|
||||||
|
const { account } = defineProps<{
|
||||||
|
account: Account;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const [username, instance] = account.acct.split("@");
|
||||||
|
</script>
|
||||||
19
components/ui/hover-card/HoverCard.vue
Normal file
19
components/ui/hover-card/HoverCard.vue
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
HoverCardRoot,
|
||||||
|
type HoverCardRootEmits,
|
||||||
|
type HoverCardRootProps,
|
||||||
|
useForwardPropsEmits,
|
||||||
|
} from "radix-vue";
|
||||||
|
|
||||||
|
const props = defineProps<HoverCardRootProps>();
|
||||||
|
const emits = defineEmits<HoverCardRootEmits>();
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(props, emits);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<HoverCardRoot v-bind="forwarded">
|
||||||
|
<slot />
|
||||||
|
</HoverCardRoot>
|
||||||
|
</template>
|
||||||
41
components/ui/hover-card/HoverCardContent.vue
Normal file
41
components/ui/hover-card/HoverCardContent.vue
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
HoverCardContent,
|
||||||
|
type HoverCardContentProps,
|
||||||
|
HoverCardPortal,
|
||||||
|
useForwardProps,
|
||||||
|
} from "radix-vue";
|
||||||
|
import { type HTMLAttributes, computed } from "vue";
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<HoverCardContentProps & { class?: HTMLAttributes["class"] }>(),
|
||||||
|
{
|
||||||
|
sideOffset: 4,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const delegatedProps = computed(() => {
|
||||||
|
const { class: _, ...delegated } = props;
|
||||||
|
|
||||||
|
return delegated;
|
||||||
|
});
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<HoverCardPortal>
|
||||||
|
<HoverCardContent
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</HoverCardContent>
|
||||||
|
</HoverCardPortal>
|
||||||
|
</template>
|
||||||
11
components/ui/hover-card/HoverCardTrigger.vue
Normal file
11
components/ui/hover-card/HoverCardTrigger.vue
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { HoverCardTrigger, type HoverCardTriggerProps } from "radix-vue";
|
||||||
|
|
||||||
|
const props = defineProps<HoverCardTriggerProps>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<HoverCardTrigger v-bind="props">
|
||||||
|
<slot />
|
||||||
|
</HoverCardTrigger>
|
||||||
|
</template>
|
||||||
3
components/ui/hover-card/index.ts
Normal file
3
components/ui/hover-card/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { default as HoverCard } from "./HoverCard.vue";
|
||||||
|
export { default as HoverCardContent } from "./HoverCardContent.vue";
|
||||||
|
export { default as HoverCardTrigger } from "./HoverCardTrigger.vue";
|
||||||
Loading…
Reference in a new issue