fix friends locations

This commit is contained in:
pa
2026-01-16 12:39:07 +09:00
committed by Natsumi
parent 1c1dd2ebc3
commit 8fd24d2914
+265 -199
View File
@@ -58,99 +58,45 @@
<div v-else class="friend-view__toolbar friend-view__toolbar--loading"> <div v-else class="friend-view__toolbar friend-view__toolbar--loading">
<span class="friend-view__loading-text">{{ t('view.friends_locations.loading_more') }}</span> <span class="friend-view__loading-text">{{ t('view.friends_locations.loading_more') }}</span>
</div> </div>
<ScrollArea v-if="settingsReady" ref="scrollbarRef" class="friend-view__scroll" @scroll="handleScroll"> <ScrollArea v-if="settingsReady" ref="scrollbarRef" class="friend-view__scroll">
<template v-if="isSameInstanceView"> <div v-if="virtualRows.length" class="friend-view__virtual" :style="virtualListStyle">
<div v-if="visibleSameInstanceGroups.length" class="friend-view__instances"> <div class="friend-view__virtual-spacer" :style="virtualSpacerStyle">
<section <div
v-for="group in visibleSameInstanceGroups" v-for="vRow in virtualItems"
:key="group.instanceId" :key="String(virtualRows[vRow.index]?.key ?? vRow.key)"
class="friend-view__instance"> class="friend-view__virtual-row"
<header class="friend-view__instance-header"> :data-index="vRow.index"
<Location class="extra" :location="group.instanceId" style="display: inline" /> :ref="(el) => onVirtualRowRef(el)"
<span class="friend-view__instance-count">{{ group.friends.length }}</span> :style="virtualRowStyle(vRow.start)">
</header> <template v-if="virtualRows[vRow.index]?.type === 'header'">
<div <header class="friend-view__instance-header">
class="friend-view__grid" <Location
:style=" class="extra"
gridStyle(group.friends.length, { :location="virtualRows[vRow.index].instanceId"
preferredColumns: sameInstanceColumnTarget, style="display: inline" />
disableAutoStretch: true, <span class="friend-view__instance-count">{{ virtualRows[vRow.index].count }}</span>
matchMaxColumnWidth: true </header>
}) </template>
">
<FriendLocationCard <template v-else-if="virtualRows[vRow.index]?.type === 'divider'">
v-for="friend in group.friends" <div class="friend-view__divider"><span class="friend-view__divider-text"></span></div>
:key="friend.id ?? friend.userId ?? friend.displayName" </template>
:friend="friend"
:card-scale="cardScale" <template v-else>
:card-spacing="cardSpacing" /> <div class="friend-view__row">
</div> <FriendLocationCard
</section> v-for="item in virtualRows[vRow.index]?.items ?? []"
:key="item.key"
:friend="item.friend"
:card-scale="cardScale"
:card-spacing="cardSpacing"
:display-instance-info="item.displayInstanceInfo" />
</div>
</template>
</div>
</div> </div>
<div v-else class="friend-view__empty">{{ t('view.friends_locations.no_matching_friends') }}</div>
</template>
<template v-else-if="shouldMergeSameInstance">
<div v-if="mergedSameInstanceGroups.length" class="friend-view__instances">
<section
v-for="group in mergedSameInstanceGroups"
:key="group.instanceId"
class="friend-view__instance">
<header class="friend-view__instance-header">
<Location class="extra" :location="group.instanceId" style="display: inline" />
<span class="friend-view__instance-count">{{ group.friends.length }}</span>
</header>
<div
class="friend-view__grid"
:style="
gridStyle(group.friends.length, {
preferredColumns: sameInstanceColumnTarget,
disableAutoStretch: true,
matchMaxColumnWidth: true
})
">
<FriendLocationCard
v-for="friend in group.friends"
:key="friend.id ?? friend.userId ?? friend.displayName"
:friend="friend"
:card-scale="cardScale"
:card-spacing="cardSpacing"
:display-instance-info="false" />
</div>
</section>
</div>
<div v-if="mergedSameInstanceGroups.length && mergedOnlineEntries.length" class="friend-view__divider">
<span class="friend-view__divider-text"></span>
</div>
<div
v-if="mergedOnlineEntries.length"
class="friend-view__grid"
:style="gridStyle(mergedOnlineEntries.length)">
<FriendLocationCard
v-for="entry in mergedOnlineEntries"
:key="entry.id ?? entry.friend.id ?? entry.friend.displayName"
:friend="entry.friend"
:card-scale="cardScale"
:card-spacing="cardSpacing" />
</div>
<div v-if="!mergedSameInstanceGroups.length && !mergedOnlineEntries.length" class="friend-view__empty">
{{ t('view.friends_locations.no_matching_friends') }}
</div>
</template>
<template v-else>
<div v-if="visibleFriends.length" class="friend-view__grid" :style="gridStyle(visibleFriends.length)">
<FriendLocationCard
v-for="entry in visibleFriends"
:key="entry.id ?? entry.friend.id ?? entry.friend.displayName"
:friend="entry.friend"
:card-scale="cardScale"
:card-spacing="cardSpacing" />
</div>
<div v-else class="friend-view__empty">{{ t('view.friends_locations.no_matching_friends') }}</div>
</template>
<div v-if="isLoadingMore" class="friend-view__loading">
<Loader2 class="friend-view__loading-icon" :size="18" />
<span>{{ t('view.friends_locations.loading_more') }}</span>
</div> </div>
<div v-else class="friend-view__empty">{{ t('view.friends_locations.no_matching_friends') }}</div>
</ScrollArea> </ScrollArea>
<div v-else class="friend-view__initial-loading"> <div v-else class="friend-view__initial-loading">
<Loader2 class="friend-view__loading-icon" :size="22" /> <Loader2 class="friend-view__loading-icon" :size="22" />
@@ -159,7 +105,7 @@
</template> </template>
<script setup> <script setup>
import { computed, nextTick, onBeforeMount, onBeforeUnmount, onMounted, ref, watch } from 'vue'; import { computed, nextTick, onBeforeMount, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Loader2, Settings } from 'lucide-vue-next'; import { Loader2, Settings } from 'lucide-vue-next';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -167,6 +113,7 @@
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useVirtualizer } from '@tanstack/vue-virtual';
import { Popover, PopoverContent, PopoverTrigger } from '../../components/ui/popover'; import { Popover, PopoverContent, PopoverTrigger } from '../../components/ui/popover';
import { Slider } from '../../components/ui/slider'; import { Slider } from '../../components/ui/slider';
@@ -249,21 +196,17 @@
const settingsReady = ref(false); const settingsReady = ref(false);
const PAGE_SIZE = 18;
const VIEWPORT_BUFFER = 32;
const activeSegment = ref('online'); const activeSegment = ref('online');
const searchTerm = ref(''); const searchTerm = ref('');
const itemsToShow = ref(PAGE_SIZE);
const isLoadingMore = ref(false);
const scrollbarRef = ref(); const scrollbarRef = ref();
const scrollViewportRef = shallowRef(null);
const gridWidth = ref(0); const gridWidth = ref(0);
let resizeObserver; let resizeObserver;
let cleanupResize; let cleanupResize;
const updateGridWidth = () => { const updateGridWidth = () => {
const wrap = scrollbarRef.value?.viewportEl?.value; const wrap = scrollViewportRef.value;
if (!wrap) { if (!wrap) {
return; return;
} }
@@ -277,7 +220,7 @@
cleanupResize = undefined; cleanupResize = undefined;
} }
const wrap = scrollbarRef.value?.viewportEl?.value; const wrap = scrollViewportRef.value;
if (!wrap) { if (!wrap) {
return; return;
} }
@@ -418,8 +361,6 @@
} }
}); });
const visibleFriends = computed(() => filteredFriends.value.slice(0, itemsToShow.value));
const isSameInstanceView = computed( const isSameInstanceView = computed(
() => showSameInstance.value && activeSegment.value === 'same-instance' && !normalizedSearchTerm.value () => showSameInstance.value && activeSegment.value === 'same-instance' && !normalizedSearchTerm.value
); );
@@ -454,56 +395,34 @@
})); }));
}; };
const visibleSameInstanceGroups = computed(() => { const sameInstanceGroupsForVirtual = computed(() => {
if (!isSameInstanceView.value) { if (!isSameInstanceView.value) {
return []; return [];
} }
return buildSameInstanceGroups(filteredFriends.value);
return buildSameInstanceGroups(visibleFriends.value);
}); });
const mergedSameInstanceEntries = computed(() => { const mergedSameInstanceEntries = computed(() => {
if (!shouldMergeSameInstance.value) { if (!shouldMergeSameInstance.value) {
return []; return [];
} }
return filteredFriends.value.filter((entry) => entry.section === 'same-instance');
return visibleFriends.value.filter((entry) => entry.section === 'same-instance');
}); });
const mergedOnlineEntries = computed(() => { const mergedOnlineEntries = computed(() => {
if (!shouldMergeSameInstance.value) { if (!shouldMergeSameInstance.value) {
return []; return [];
} }
return filteredFriends.value.filter((entry) => entry.section !== 'same-instance');
return visibleFriends.value.filter((entry) => entry.section !== 'same-instance');
}); });
const mergedSameInstanceGroups = computed(() => { const mergedSameInstanceGroups = computed(() => {
if (!shouldMergeSameInstance.value) { if (!shouldMergeSameInstance.value) {
return []; return [];
} }
return buildSameInstanceGroups(mergedSameInstanceEntries.value); return buildSameInstanceGroups(mergedSameInstanceEntries.value);
}); });
const sameInstanceColumnTarget = computed(() => {
const groups = isSameInstanceView.value
? visibleSameInstanceGroups.value
: shouldMergeSameInstance.value
? mergedSameInstanceGroups.value
: [];
let maxCount = 0;
for (const group of groups) {
const size = Array.isArray(group?.friends) ? group.friends.length : 0;
if (size > maxCount) {
maxCount = size;
}
}
return maxCount > 0 ? maxCount : null;
});
const gridStyle = computed(() => { const gridStyle = computed(() => {
const baseWidth = 220; const baseWidth = 220;
const baseGap = 14; const baseGap = 14;
@@ -551,92 +470,191 @@
}; };
}); });
const handleScroll = () => { function resolveScrollViewport() {
if ( const rootEl = scrollbarRef.value?.$el ?? null;
isLoadingMore.value || if (!rootEl) {
filteredFriends.value.length === 0 || scrollViewportRef.value = null;
itemsToShow.value >= filteredFriends.value.length
) {
return; return;
} }
scrollViewportRef.value = rootEl.querySelector('[data-slot="scroll-area-viewport"]');
const wrap = scrollbarRef.value?.viewportEl?.value;
if (!wrap) {
return;
}
const { scrollHeight, scrollTop, clientHeight } = wrap;
if (scrollTop + clientHeight >= scrollHeight - 120) {
loadMoreFriends();
}
};
function loadMoreFriends() {
if (isLoadingMore.value || itemsToShow.value >= filteredFriends.value.length) {
return;
}
isLoadingMore.value = true;
window.setTimeout(() => {
if (itemsToShow.value < filteredFriends.value.length) {
itemsToShow.value = Math.min(itemsToShow.value + PAGE_SIZE, filteredFriends.value.length);
}
isLoadingMore.value = false;
maybeFillViewport();
}, 350);
} }
function maybeFillViewport() { const maxColumns = computed(() => {
nextTick(() => { const styleFn = gridStyle.value;
const wrap = scrollbarRef.value?.viewportEl?.value; if (typeof styleFn !== 'function') {
if (!wrap) { return 1;
return; }
}
const { scrollHeight, clientHeight } = wrap; const containerWidth = Math.max(gridWidth.value ?? 0, 0);
const hasSpace = scrollHeight <= clientHeight + VIEWPORT_BUFFER;
if (!hasSpace || isLoadingMore.value) { const baseWidth = 220;
return; const baseGap = 14;
} const scale = cardScale.value;
const spacing = cardSpacing.value;
const minWidth = baseWidth * scale;
const gap = Math.max(6, (baseGap + (scale - 1) * 10) * spacing);
if (filteredFriends.value.length > visibleFriends.value.length) { return Math.max(1, Math.floor((containerWidth + gap) / (minWidth + gap)) || 1);
loadMoreFriends();
}
});
}
watch([searchTerm, activeSegment], () => {
itemsToShow.value = PAGE_SIZE;
nextTick(() => {
updateGridWidth();
maybeFillViewport();
});
}); });
watch( const chunk = (items = [], size = 1) => {
() => filteredFriends.value.length, const out = [];
(length) => { const n = Math.max(1, Math.floor(size) || 1);
if (itemsToShow.value > length) { for (let i = 0; i < items.length; i += n) {
itemsToShow.value = length; out.push(items.slice(i, i + n));
}
return out;
};
const virtualRows = computed(() => {
const rows = [];
const columns = maxColumns.value;
if (isSameInstanceView.value) {
for (const group of sameInstanceGroupsForVirtual.value) {
rows.push({
type: 'header',
key: `h:${group.instanceId}`,
instanceId: group.instanceId,
count: Array.isArray(group.friends) ? group.friends.length : 0
});
const friends = Array.isArray(group.friends) ? group.friends : [];
for (const rowFriends of chunk(friends, Math.min(columns, friends.length || 1))) {
rows.push({
type: 'cards',
key: `g:${group.instanceId}:${rowFriends
.map((f) => f?.id ?? f?.userId ?? f?.displayName ?? '')
.join('|')}`,
items: rowFriends.map((friend) => ({
key: `f:${friend?.id ?? friend?.userId ?? friend?.displayName ?? Math.random()}`,
friend,
displayInstanceInfo: true
}))
});
}
} }
nextTick(() => {
updateGridWidth(); return rows;
maybeFillViewport(); }
if (shouldMergeSameInstance.value) {
for (const group of mergedSameInstanceGroups.value) {
rows.push({
type: 'header',
key: `h:${group.instanceId}`,
instanceId: group.instanceId,
count: Array.isArray(group.friends) ? group.friends.length : 0
});
const friends = Array.isArray(group.friends) ? group.friends : [];
for (const rowFriends of chunk(friends, Math.min(columns, friends.length || 1))) {
rows.push({
type: 'cards',
key: `mg:${group.instanceId}:${rowFriends
.map((f) => f?.id ?? f?.userId ?? f?.displayName ?? '')
.join('|')}`,
items: rowFriends.map((friend) => ({
key: `f:${friend?.id ?? friend?.userId ?? friend?.displayName ?? Math.random()}`,
friend,
displayInstanceInfo: false
}))
});
}
}
if (mergedSameInstanceGroups.value.length && mergedOnlineEntries.value.length) {
rows.push({ type: 'divider', key: 'divider:merged' });
}
const online = mergedOnlineEntries.value;
for (const rowEntries of chunk(online, Math.min(columns, online.length || 1))) {
rows.push({
type: 'cards',
key: `o:${rowEntries.map((e) => e?.id ?? '').join('|')}`,
items: rowEntries.map((entry) => ({
key: `e:${entry?.id ?? entry?.friend?.id ?? entry?.friend?.displayName ?? Math.random()}`,
friend: entry.friend,
displayInstanceInfo: true
}))
});
}
return rows;
}
const entries = filteredFriends.value;
for (const rowEntries of chunk(entries, Math.min(columns, entries.length || 1))) {
rows.push({
type: 'cards',
key: `r:${rowEntries.map((e) => e?.id ?? '').join('|')}`,
items: rowEntries.map((entry) => ({
key: `e:${entry?.id ?? entry?.friend?.id ?? entry?.friend?.displayName ?? Math.random()}`,
friend: entry.friend,
displayInstanceInfo: true
}))
}); });
} }
return rows;
});
const estimatedRowHeight = computed(() => {
const base = 148;
return Math.max(64, Math.round(base * cardScale.value * cardSpacing.value));
});
const virtualizerRef = useVirtualizer(
computed(() => ({
count: virtualRows.value.length,
getScrollElement: () => scrollViewportRef.value,
estimateSize: (index) => {
const row = virtualRows.value[index];
if (row?.type === 'header') return 34;
if (row?.type === 'divider') return 18;
return estimatedRowHeight.value;
},
overscan: 10
}))
); );
watch([cardScale, cardSpacing], () => { const virtualizer = computed(() => virtualizerRef.value);
if (!settingsReady.value) { const virtualItems = computed(() => virtualizer.value?.getVirtualItems?.() ?? []);
const virtualSpacerStyle = computed(() => {
const height = `${virtualizer.value?.getTotalSize?.() ?? 0}px`;
return `height:${height};position:relative;width:100%;`;
});
function virtualRowStyle(start) {
const y = Number(start) || 0;
return `transform:translateY(${y}px);position:absolute;top:0;left:0;width:100%;`;
}
function onVirtualRowRef(el) {
const target = el?.$el ?? el;
if (!target) {
return; return;
} }
virtualizer.value?.measureElement?.(/** @type {Element} */ (target));
}
const virtualListStyle = computed(() => {
const styleFn = gridStyle.value;
const total = filteredFriends.value.length;
// Use matchMaxColumnWidth so rows don't collapse to minWidth on short rows.
const vars = typeof styleFn === 'function' ? styleFn(total, { matchMaxColumnWidth: true }) : {};
return {
...vars
};
});
watch([searchTerm, activeSegment], () => {
virtualizer.value?.scrollToOffset?.(0);
nextTick(() => { nextTick(() => {
resolveScrollViewport();
updateGridWidth(); updateGridWidth();
maybeFillViewport(); virtualizer.value?.measure?.();
}); });
}); });
@@ -648,18 +666,41 @@
activeSegment.value = 'online'; activeSegment.value = 'online';
} }
itemsToShow.value = PAGE_SIZE; virtualizer.value?.scrollToOffset?.(0);
nextTick(() => {
resolveScrollViewport();
updateGridWidth();
virtualizer.value?.measure?.();
});
});
watch(
() => filteredFriends.value.length,
() => {
nextTick(() => {
resolveScrollViewport();
updateGridWidth();
virtualizer.value?.measure?.();
});
}
);
watch([cardScale, cardSpacing], () => {
if (!settingsReady.value) {
return;
}
nextTick(() => { nextTick(() => {
updateGridWidth(); updateGridWidth();
maybeFillViewport(); virtualizer.value?.measure?.();
}); });
}); });
onMounted(() => { onMounted(() => {
nextTick(() => { nextTick(() => {
resolveScrollViewport();
setupResizeHandling(); setupResizeHandling();
maybeFillViewport(); updateGridWidth();
virtualizer.value?.measure?.();
}); });
}); });
@@ -696,9 +737,10 @@
} finally { } finally {
settingsReady.value = true; settingsReady.value = true;
nextTick(() => { nextTick(() => {
resolveScrollViewport();
setupResizeHandling(); setupResizeHandling();
updateGridWidth(); updateGridWidth();
maybeFillViewport(); virtualizer.value?.measure?.();
}); });
} }
} }
@@ -747,6 +789,30 @@
color: var(--el-text-color-regular); color: var(--el-text-color-regular);
} }
.friend-view__virtual {
width: 100%;
padding: 2px;
box-sizing: border-box;
}
.friend-view__virtual-spacer {
width: 100%;
}
.friend-view__virtual-row {
width: 100%;
box-sizing: border-box;
}
.friend-view__row {
display: flex;
flex-wrap: nowrap;
gap: var(--friend-card-gap, 14px);
align-items: stretch;
padding: 2px;
box-sizing: border-box;
}
.friend-view__settings-label { .friend-view__settings-label {
font-size: 13px; font-size: 13px;
font-weight: 500; font-weight: 500;