feat add notification center

This commit is contained in:
pa
2026-02-17 21:26:38 +09:00
parent 5d36163eef
commit ec6d224d71
8 changed files with 774 additions and 172 deletions

View File

@@ -0,0 +1,128 @@
<template>
<Sheet v-model:open="isNotificationCenterOpen">
<SheetContent side="right" class="flex flex-col p-0 sm:max-w-md px-1" @open-auto-focus.prevent>
<SheetHeader class="border-b px-4 pt-4 pb-3">
<div class="flex items-center pr-6">
<SheetTitle>{{ t('side_panel.notification_center.title') }}</SheetTitle>
</div>
</SheetHeader>
<Tabs v-model="activeTab" class="flex min-h-0 flex-1 flex-col">
<TabsList class="mr-4 ml-2 mt-2 grid w-auto grid-cols-3">
<TabsTrigger value="friend">
{{ t('side_panel.notification_center.tab_friend') }}
<span v-if="activeCount.friend" class="ml-1 text-xs text-muted-foreground">
({{ activeCount.friend }})
</span>
</TabsTrigger>
<TabsTrigger value="group">
{{ t('side_panel.notification_center.tab_group') }}
<span v-if="activeCount.group" class="ml-1 text-xs text-muted-foreground">
({{ activeCount.group }})
</span>
</TabsTrigger>
<TabsTrigger value="other">
{{ t('side_panel.notification_center.tab_other') }}
<span v-if="activeCount.other" class="ml-1 text-xs text-muted-foreground">
({{ activeCount.other }})
</span>
</TabsTrigger>
</TabsList>
<TabsContent value="friend" class="mt-0 min-h-0 flex-1 overflow-hidden">
<NotificationList
:notifications="friendNotifications"
:unseen-ids="unseenNotifications"
@show-invite-response="showSendInviteResponseDialog"
@show-invite-request-response="showSendInviteRequestResponseDialog"
@navigate-to-table="navigateToTable" />
</TabsContent>
<TabsContent value="group" class="mt-0 min-h-0 flex-1 overflow-hidden">
<NotificationList
:notifications="groupNotifications"
:unseen-ids="unseenNotifications"
@show-invite-response="showSendInviteResponseDialog"
@show-invite-request-response="showSendInviteRequestResponseDialog"
@navigate-to-table="navigateToTable" />
</TabsContent>
<TabsContent value="other" class="mt-0 min-h-0 flex-1 overflow-hidden">
<NotificationList
:notifications="otherNotifications"
:unseen-ids="unseenNotifications"
@show-invite-response="showSendInviteResponseDialog"
@show-invite-request-response="showSendInviteRequestResponseDialog"
@navigate-to-table="navigateToTable" />
</TabsContent>
</Tabs>
</SheetContent>
</Sheet>
<SendInviteResponseDialog
v-model:send-invite-response-dialog="sendInviteResponseDialog"
v-model:sendInviteResponseDialogVisible="sendInviteResponseDialogVisible" />
<SendInviteRequestResponseDialog
v-model:send-invite-response-dialog="sendInviteResponseDialog"
v-model:sendInviteRequestResponseDialogVisible="sendInviteRequestResponseDialogVisible" />
</template>
<script setup>
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { computed, ref } from 'vue';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useGalleryStore, useInviteStore, useNotificationStore } from '../../../stores';
import NotificationList from './NotificationList.vue';
import SendInviteRequestResponseDialog from '../../Notifications/dialogs/SendInviteRequestResponseDialog.vue';
import SendInviteResponseDialog from '../../Notifications/dialogs/SendInviteResponseDialog.vue';
const { t } = useI18n();
const router = useRouter();
const { refreshInviteMessageTableData } = useInviteStore();
const { clearInviteImageUpload } = useGalleryStore();
const {
isNotificationCenterOpen,
friendNotifications,
groupNotifications,
otherNotifications,
unseenNotifications
} = storeToRefs(useNotificationStore());
const activeTab = ref('friend');
const activeCount = computed(() => ({
friend: friendNotifications.value.filter((n) => !n.$isExpired).length,
group: groupNotifications.value.filter((n) => !n.$isExpired).length,
other: otherNotifications.value.filter((n) => !n.$isExpired).length
}));
// Dialog state
const sendInviteResponseDialog = ref({
messageSlot: {},
invite: {}
});
const sendInviteResponseDialogVisible = ref(false);
const sendInviteRequestResponseDialogVisible = ref(false);
function navigateToTable() {
isNotificationCenterOpen.value = false;
router.push({ name: 'notification' });
}
function showSendInviteResponseDialog(invite) {
sendInviteResponseDialog.value.invite = invite;
sendInviteResponseDialog.value.messageSlot = {};
refreshInviteMessageTableData('response');
clearInviteImageUpload();
sendInviteResponseDialogVisible.value = true;
}
function showSendInviteRequestResponseDialog(invite) {
sendInviteResponseDialog.value.invite = invite;
sendInviteResponseDialog.value.messageSlot = {};
refreshInviteMessageTableData('requestResponse');
clearInviteImageUpload();
sendInviteRequestResponseDialogVisible.value = true;
}
</script>

