theme and virtualized list

This commit is contained in:
pa
2026-01-18 20:50:58 +09:00
committed by Natsumi
parent 9081dbe2b1
commit 265e0f999c
30 changed files with 853 additions and 270 deletions
+15
View File
@@ -957,6 +957,10 @@
clearSelectedAvatars();
}
function clearSelectedAvatars() {
selectedFavoriteAvatars.value = [];
}
function isGroupActive(type, key) {
return selectedGroup.value?.type === type && selectedGroup.value?.key === key;
}
@@ -1416,6 +1420,16 @@
min-height: 0;
}
.favorites-splitter :deep([data-slot='resizable-handle']) {
opacity: 0;
transition: opacity 0.2s ease;
}
.favorites-splitter :deep([data-slot='resizable-handle']:hover),
.favorites-splitter :deep([data-slot='resizable-handle']:focus-visible) {
opacity: 1;
}
.favorites-groups-panel {
height: 100%;
padding-right: 8px;
@@ -1452,6 +1466,7 @@
.group-item {
border-radius: 8px;
border: 1px solid var(--border);
padding: 8px;
cursor: pointer;
box-shadow: 0 0 6px rgba(15, 23, 42, 0.04);
+11
View File
@@ -891,6 +891,16 @@
min-height: 0;
}
.favorites-splitter :deep([data-slot='resizable-handle']) {
opacity: 0;
transition: opacity 0.2s ease;
}
.favorites-splitter :deep([data-slot='resizable-handle']:hover),
.favorites-splitter :deep([data-slot='resizable-handle']:focus-visible) {
opacity: 1;
}
.favorites-groups-panel {
height: 100%;
padding-right: 8px;
@@ -927,6 +937,7 @@
.group-item {
border-radius: 8px;
border: 1px solid var(--border);
padding: 8px;
cursor: pointer;
box-shadow: 0 0 6px rgba(15, 23, 42, 0.04);
+173 -15
View File
@@ -379,25 +379,39 @@
</template>
<div v-else class="favorites-empty">No Data</div>
</div>
<ScrollArea
<div
v-else-if="activeLocalGroupName && isLocalGroupSelected"
class="favorites-content__scroll">
ref="localFavoritesViewportRef"
class="favorites-content__scroll favorites-content__scroll--native favorites-content__scroll--local focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
data-reka-scroll-area-viewport=""
data-slot="scroll-area-viewport"
tabindex="0"
style="overflow: hidden scroll">
<template v-if="currentLocalFavorites.length">
<div
class="favorites-card-list"
:style="worldFavoritesGridStyle(currentLocalFavorites.length)">
<FavoritesWorldLocalItem
v-for="favorite in currentLocalFavorites"
:key="favorite.id"
:group="activeLocalGroupName"
:favorite="favorite"
:edit-mode="worldEditMode"
@remove-local-world-favorite="removeLocalWorldFavorite"
@click="showWorldDialog(favorite.id)" />
<div class="favorites-card-virtual" :style="localVirtualContainerStyle">
<template v-for="item in localVirtualItems" :key="String(item.virtualItem.key)">
<div
v-if="item.row"
class="favorites-card-virtual-row"
:data-index="item.virtualItem.index"
:ref="localVirtualizer.measureElement"
:style="{ transform: `translateY(${item.virtualItem.start}px)` }">
<div class="favorites-card-virtual-row-grid">
<FavoritesWorldLocalItem
v-for="favorite in getLocalRowItems(item.row)"
:key="favorite.key"
:group="activeLocalGroupName"
:favorite="favorite.favorite"
:edit-mode="worldEditMode"
@remove-local-world-favorite="removeLocalWorldFavorite"
@click="showWorldDialog(favorite.favorite.id)" />
</div>
</div>
</template>
</div>
</template>
<div v-else class="favorites-empty">No Data</div>
</ScrollArea>
</div>
<div v-else class="favorites-empty">No Data</div>
</template>
</div>
@@ -413,11 +427,11 @@
import { ArrowUpDown, Ellipsis, MoreHorizontal, Plus, RefreshCcw, RefreshCw } from 'lucide-vue-next';
import { InputGroupField, InputGroupSearch } from '@/components/ui/input-group';
import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Spinner } from '@/components/ui/spinner';
import { storeToRefs } from 'pinia';
import { toast } from 'vue-sonner';
import { useI18n } from 'vue-i18n';
import { useVirtualizer } from '@tanstack/vue-virtual';
import {
DropdownMenu,
@@ -757,6 +771,86 @@
return localWorldFavorites.value[activeLocalGroupName.value] || [];
});
const localFavoritesViewportRef = ref(null);
const getFavoritesGridMetrics = (count = 1, options = {}) => {
const styleFn = worldFavoritesGridStyle.value;
const styles = typeof styleFn === 'function' ? styleFn(count, options) : {};
const columnsRaw = styles['--favorites-grid-columns'] ?? 1;
const gapRaw = styles['--favorites-card-gap'] ?? 12;
const columns = Math.max(1, Number(columnsRaw) || 1);
const gap = Number(String(gapRaw).replace('px', '')) || 0;
return {
columns,
gap,
styles
};
};
const chunkLocalFavorites = (favorites = []) => {
const items = Array.isArray(favorites) ? favorites : [];
if (!items.length) {
return [];
}
const { columns } = getFavoritesGridMetrics(items.length, { matchMaxColumnWidth: true });
const safeColumns = Math.max(1, columns || 1);
const rows = [];
for (let index = 0; index < items.length; index += safeColumns) {
rows.push({
type: 'cards',
key: `local:${activeLocalGroupName.value}:${index}`,
items: items.slice(index, index + safeColumns).map((favorite) => ({
key: favorite.id ?? favorite.worldId ?? favorite.name ?? `${index}:${Math.random()}`,
favorite
}))
});
}
return rows;
};
const localVirtualRows = computed(() => chunkLocalFavorites(currentLocalFavorites.value));
const estimateLocalRowSize = (row) => {
if (!row) {
return 120;
}
const itemCount = Array.isArray(row.items) ? row.items.length : 0;
const { columns, gap } = getFavoritesGridMetrics(itemCount, { matchMaxColumnWidth: true });
const safeColumns = Math.max(1, columns || 1);
const rows = Math.max(1, Math.ceil(itemCount / safeColumns));
const baseCardHeight = 220;
const rowGap = Math.max(0, gap);
return rows * baseCardHeight + (rows - 1) * rowGap + 8;
};
const localVirtualizer = useVirtualizer(
computed(() => ({
count: localVirtualRows.value.length,
getScrollElement: () => localFavoritesViewportRef.value,
estimateSize: (index) => estimateLocalRowSize(localVirtualRows.value[index]),
overscan: 8
}))
);
const localVirtualItems = computed(() => {
const items = localVirtualizer.value?.getVirtualItems?.() ?? [];
return items.map((virtualItem) => ({
virtualItem,
row: localVirtualRows.value[virtualItem.index]
}));
});
const localVirtualContainerStyle = computed(() => ({
...getFavoritesGridMetrics(currentLocalFavorites.value.length, { matchMaxColumnWidth: true }).styles,
height: `${localVirtualizer.value?.getTotalSize?.() ?? 0}px`
}));
const getLocalRowItems = (row) => (row && Array.isArray(row.items) ? row.items : []);
function handleSortFavoritesChange(value) {
const next = Boolean(value);
if (next !== sortFavorites.value) {
@@ -797,6 +891,12 @@
}
});
watch([currentLocalFavorites, worldCardScale, worldCardSpacing, activeLocalGroupName], () => {
nextTick(() => {
localVirtualizer.value?.measure?.();
});
});
watch(
() => worldEditMode.value,
(value) => {
@@ -1248,6 +1348,16 @@
min-height: 0;
}
.favorites-splitter :deep([data-slot='resizable-handle']) {
opacity: 0;
transition: opacity 0.2s ease;
}
.favorites-splitter :deep([data-slot='resizable-handle']:hover),
.favorites-splitter :deep([data-slot='resizable-handle']:focus-visible) {
opacity: 1;
}
.favorites-dropdown {
padding: 10px;
}
@@ -1284,6 +1394,7 @@
.group-item {
border-radius: 8px;
border: 1px solid var(--border);
padding: 8px;
cursor: pointer;
box-shadow: 0 0 6px rgba(15, 23, 42, 0.04);
@@ -1423,6 +1534,26 @@
overflow: auto;
}
.favorites-content__scroll--local {
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
.favorites-content__scroll--local::-webkit-scrollbar {
width: 10px;
}
.favorites-content__scroll--local::-webkit-scrollbar-track {
background: transparent;
}
.favorites-content__scroll--local::-webkit-scrollbar-thumb {
background-color: var(--border);
border-radius: 999px;
border: 2px solid transparent;
background-clip: content-box;
}
.favorites-search-grid {
display: grid;
grid-template-columns: repeat(
@@ -1445,6 +1576,33 @@
padding: 4px 2px 12px 2px;
}
.favorites-card-virtual {
width: 100%;
position: relative;
box-sizing: border-box;
}
.favorites-card-virtual-row {
width: 100%;
position: absolute;
left: 0;
top: 0;
box-sizing: border-box;
padding-bottom: var(--favorites-card-gap, 12px);
}
.favorites-card-virtual-row-grid {
display: grid;
grid-template-columns: repeat(
var(--favorites-grid-columns, 1),
minmax(var(--favorites-card-min-width, 260px), var(--favorites-card-target-width, 1fr))
);
gap: var(--favorites-card-gap, 12px);
justify-content: start;
padding: 4px 2px 0 2px;
box-sizing: border-box;
}
.favorites-card-list::after {
content: '';
}
+218 -118
View File
@@ -74,35 +74,46 @@
<div v-else class="friend-view__toolbar friend-view__toolbar--loading">
<span class="friend-view__loading-text">{{ t('view.friends_locations.loading_more') }}</span>
</div>
<ScrollArea v-if="settingsReady" ref="scrollbarRef" class="friend-view__scroll">
<div v-if="virtualRows.length" class="friend-view__virtual" :style="virtualListStyle">
<div v-for="row in virtualRows" :key="String(row.key)" class="friend-view__virtual-row">
<template v-if="row.type === 'header'">
<header class="friend-view__instance-header">
<Location class="text-xs" :location="row.instanceId" style="display: inline" />
<span class="friend-view__instance-count">({{ row.count }})</span>
</header>
</template>
<div v-if="settingsReady" ref="scrollbarRef" class="friend-view__scroll">
<div v-if="virtualRows.length" class="friend-view__virtual" :style="virtualContainerStyle">
<template v-for="item in virtualItems" :key="String(item.virtualItem.key)">
<div
v-if="item.row"
class="friend-view__virtual-row"
:class="`friend-view__virtual-row--${item.row.type}`"
:data-index="item.virtualItem.index"
:ref="virtualizer.measureElement"
:style="{ transform: `translateY(${item.virtualItem.start}px)` }">
<template v-if="item.row.type === 'header'">
<header class="friend-view__instance-header">
<Location
class="text-xs"
:location="getRowInstanceId(item.row)"
style="display: inline" />
<span class="friend-view__instance-count">({{ getRowCount(item.row) }})</span>
</header>
</template>
<template v-else-if="row.type === 'divider'">
<div class="friend-view__divider"><span class="friend-view__divider-text"></span></div>
</template>
<template v-else-if="item.row.type === 'divider'">
<div class="friend-view__divider"><span class="friend-view__divider-text"></span></div>
</template>
<template v-else>
<div class="friend-view__row">
<FriendLocationCard
v-for="item in row.items ?? []"
:key="item.key"
:friend="item.friend"
:card-scale="cardScale"
:card-spacing="cardSpacing"
:display-instance-info="item.displayInstanceInfo" />
</div>
</template>
</div>
<template v-else>
<div class="friend-view__row">
<FriendLocationCard
v-for="card in getRowItems(item.row)"
:key="card.key"
:friend="card.friend"
:card-scale="cardScale"
:card-spacing="cardSpacing"
:display-instance-info="card.displayInstanceInfo" />
</div>
</template>
</div>
</template>
</div>
<div v-else class="friend-view__empty">{{ t('view.friends_locations.no_matching_friends') }}</div>
</ScrollArea>
</div>
<div v-else class="friend-view__initial-loading">
<Loader2 class="friend-view__loading-icon" :size="22" />
</div>
@@ -110,15 +121,15 @@
</template>
<script setup>
import { computed, nextTick, onBeforeMount, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue';
import { computed, nextTick, onBeforeMount, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { Field, FieldContent, FieldLabel } from '@/components/ui/field';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Loader2, Settings } from 'lucide-vue-next';
import { Button } from '@/components/ui/button';
import { InputGroupSearch } from '@/components/ui/input-group';
import { ScrollArea } from '@/components/ui/scroll-area';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';
import { useVirtualizer } from '@tanstack/vue-virtual';
import { Popover, PopoverContent, PopoverTrigger } from '../../components/ui/popover';
import { Slider } from '../../components/ui/slider';
@@ -205,13 +216,12 @@
const searchTerm = ref('');
const scrollbarRef = ref();
const scrollViewportRef = shallowRef(null);
const gridWidth = ref(0);
let resizeObserver;
let cleanupResize;
const updateGridWidth = () => {
const wrap = scrollViewportRef.value;
const wrap = scrollbarRef.value;
if (!wrap) {
return;
}
@@ -225,7 +235,7 @@
cleanupResize = undefined;
}
const wrap = scrollViewportRef.value;
const wrap = scrollbarRef.value;
if (!wrap) {
return;
}
@@ -475,14 +485,69 @@
};
});
function resolveScrollViewport() {
const rootEl = scrollbarRef.value?.$el ?? null;
if (!rootEl) {
scrollViewportRef.value = null;
return;
const getGridMetrics = (count = 1, options = {}) => {
const baseWidth = 220;
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);
const containerWidth = Math.max(gridWidth.value ?? 0, 0);
const itemCount = Math.max(Number(count) || 0, 0);
const safeCount = itemCount > 0 ? itemCount : 1;
const maxColumns = Math.max(1, Math.floor((containerWidth + gap) / (minWidth + gap)) || 1);
const preferredColumns = options?.preferredColumns;
const requestedColumns = preferredColumns
? Math.max(1, Math.min(Math.round(preferredColumns), maxColumns))
: maxColumns;
const columns = Math.max(1, Math.min(safeCount, requestedColumns));
const forceStretch = Boolean(options?.forceStretch);
const disableAutoStretch = Boolean(options?.disableAutoStretch);
const matchMaxColumnWidth = Boolean(options?.matchMaxColumnWidth);
const shouldStretch = !disableAutoStretch && (forceStretch || itemCount >= maxColumns);
let cardWidth = minWidth;
const maxColumnWidth = maxColumns > 0 ? (containerWidth - gap * (maxColumns - 1)) / maxColumns : minWidth;
if (shouldStretch && columns > 0) {
const columnsWidth = containerWidth - gap * (columns - 1);
const rawWidth = columnsWidth > 0 ? columnsWidth / columns : minWidth;
if (Number.isFinite(rawWidth) && rawWidth > 0) {
cardWidth = Math.max(minWidth, rawWidth);
}
} else if (matchMaxColumnWidth && Number.isFinite(maxColumnWidth) && maxColumnWidth > 0) {
cardWidth = Math.max(minWidth, maxColumnWidth);
}
scrollViewportRef.value = rootEl.querySelector('[data-slot="scroll-area-viewport"]');
}
return {
minWidth,
gap,
columns,
cardWidth
};
};
const chunkCardItems = (items = [], keyPrefix = 'row') => {
const safeItems = Array.isArray(items) ? items : [];
if (!safeItems.length) {
return [];
}
const { columns } = getGridMetrics(safeItems.length, { matchMaxColumnWidth: true });
const safeColumns = Math.max(1, columns || 1);
const rows = [];
for (let index = 0; index < safeItems.length; index += safeColumns) {
rows.push({
type: 'cards',
key: `${keyPrefix}:${index}`,
items: safeItems.slice(index, index + safeColumns)
});
}
return rows;
};
const virtualRows = computed(() => {
const rows = [];
@@ -498,15 +563,12 @@
const friends = Array.isArray(group.friends) ? group.friends : [];
if (friends.length) {
rows.push({
type: 'cards',
key: `g:${group.instanceId}`,
items: friends.map((friend) => ({
key: `f:${friend?.id ?? friend?.userId ?? friend?.displayName ?? Math.random()}`,
friend,
displayInstanceInfo: true
}))
});
const items = friends.map((friend) => ({
key: `f:${friend?.id ?? friend?.userId ?? friend?.displayName ?? Math.random()}`,
friend,
displayInstanceInfo: true
}));
rows.push(...chunkCardItems(items, `g:${group.instanceId}`));
}
}
@@ -524,15 +586,12 @@
const friends = Array.isArray(group.friends) ? group.friends : [];
if (friends.length) {
rows.push({
type: 'cards',
key: `mg:${group.instanceId}`,
items: friends.map((friend) => ({
key: `f:${friend?.id ?? friend?.userId ?? friend?.displayName ?? Math.random()}`,
friend,
displayInstanceInfo: false
}))
});
const items = friends.map((friend) => ({
key: `f:${friend?.id ?? friend?.userId ?? friend?.displayName ?? Math.random()}`,
friend,
displayInstanceInfo: false
}));
rows.push(...chunkCardItems(items, `mg:${group.instanceId}`));
}
}
@@ -542,15 +601,12 @@
const online = mergedOnlineEntries.value;
if (online.length) {
rows.push({
type: 'cards',
key: 'o:merged',
items: online.map((entry) => ({
key: `e:${entry?.id ?? entry?.friend?.id ?? entry?.friend?.displayName ?? Math.random()}`,
friend: entry.friend,
displayInstanceInfo: true
}))
});
const items = online.map((entry) => ({
key: `e:${entry?.id ?? entry?.friend?.id ?? entry?.friend?.displayName ?? Math.random()}`,
friend: entry.friend,
displayInstanceInfo: true
}));
rows.push(...chunkCardItems(items, 'o:merged'));
}
return rows;
@@ -558,15 +614,12 @@
const entries = filteredFriends.value;
if (entries.length) {
rows.push({
type: 'cards',
key: 'r:all',
items: entries.map((entry) => ({
key: `e:${entry?.id ?? entry?.friend?.id ?? entry?.friend?.displayName ?? Math.random()}`,
friend: entry.friend,
displayInstanceInfo: true
}))
});
const items = entries.map((entry) => ({
key: `e:${entry?.id ?? entry?.friend?.id ?? entry?.friend?.displayName ?? Math.random()}`,
friend: entry.friend,
displayInstanceInfo: true
}));
rows.push(...chunkCardItems(items, 'r:all'));
}
return rows;
});
@@ -582,10 +635,60 @@
};
});
const estimateRowSize = (row) => {
if (!row) {
return 48;
}
if (row.type === 'header') {
return 32;
}
if (row.type === 'divider') {
return 36;
}
const itemCount = Array.isArray(row.items) ? row.items.length : 0;
const { columns, gap } = getGridMetrics(itemCount, { matchMaxColumnWidth: true });
const safeColumns = Math.max(1, columns || 1);
const rows = Math.max(1, Math.ceil(itemCount / safeColumns));
const scale = cardScale.value;
const spacing = cardSpacing.value;
const baseCardHeight = 150;
const cardHeight = baseCardHeight * scale * spacing;
const rowGap = Math.max(0, gap - 4);
return rows * cardHeight + (rows - 1) * rowGap + 8;
};
const virtualizer = useVirtualizer(
computed(() => ({
count: virtualRows.value.length,
getScrollElement: () => scrollbarRef.value,
estimateSize: (index) => estimateRowSize(virtualRows.value[index]),
overscan: 5
}))
);
const virtualItems = computed(() => {
const items = virtualizer.value?.getVirtualItems?.() ?? [];
return items.map((virtualItem) => ({
virtualItem,
row: virtualRows.value[virtualItem.index]
}));
});
const virtualContainerStyle = computed(() => ({
...virtualListStyle.value,
height: `${virtualizer.value?.getTotalSize?.() ?? 0}px`
}));
const getRowItems = (row) => (row && Array.isArray(row.items) ? row.items : []);
const getRowInstanceId = (row) => (row && row.type === 'header' ? row.instanceId : '');
const getRowCount = (row) => (row && row.type === 'header' ? row.count : 0);
watch([searchTerm, activeSegment], () => {
nextTick(() => {
resolveScrollViewport();
updateGridWidth();
virtualizer.value?.measure?.();
});
});
@@ -598,8 +701,8 @@
}
nextTick(() => {
resolveScrollViewport();
updateGridWidth();
virtualizer.value?.measure?.();
});
});
@@ -607,8 +710,8 @@
() => filteredFriends.value.length,
() => {
nextTick(() => {
resolveScrollViewport();
updateGridWidth();
virtualizer.value?.measure?.();
});
}
);
@@ -619,14 +722,21 @@
}
nextTick(() => {
updateGridWidth();
virtualizer.value?.measure?.();
});
});
watch(virtualRows, () => {
nextTick(() => {
virtualizer.value?.measure?.();
});
});
onMounted(() => {
nextTick(() => {
resolveScrollViewport();
setupResizeHandling();
updateGridWidth();
virtualizer.value?.measure?.();
});
});
@@ -663,9 +773,9 @@
} finally {
settingsReady.value = true;
nextTick(() => {
resolveScrollViewport();
setupResizeHandling();
updateGridWidth();
virtualizer.value?.measure?.();
});
}
}
@@ -680,6 +790,13 @@
display: grid;
grid-template-rows: auto 1fr;
gap: 16px;
min-height: 0;
height: 100%;
overflow: hidden;
}
.friend-view.x-container {
overflow: hidden;
}
.friend-view__toolbar {
@@ -721,17 +838,31 @@
width: 100%;
padding: 2px;
box-sizing: border-box;
display: grid;
row-gap: calc(var(--friend-card-gap) - 4px);
}
.friend-view__virtual-spacer {
width: 100%;
position: relative;
}
.friend-view__virtual-row {
width: 100%;
box-sizing: border-box;
position: absolute;
left: 0;
top: 0;
padding-bottom: calc(var(--friend-card-gap, 14px) - 4px);
}
.friend-view__virtual-row--header {
padding: 4px 10px;
padding-bottom: calc(var(--friend-card-gap, 14px) - 4px);
}
.friend-view__virtual-row--divider {
padding: 16px 4px;
padding-bottom: calc(var(--friend-card-gap, 14px) - 4px);
}
.friend-view__virtual-row--cards {
padding: 2px;
padding-bottom: calc(var(--friend-card-gap, 14px) - 4px);
}
.friend-view__row {
@@ -784,7 +915,9 @@
}
.friend-view__scroll {
padding: 2px;
overflow: auto;
min-height: 0;
height: 100%;
}
.friend-view__initial-loading {
@@ -793,33 +926,10 @@
min-height: 240px;
}
.friend-view__grid {
display: grid;
grid-template-columns: repeat(
var(--friend-grid-columns, 1),
minmax(var(--friend-card-min-width, 200px), var(--friend-card-target-width, 1fr))
);
gap: var(--friend-card-gap, 18px);
justify-content: start;
padding: 2px;
}
.friend-view__instances {
display: grid;
gap: 18px;
box-sizing: border-box;
}
.friend-view__instance {
display: grid;
gap: 10px;
}
.friend-view__instance-header {
display: flex;
align-items: center;
padding: 4px 2px;
margin: 5px 10px;
font-weight: 600;
font-size: 13px;
}
@@ -828,7 +938,6 @@
display: flex;
align-items: center;
gap: 12px;
margin: 16px 4px;
font-size: 13px;
font-weight: 600;
}
@@ -856,15 +965,6 @@
letter-spacing: 0.5px;
}
.friend-view__loading {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 18px 0 12px;
font-size: 14px;
}
.friend-view__loading-icon {
animation: spin 1s linear infinite;
}
@@ -1,7 +1,7 @@
<template>
<Card class="friend-card p-0 gap-0" :style="cardStyle" @click="showUserDialog(friend.id)">
<div class="friend-card__header">
<div class="friend-card__avatar-wrapper">
<div>
<Avatar class="friend-card__avatar" :style="{ width: `${avatarSize}px`, height: `${avatarSize}px` }">
<AvatarImage :src="userImage(props.friend.ref, true)" />
<AvatarFallback>{{ avatarFallback }}</AvatarFallback>
@@ -62,7 +62,7 @@
'--card-scale': props.cardScale,
'--card-spacing': props.cardSpacing,
cursor: 'pointer',
padding: `${16 * props.cardScale * props.cardSpacing}px`
padding: `${24 * props.cardScale * props.cardSpacing}px`
}));
const avatarFallback = computed(() => props.friend?.name?.charAt(0) ?? '?');
@@ -115,11 +115,6 @@
gap: calc(12px * var(--card-scale) * var(--card-spacing));
}
.friend-card__avatar-wrapper {
position: static;
flex: none;
}
.friend-card__status-dot {
position: absolute;
top: calc(8px * var(--card-scale));
+5 -2
View File
@@ -27,6 +27,7 @@
<InputGroupField
id="login-form-username"
:model-value="field.value"
autocomplete="off"
name="username"
:placeholder="t('view.login.field.username')"
:aria-invalid="!!errors.length"
@@ -47,11 +48,11 @@
id="login-form-password"
:model-value="field.value"
type="password"
autocomplete="off"
name="password"
:placeholder="t('view.login.field.password')"
:aria-invalid="!!errors.length"
clearable
show-password
@update:modelValue="field.onChange"
@blur="field.onBlur" />
<FieldError v-if="errors.length" :errors="errors" />
@@ -77,6 +78,7 @@
<InputGroupField
id="login-form-endpoint"
:model-value="field.value"
autocomplete="off"
name="endpoint"
:placeholder="AppDebug.endpointDomainVrchat"
:aria-invalid="!!errors.length"
@@ -96,6 +98,7 @@
<InputGroupField
id="login-form-websocket"
:model-value="field.value"
autocomplete="off"
name="websocket"
:placeholder="AppDebug.websocketDomainVrchat"
:aria-invalid="!!errors.length"
@@ -131,7 +134,7 @@
<div
v-for="user in savedCredentials"
:key="user.user.id"
class="x-friend-item"
class="x-friend-item hover:bg-muted rounded-xs"
@click="clickSavedLogin(user)">
<div class="avatar">
<img :src="userImage(user.user)" loading="lazy" />
@@ -17,21 +17,7 @@
</SelectContent>
</Select>
</div>
<div class="options-container-item">
<span class="name">{{ t('view.settings.appearance.appearance.theme_mode') }}</span>
<Select :model-value="themeMode" @update:modelValue="setThemeMode">
<SelectTrigger size="sm">
<SelectValue :placeholder="themeDisplayName(themeMode)" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem v-for="(config, themeKey) in THEME_CONFIG" :key="themeKey" :value="themeKey">
{{ themeDisplayName(themeKey) }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
<div class="options-container-item">
<span class="name flex! items-center!">
{{ t('view.settings.appearance.appearance.font_family') }}
@@ -466,28 +452,18 @@
import PresetColorPicker from '@/components/PresetColorPicker.vue';
import { useAppearanceSettingsStore, useFavoriteStore, useVrStore } from '../../../../stores';
import { APP_FONT_FAMILIES, THEME_CONFIG } from '../../../../shared/constants';
import { getLanguageName, languageCodes } from '../../../../localization';
import { APP_FONT_FAMILIES } from '../../../../shared/constants';
import SimpleSwitch from '../SimpleSwitch.vue';
const { t } = useI18n();
const themeDisplayName = (themeKey) => {
const i18nKey = `view.settings.appearance.appearance.theme_mode_${themeKey}`;
const translated = t(i18nKey);
if (translated !== i18nKey) {
return translated;
}
return THEME_CONFIG[themeKey]?.name ?? themeKey;
};
const appearanceSettingsStore = useAppearanceSettingsStore();
const { saveOpenVROption, updateVRConfigVars } = useVrStore();
const {
appLanguage,
themeMode,
displayVRCPlusIconsAsAvatar,
appFontFamily,
hideNicknames,
@@ -534,7 +510,6 @@
setHideUserMemos,
setHideUnfriends,
updateTrustColor,
setThemeMode,
changeAppLanguage,
promptMaxTableSizeDialog,
setNotificationIconDot,
@@ -9,8 +9,7 @@
:placeholder="t('dialog.primary_password.password_placeholder')"
type="password"
size="sm"
maxlength="32"
show-password
:maxlength="32"
autofocus />
<InputGroupField
v-model="enablePrimaryPasswordDialog.rePassword"
@@ -18,8 +17,7 @@
type="password"
style="margin-top: 5px"
size="sm"
maxlength="32"
show-password />
:maxlength="32" />
<DialogFooter>
<Button
:disabled="