add hover card

This commit is contained in:
pa
2026-02-21 20:41:43 +09:00
parent f5486262d4
commit dd631ac318
+247 -118
View File
@@ -1,136 +1,224 @@
<template> <template>
<Item size="sm" variant="muted" class="mb-1.5"> <HoverCard :open-delay="400" :close-delay="100">
<ItemMedia variant="image" class="cursor-pointer" @click.stop="openSender"> <HoverCardTrigger as-child>
<Avatar class="size-full"> <Item size="sm" variant="muted" class="mb-1.5">
<AvatarImage v-if="avatarUrl" :src="avatarUrl" /> <ItemMedia variant="image" class="cursor-pointer" @click.stop="openSender">
<AvatarFallback class="text-muted-foreground"> <Avatar class="size-full">
<component :is="typeIcon" class="size-4" /> <AvatarImage v-if="avatarUrl" :src="avatarUrl" />
</AvatarFallback> <AvatarFallback class="text-muted-foreground">
</Avatar> <component :is="typeIcon" class="size-4" />
</ItemMedia> </AvatarFallback>
<ItemContent class="min-w-0"> </Avatar>
<ItemTitle class="min-w-0 w-full"> </ItemMedia>
<span class="truncate cursor-pointer" @click.stop="openSender">{{ senderName }}</span> <ItemContent class="min-w-0">
<Badge variant="secondary" class="shrink-0 text-muted-foreground text-[10px]"> <ItemTitle class="min-w-0 w-full">
{{ typeLabel }} <span class="truncate cursor-pointer" @click.stop="openSender">{{ senderName }}</span>
</Badge> <Badge variant="secondary" class="shrink-0 text-muted-foreground text-[10px]">
<span {{ typeLabel }}
v-if="!isNotificationExpired(notification) && !isSeen" </Badge>
class="ml-auto size-2 shrink-0 rounded-full bg-blue-500" /> <span
</ItemTitle> v-if="!isNotificationExpired(notification) && !isSeen"
<ItemDescription v-if="notification.type === 'invite' && notification.details?.worldId" class="text-xs"> class="ml-auto size-2 shrink-0 rounded-full bg-blue-500" />
<Location </ItemTitle>
:location="notification.details.worldId" <ItemDescription
:hint="notification.details.worldName || ''" v-if="notification.type === 'invite' && notification.details?.worldId"
:grouphint="notification.details.groupName || ''" class="text-xs">
link /> <Location
</ItemDescription> :location="notification.details.worldId"
<ItemDescription :hint="notification.details.worldName || ''"
v-else-if=" :grouphint="notification.details.groupName || ''"
(notification.type === 'group.queueReady' || notification.type === 'instance.closed') && link />
notification.location </ItemDescription>
" <ItemDescription
class="text-xs"> v-else-if="
<Location (notification.type === 'group.queueReady' || notification.type === 'instance.closed') &&
:location="notification.location" notification.location
:hint="notification.worldName || ''" "
:grouphint="notification.groupName || ''" class="text-xs">
link /> <Location
</ItemDescription> :location="notification.location"
<TooltipWrapper v-if="displayMessage" side="top" :content="displayMessage" :delay-duration="600"> :hint="notification.worldName || ''"
<ItemDescription class="text-xs select-none"> :grouphint="notification.groupName || ''"
{{ displayMessage }} link />
</ItemDescription> </ItemDescription>
</TooltipWrapper> <ItemDescription v-if="displayMessage" class="text-xs select-none truncate">
</ItemContent> {{ displayMessage }}
</ItemDescription>
</ItemContent>
<div class="flex h-full shrink-0 flex-col items-end justify-between gap-1"> <div class="flex h-full shrink-0 flex-col items-end justify-between gap-1">
<TooltipWrapper v-if="relativeTime" side="top" :content="absoluteTime"> <TooltipWrapper v-if="relativeTime" side="top" :content="absoluteTime">
<span class="text-[10px] text-muted-foreground whitespace-nowrap"> <span class="text-[10px] text-muted-foreground whitespace-nowrap">
{{ relativeTime }} {{ relativeTime }}
</span> </span>
</TooltipWrapper>
<div class="flex items-center gap-1">
<template v-if="!isNotificationExpired(notification)">
<TooltipWrapper
v-if="notification.type === 'friendRequest'"
side="top"
:content="t('view.notification.actions.accept')">
<button
type="button"
class="inline-flex size-5 items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-muted"
@click.stop="notificationStore.acceptFriendRequestNotification(notification)">
<Check class="size-3" />
</button>
</TooltipWrapper> </TooltipWrapper>
<div class="flex items-center gap-1">
<template v-if="!isNotificationExpired(notification)">
<TooltipWrapper
v-if="notification.type === 'friendRequest'"
side="top"
:content="t('view.notification.actions.accept')">
<button
type="button"
class="inline-flex size-5 items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-muted"
@click.stop="notificationStore.acceptFriendRequestNotification(notification)">
<Check class="size-3" />
</button>
</TooltipWrapper>
<TooltipWrapper <TooltipWrapper
v-if="notification.type === 'invite'" v-if="notification.type === 'invite'"
side="top" side="top"
:content="t('view.notification.actions.decline_with_message')"> :content="t('view.notification.actions.decline_with_message')">
<button <button
type="button" type="button"
class="inline-flex size-5 items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-muted" class="inline-flex size-5 items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-muted"
@click.stop="$emit('show-invite-response', notification)"> @click.stop="$emit('show-invite-response', notification)">
<MessageCircle class="size-3" /> <MessageCircle class="size-3" />
</button> </button>
</TooltipWrapper> </TooltipWrapper>
<template v-if="notification.type === 'requestInvite'"> <template v-if="notification.type === 'requestInvite'">
<TooltipWrapper v-if="canInvite" side="top" :content="t('view.notification.actions.invite')"> <TooltipWrapper
<button v-if="canInvite"
type="button" side="top"
class="inline-flex size-5 items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-muted" :content="t('view.notification.actions.invite')">
@click.stop="notificationStore.acceptRequestInvite(notification)"> <button
<Check class="size-3" /> type="button"
</button> class="inline-flex size-5 items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-muted"
</TooltipWrapper> @click.stop="notificationStore.acceptRequestInvite(notification)">
<TooltipWrapper side="top" :content="t('view.notification.actions.decline_with_message')"> <Check class="size-3" />
<button </button>
type="button" </TooltipWrapper>
class="inline-flex size-5 items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-muted" <TooltipWrapper
@click.stop="$emit('show-invite-request-response', notification)"> side="top"
<MessageCircle class="size-3" /> :content="t('view.notification.actions.decline_with_message')">
</button> <button
</TooltipWrapper> type="button"
</template> class="inline-flex size-5 items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-muted"
@click.stop="$emit('show-invite-request-response', notification)">
<MessageCircle class="size-3" />
</button>
</TooltipWrapper>
</template>
<template v-if="hasResponses">
<TooltipWrapper
v-for="response in notification.responses"
:key="`${response.text}:${response.type}`"
side="top"
:content="response.text">
<button
type="button"
class="inline-flex size-5 items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-muted"
@click.stop="handleResponse(response)">
<component :is="getResponseIcon(response)" class="size-3" />
</button>
</TooltipWrapper>
</template>
<TooltipWrapper
v-if="showDecline"
side="top"
:content="t('view.notification.actions.decline')">
<button
type="button"
class="inline-flex size-5 items-center justify-center rounded text-muted-foreground hover:text-destructive hover:bg-muted"
@click.stop="notificationStore.hideNotificationPrompt(notification)">
<X class="size-3" />
</button>
</TooltipWrapper>
</template>
<template v-if="hasResponses">
<TooltipWrapper <TooltipWrapper
v-for="response in notification.responses" v-if="showDeleteLog"
:key="`${response.text}:${response.type}`"
side="top" side="top"
:content="response.text"> :content="t('view.notification.actions.delete_log')">
<button <button
type="button" type="button"
class="inline-flex size-5 items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-muted" class="inline-flex size-5 items-center justify-center rounded text-muted-foreground hover:text-destructive hover:bg-muted"
@click.stop="handleResponse(response)"> @click.stop="notificationStore.deleteNotificationLogPrompt(notification)">
<component :is="getResponseIcon(response)" class="size-3" /> <Trash2 class="size-3" />
</button> </button>
</TooltipWrapper> </TooltipWrapper>
</template> </div>
</div>
</Item>
</HoverCardTrigger>
<HoverCardContent side="left" :side-offset="8" class="w-80 p-3">
<!-- Group notifications -->
<template v-if="isGroupType">
<div class="flex items-center gap-2 mb-2">
<Avatar class="size-8 shrink-0 rounded">
<AvatarImage v-if="hoverImageUrl" :src="hoverImageUrl" />
<AvatarFallback class="text-muted-foreground rounded">
<Users class="size-4" />
</AvatarFallback>
</Avatar>
<div class="min-w-0">
<p class="text-sm font-medium truncate">{{ groupDisplayName }}</p>
<p class="text-xs text-muted-foreground">{{ typeLabel }}</p>
</div>
</div>
<p v-if="hoverTitle" class="text-sm font-medium mb-1">{{ hoverTitle }}</p>
<p
v-if="notification.message"
class="text-xs text-muted-foreground whitespace-pre-line warp-break-words leading-relaxed">
{{ notification.message }}
</p>
</template>
<TooltipWrapper v-if="showDecline" side="top" :content="t('view.notification.actions.decline')"> <!-- Friend -->
<button <template v-else-if="isFriendType">
type="button" <div class="flex items-center gap-2 mb-2">
class="inline-flex size-5 items-center justify-center rounded text-muted-foreground hover:text-destructive hover:bg-muted" <Avatar class="size-8 shrink-0">
@click.stop="notificationStore.hideNotificationPrompt(notification)"> <AvatarImage v-if="avatarUrl" :src="avatarUrl" />
<X class="size-3" /> <AvatarFallback class="text-muted-foreground">
</button> <component :is="typeIcon" class="size-4" />
</TooltipWrapper> </AvatarFallback>
</template> </Avatar>
<div class="min-w-0">
<p class="text-sm font-medium truncate">{{ senderName }}</p>
<p class="text-xs text-muted-foreground">{{ typeLabel }}</p>
</div>
</div>
<p v-if="notification.details?.worldName" class="text-xs mb-1">
<span class="text-muted-foreground">World: </span>{{ notification.details.worldName }}
</p>
<p v-if="friendMessage" class="text-xs text-muted-foreground warp-break-words leading-relaxed">
{{ friendMessage }}
</p>
</template>
<TooltipWrapper v-if="showDeleteLog" side="top" :content="t('view.notification.actions.delete_log')"> <!-- Other notifications -->
<button <template v-else>
type="button" <div class="flex items-center gap-2 mb-2">
class="inline-flex size-5 items-center justify-center rounded text-muted-foreground hover:text-destructive hover:bg-muted" <Avatar class="size-8 shrink-0">
@click.stop="notificationStore.deleteNotificationLogPrompt(notification)"> <AvatarImage v-if="avatarUrl" :src="avatarUrl" />
<Trash2 class="size-3" /> <AvatarFallback class="text-muted-foreground">
</button> <component :is="typeIcon" class="size-4" />
</TooltipWrapper> </AvatarFallback>
</Avatar>
<div class="min-w-0">
<p class="text-sm font-medium truncate">{{ senderName || notification.type }}</p>
<p class="text-xs text-muted-foreground">{{ typeLabel }}</p>
</div>
</div>
<p v-if="notification.title" class="text-sm font-medium mb-1">{{ notification.title }}</p>
<p
v-if="displayMessage"
class="text-xs text-muted-foreground whitespace-pre-line wrap-break-words leading-relaxed">
{{ displayMessage }}
</p>
</template>
<!-- Time (always shown) -->
<Separator v-if="absoluteTime" class="my-2" />
<div v-if="absoluteTime" class="flex items-center gap-2 text-[10px] text-muted-foreground">
<CalendarDays />{{ absoluteTime }}
</div> </div>
</div> </HoverCardContent>
</Item> </HoverCard>
</template> </template>
<script setup> <script setup>
@@ -138,6 +226,7 @@
Ban, Ban,
Bell, Bell,
BellOff, BellOff,
CalendarDays,
Check, Check,
Link, Link,
Mail, Mail,
@@ -152,8 +241,10 @@
} from 'lucide-vue-next'; } from 'lucide-vue-next';
import { Item, ItemContent, ItemDescription, ItemMedia, ItemTitle } from '@/components/ui/item'; import { Item, ItemContent, ItemDescription, ItemMedia, ItemTitle } from '@/components/ui/item';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';
import { computed, onMounted } from 'vue'; import { computed, onMounted } from 'vue';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { TooltipWrapper } from '@/components/ui/tooltip'; import { TooltipWrapper } from '@/components/ui/tooltip';
import { notificationRequest } from '@/api'; import { notificationRequest } from '@/api';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
@@ -278,6 +369,44 @@
return true; return true;
}); });
const isGroupType = computed(() => {
const type = props.notification.type;
return type?.startsWith('group.') || type === 'groupChange';
});
const isFriendType = computed(() => {
const type = props.notification.type;
return [
'invite',
'requestInvite',
'inviteResponse',
'requestInviteResponse',
'friendRequest',
'ignoredFriendRequest',
'boop'
].includes(type);
});
const groupDisplayName = computed(() => {
const n = props.notification;
return n.data?.groupName || n.groupName || n.details?.groupName || n.senderUsername || '';
});
const hoverTitle = computed(() => {
const n = props.notification;
return n.data?.announcementTitle || n.title || '';
});
const hoverImageUrl = computed(() => {
const n = props.notification;
return n.imageUrl || n.details?.imageUrl || null;
});
const friendMessage = computed(() => {
const n = props.notification;
return n.message || n.details?.inviteMessage || n.details?.requestMessage || n.details?.responseMessage || '';
});
const isSeen = computed(() => { const isSeen = computed(() => {
const n = props.notification; const n = props.notification;
if (typeof n.seen === 'boolean') { if (typeof n.seen === 'boolean') {