add tanstack/vue-virtual

This commit is contained in:
pa
2026-02-21 20:49:55 +09:00
parent dd631ac318
commit b9db931017
2 changed files with 100 additions and 37 deletions

View File

@@ -145,8 +145,8 @@
</div>
</Item>
</HoverCardTrigger>
<HoverCardContent side="left" :side-offset="8" class="w-80 p-3">
<!-- Group notifications -->
<HoverCardContent side="left" :side-offset="8" class="w-100 p-3">
<!-- Group -->
<template v-if="isGroupType">
<div class="flex items-center gap-2 mb-2">
<Avatar class="size-8 shrink-0 rounded">
@@ -182,15 +182,19 @@
<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>
<div v-if="notification.details?.worldId" class="text-xs mb-1">
<Location
:location="notification.details.worldId"
:hint="notification.details.worldName || ''"
:grouphint="notification.details.groupName || ''"
link />
</div>
<p v-if="friendMessage" class="text-xs text-muted-foreground warp-break-words leading-relaxed">
{{ friendMessage }}
</p>
</template>
<!-- Other notifications -->
<!-- Other -->
<template v-else>
<div class="flex items-center gap-2 mb-2">
<Avatar class="size-8 shrink-0">
@@ -212,7 +216,6 @@
</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 }}

View File

@@ -1,36 +1,34 @@
<template>
<div class="flex h-full flex-col overflow-hidden">
<div class="flex-1 overflow-y-auto">
<div v-if="sortedUnseenNotifications.length" class="flex flex-col gap-0.5 p-2">
<NotificationItem
v-for="n in sortedUnseenNotifications"
:key="n.id + n.created_at"
:notification="n"
:is-unseen="true"
@show-invite-response="$emit('show-invite-response', $event)"
@show-invite-request-response="$emit('show-invite-request-response', $event)" />
<div ref="scrollViewportRef" class="flex-1 overflow-y-auto">
<div v-if="allRows.length" class="relative w-full px-1.5 py-2" :style="virtualContainerStyle">
<template v-for="item in virtualItems" :key="item.virtualItem.key">
<div
class="absolute left-0 top-0 w-full px-0.5 box-border"
:data-index="item.virtualItem.index"
:ref="virtualizer.measureElement"
:style="{ transform: `translateY(${item.virtualItem.start}px)` }">
<!-- Section header -->
<div v-if="item.row.type === 'section-header'" class="flex items-center gap-2 px-2.5 py-2">
<Separator class="flex-1" />
<span class="shrink-0 text-[10px] text-muted-foreground uppercase tracking-wider">
{{ item.row.label }}
</span>
<Separator class="flex-1" />
</div>
<!-- Notification item -->
<NotificationItem
v-else-if="item.row.type === 'notification'"
:notification="item.row.notification"
:is-unseen="item.row.isUnseen"
@show-invite-response="$emit('show-invite-response', $event)"
@show-invite-request-response="$emit('show-invite-request-response', $event)" />
</div>
</template>
</div>
<div v-if="sortedRecentNotifications.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 sortedRecentNotifications"
:key="n.id + n.created_at"
:notification="n"
:is-unseen="false" />
</div>
</div>
<div
v-if="!sortedUnseenNotifications.length && !sortedRecentNotifications.length"
class="flex items-center justify-center p-8 text-sm text-muted-foreground">
<div v-if="!allRows.length" class="flex items-center justify-center p-8 text-sm text-muted-foreground">
{{ t('side_panel.notification_center.no_new_notifications') }}
</div>
@@ -48,10 +46,11 @@
</template>
<script setup>
import { computed, nextTick, ref, watch } from 'vue';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVirtualizer } from '@tanstack/vue-virtual';
import dayjs from 'dayjs';
@@ -65,6 +64,7 @@
defineEmits(['show-invite-response', 'show-invite-request-response', 'navigate-to-table']);
const { t } = useI18n();
const scrollViewportRef = ref(null);
function getTs(n) {
const raw = n?.created_at ?? n?.createdAt;
@@ -77,4 +77,64 @@
const sortedRecentNotifications = computed(() =>
[...props.recentNotifications].sort((a, b) => getTs(b) - getTs(a))
);
const allRows = computed(() => {
const rows = [];
for (const n of sortedUnseenNotifications.value) {
rows.push({
type: 'notification',
key: `unseen:${n.id}`,
notification: n,
isUnseen: true
});
}
if (sortedRecentNotifications.value.length) {
rows.push({
type: 'section-header',
key: 'header:recent',
label: t('side_panel.notification_center.past_notifications')
});
for (const n of sortedRecentNotifications.value) {
rows.push({
type: 'notification',
key: `recent:${n.id}`,
notification: n,
isUnseen: false
});
}
}
return rows;
});
const virtualizer = useVirtualizer(
computed(() => ({
count: allRows.value.length,
getScrollElement: () => scrollViewportRef.value,
estimateSize: (index) => {
const row = allRows.value[index];
return row?.type === 'section-header' ? 32 : 56;
},
getItemKey: (index) => allRows.value[index]?.key ?? index,
overscan: 8
}))
);
const virtualItems = computed(() => {
const items = virtualizer.value?.getVirtualItems?.() ?? [];
return items.map((virtualItem) => ({
virtualItem,
row: allRows.value[virtualItem.index]
}));
});
const virtualContainerStyle = computed(() => ({
height: `${virtualizer.value?.getTotalSize?.() ?? 0}px`,
width: '100%'
}));
watch(allRows, () => {
nextTick(() => {
virtualizer.value?.measure?.();
});
});
</script>