View File

@@ -0,0 +1,317 @@
<template>
<Item
size="sm"
:variant="notification.$isExpired ? 'default' : 'muted'"
:class="[{ 'opacity-50': notification.$isExpired }, 'mb-1.5']">
<ItemMedia variant="image" class="cursor-pointer" @click.stop="openSender">
<Avatar class="size-full">
<AvatarImage v-if="avatarUrl" :src="avatarUrl" />
<AvatarFallback class="text-muted-foreground">
<component :is="typeIcon" class="size-4" />
</AvatarFallback>
</Avatar>
</ItemMedia>
<ItemContent class="min-w-0">
<ItemTitle class="min-w-0">
<span class="truncate cursor-pointer" @click.stop="openSender">{{ senderName }}</span>
<Badge variant="secondary" class="shrink-0 text-muted-foreground text-[10px]">
{{ typeLabel }}
</Badge>
<span
v-if="!notification.$isExpired && isUnseen"
class="ml-auto size-2 shrink-0 rounded-full bg-blue-500" />
</ItemTitle>
<TooltipWrapper v-if="displayMessage" side="top" :content="displayMessage" :delay-duration="600">
<ItemDescription class="text-xs select-none line-clamp-3">
{{ displayMessage }}
</ItemDescription>
</TooltipWrapper>
</ItemContent>
<div class="flex shrink-0 flex-col items-end gap-1">
<span class="text-[10px] text-muted-foreground whitespace-nowrap">
{{ relativeTime }}
</span>
<div class="flex items-center gap-1">
<template v-if="!notification.$isExpired">
<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
v-if="notification.type === 'invite'"
side="top"
:content="t('view.notification.actions.decline_with_message')">
<button
type="button"
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)">
<MessageCircle class="size-3" />
</button>
</TooltipWrapper>
<template v-if="notification.type === 'requestInvite'">
<TooltipWrapper v-if="canInvite" side="top" :content="t('view.notification.actions.invite')">
<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.acceptRequestInvite(notification)">
<Check class="size-3" />
</button>
</TooltipWrapper>
<TooltipWrapper side="top" :content="t('view.notification.actions.decline_with_message')">
<button
type="button"
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>
<TooltipWrapper v-if="showDeleteLog" side="top" :content="t('view.notification.actions.delete_log')">
<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.deleteNotificationLogPrompt(notification)">
<Trash2 class="size-3" />
</button>
</TooltipWrapper>
</div>
</div>
</Item>
</template>
<script setup>
import {
Ban,
Bell,
BellOff,
Check,
Link,
Mail,
MessageCircle,
Reply,
Send,
Tag,
Trash2,
UserPlus,
Users,
X
} from 'lucide-vue-next';
import { Item, ItemContent, ItemDescription, ItemMedia, ItemTitle } from '@/components/ui/item';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { TooltipWrapper } from '@/components/ui/tooltip';
import { computed } from 'vue';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';
import dayjs from 'dayjs';
import { useGameStore, useGroupStore, useLocationStore, useNotificationStore, useUserStore } from '../../../stores';
import { checkCanInvite, userImage } from '../../../shared/utils';
const props = defineProps({
notification: { type: Object, required: true },
isUnseen: { type: Boolean, default: false }
});
defineEmits(['show-invite-response', 'show-invite-request-response']);
const { t, te } = useI18n();
const userStore = useUserStore();
const groupStore = useGroupStore();
const notificationStore = useNotificationStore();
const { lastLocation } = storeToRefs(useLocationStore());
const { isGameRunning } = storeToRefs(useGameStore());
const senderName = computed(() => {
const n = props.notification;
return n.senderUsername || n.data?.groupName || n.groupName || n.details?.groupName || '';
});
const avatarUrl = computed(() => {
const n = props.notification;
const userId = n.senderUserId;
// Group notifications: use details.imageUrl or imageUrl
if (userId?.startsWith('grp_') || n.type?.startsWith('group.') || n.type === 'groupChange') {
return n.details?.imageUrl || n.imageUrl || n.senderUserIcon || null;
}
// User notifications: try cached user first, then fallback
if (userId) {
const user = userStore.cachedUsers.get(userId);
if (user) {
return userImage(user);
}
}
return n.senderUserIcon || null;
});
const typeLabel = computed(() => {
const typeKey = `view.notification.filters.${props.notification.type}`;
return te(typeKey) ? t(typeKey) : props.notification.type;
});
const TYPE_ICON_MAP = {
friendRequest: UserPlus,
ignoredFriendRequest: UserPlus,
invite: Send,
requestInvite: Send,
inviteResponse: Send,
requestInviteResponse: Send,
boop: MessageCircle,
message: Mail
};
const typeIcon = computed(() => {
const type = props.notification.type;
if (TYPE_ICON_MAP[type]) return TYPE_ICON_MAP[type];
if (type?.startsWith('group.') || type === 'groupChange') return Users;
return Bell;
});
const displayMessage = computed(() => {
const n = props.notification;
if (n.message) return n.message;
if (n.details?.inviteMessage) return n.details.inviteMessage;
if (n.details?.requestMessage) return n.details.requestMessage;
if (n.details?.responseMessage) return n.details.responseMessage;
if (n.details?.worldName) return n.details.worldName;
return '';
});
const relativeTime = computed(() => {
const createdAt = props.notification.created_at || props.notification.createdAt;
if (!createdAt) return '';
return dayjs(createdAt).fromNow(true);
});
const showDecline = computed(() => {
const type = props.notification.type;
return (
type !== 'requestInviteResponse' &&
type !== 'inviteResponse' &&
type !== 'message' &&
type !== 'boop' &&
type !== 'groupChange' &&
!type?.includes('group.') &&
!type?.includes('moderation.') &&
!type?.includes('instance.')
);
});
const hasResponses = computed(() => Array.isArray(props.notification.responses));
const showDeleteLog = computed(() => {
const n = props.notification;
const type = n.type;
if (type === 'friendRequest' || type === 'ignoredFriendRequest') return false;
if (type?.includes('group.') || type?.includes('moderation.') || type?.includes('instance.')) return false;
if (n.link?.startsWith('economy.')) return false;
// For active notifications, group.queueReady is handled separately
if (!n.$isExpired && type === 'group.queueReady') return false;
return true;
});
const canInvite = computed(() => {
const location = lastLocation.value?.location;
return Boolean(location) && isGameRunning.value && checkCanInvite(location);
});
function getResponseIcon(response) {
if (response?.type === 'link') return Link;
switch (response?.icon) {
case 'check':
return Check;
case 'cancel':
return X;
case 'ban':
return Ban;
case 'bell-slash':
return BellOff;
case 'reply':
return props.notification.type === 'boop' ? MessageCircle : Reply;
default:
return Tag;
}
}
function handleResponse(response) {
if (response.type === 'link') {
openNotificationLink(response.data);
return;
}
if (response.icon === 'reply' && props.notification.type === 'boop') {
userStore.showSendBoopDialog(props.notification.senderUserId);
return;
}
notificationStore.sendNotificationResponse(props.notification.id, props.notification.responses, response.type);
}
function openNotificationLink(link) {
if (!link) return;
const data = link.split(':');
if (!data.length) return;
switch (data[0]) {
case 'group':
groupStore.showGroupDialog(data[1]);
break;
case 'user':
userStore.showUserDialog(data[1]);
break;
case 'event': {
const ids = data[1].split(',');
if (ids.length >= 2) {
groupStore.showGroupDialog(ids[0]);
}
break;
}
}
}
function openSender() {
const userId = props.notification.senderUserId;
if (!userId) return;
if (userId.startsWith('grp_')) {
groupStore.showGroupDialog(userId);
} else {
userStore.showUserDialog(userId);
}
}
</script>

View File

@@ -0,0 +1,83 @@
<template>
<div class="flex h-full flex-col overflow-hidden">
<div class="flex-1 overflow-y-auto">
<div v-if="activeNotifications.length" class="flex flex-col gap-0.5 p-2">
<NotificationItem
v-for="n in activeNotifications"
:key="n.id || n.type + n.created_at"
:notification="n"
:is-unseen="unseenIds.includes(n.id)"
@show-invite-response="$emit('show-invite-response', $event)"
@show-invite-request-response="$emit('show-invite-request-response', $event)" />
</div>
<div v-else class="flex items-center justify-center p-8 text-sm text-muted-foreground">
{{ t('side_panel.notification_center.no_notifications') }}
</div>
<template v-if="expiredNotifications.length">
<div class="flex items-center gap-2 px-4 py-2">
<Separator class="flex-1" />
<span class="shrink-0 text-[10px] text-muted-foreground uppercase tracking-wider">
{{ t('side_panel.notification_center.past_notifications') }}
</span>
<Separator class="flex-1" />
</div>
<div class="flex flex-col gap-0.5 px-2 pb-2">
<NotificationItem
v-for="n in expiredNotifications"
:key="n.id || n.type + n.created_at"
:notification="n"
:is-unseen="false" />
</div>
</template>
<div class="flex justify-center py-3">
<Button
variant="ghost"
size="sm"
class="text-xs text-muted-foreground"
@click="$emit('navigate-to-table')">
{{ t('side_panel.notification_center.view_more') }}
</Button>
</div>
</div>
</div>
</template>
<script setup>
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import dayjs from 'dayjs';
import NotificationItem from './NotificationItem.vue';
const props = defineProps({
notifications: { type: Array, required: true },
unseenIds: { type: Array, default: () => [] }
});
defineEmits(['show-invite-response', 'show-invite-request-response', 'navigate-to-table']);
const { t } = useI18n();
function getTs(n) {
const raw = n?.created_at ?? n?.createdAt;
if (typeof raw === 'number') {
return raw > 1_000_000_000_000 ? raw : raw * 1000;
}
const ts = dayjs(raw).valueOf();
return Number.isFinite(ts) ? ts : 0;
}
const sortedNotifications = computed(() => [...props.notifications].sort((a, b) => getTs(b) - getTs(a)));
const activeNotifications = computed(() => sortedNotifications.value.filter((n) => !n.$isExpired));
const MAX_EXPIRED = 20;
const expiredNotifications = computed(() =>
sortedNotifications.value.filter((n) => n.$isExpired).slice(0, MAX_EXPIRED)
);
</script>