fead: add my avatar grid view

This commit is contained in:
pa
2026-03-07 23:21:34 +09:00
parent 029ed2b3e2
commit 1dc00afe89
5 changed files with 1164 additions and 115 deletions

View File

@@ -198,7 +198,9 @@
},
"my_avatars": {
"filter": "Filter",
"clear_filters": "Clear All"
"clear_filters": "Clear All",
"table_view": "Table View",
"grid_view": "Grid View"
},
"search": {
"search_placeholder": "Search",
@@ -1323,7 +1325,8 @@
"create_impostor": "Create Impostor",
"manage_tags": "Manage Tags",
"manage_tags_placeholder": "Add a tag...",
"manage_tags_hint": "Click a tag to change its color"
"manage_tags_hint": "Click a tag to change its color",
"view_details": "View Details"
},
"info": {
"header": "Info",

View File

@@ -1,6 +1,165 @@
<template>
<div class="x-container" ref="containerRef">
<div class="x-container grid h-full min-h-0 grid-rows-[auto_1fr] gap-4 overflow-hidden" ref="containerRef">
<div class="flex items-center gap-2 px-0.5 pt-1.5">
<ToggleGroup
type="single"
:model-value="viewMode"
variant="outline"
@update:model-value="handleViewModeChange">
<TooltipWrapper :content="t('view.my_avatars.table_view')" side="bottom" :delay-duration="300">
<ToggleGroupItem value="table" class="px-2">
<List class="size-4" />
</ToggleGroupItem>
</TooltipWrapper>
<TooltipWrapper :content="t('view.my_avatars.grid_view')" side="bottom" :delay-duration="300">
<ToggleGroupItem value="grid" class="px-2">
<LayoutGrid class="size-4" />
</ToggleGroupItem>
</TooltipWrapper>
</ToggleGroup>
<Popover>
<PopoverTrigger as-child>
<Button variant="outline" size="sm" class="h-8 gap-1.5">
<ListFilter class="size-4" />
{{ t('view.my_avatars.filter') }}
<Badge
v-if="activeFilterCount"
variant="secondary"
class="ml-0.5 h-4.5 min-w-4.5 rounded-full px-1 text-xs">
{{ activeFilterCount }}
</Badge>
</Button>
</PopoverTrigger>
<PopoverContent class="w-auto p-3" align="start">
<div class="flex flex-col gap-3">
<Field>
<FieldLabel>{{ t('dialog.avatar.info.visibility') }}</FieldLabel>
<FieldContent>
<ToggleGroup
type="single"
:model-value="releaseStatusFilter"
variant="outline"
@update:model-value="releaseStatusFilter = $event">
<ToggleGroupItem
v-for="opt in releaseStatusOptions"
:key="opt.value"
:value="opt.value"
class="px-2.5">
{{ opt.label }}
</ToggleGroupItem>
</ToggleGroup>
</FieldContent>
</Field>
<Field>
<FieldLabel>{{ t('dialog.avatar.info.platform') }}</FieldLabel>
<FieldContent>
<ToggleGroup
type="single"
:model-value="platformFilter"
variant="outline"
@update:model-value="platformFilter = $event">
<ToggleGroupItem value="all" class="px-2.5">
{{ t('view.search.avatar.all') }}
</ToggleGroupItem>
<ToggleGroupItem
v-for="plat in platformOptions"
:key="plat.value"
:value="plat.value"
class="px-2.5">
{{ plat.label }}
</ToggleGroupItem>
</ToggleGroup>
</FieldContent>
</Field>
<Field v-if="allTags.length">
<FieldLabel>{{ t('dialog.avatar.info.tags') }}</FieldLabel>
<FieldContent>
<div class="flex flex-wrap gap-1">
<Badge
v-for="tag in allTags"
:key="tag"
:variant="tagFilters.has(tag) ? 'default' : 'outline'"
class="cursor-pointer select-none"
:style="
tagFilters.has(tag)
? {
backgroundColor: getTagColor(tag).bg,
color: getTagColor(tag).text
}
: {
borderColor: getTagColor(tag).bg,
color: getTagColor(tag).text
}
"
@click="toggleTagFilter(tag)">
{{ tag }}
</Badge>
</div>
</FieldContent>
</Field>
<Button
v-if="activeFilterCount"
variant="outline"
size="sm"
class="w-full"
@click="clearFilters">
{{ t('view.my_avatars.clear_filters') }}
</Button>
</div>
</PopoverContent>
</Popover>
<div class="flex-1" />
<span v-if="isLoading" class="text-muted-foreground text-sm">
{{ t('view.friends_locations.loading_more') }}
</span>
<Input v-model="searchText" :placeholder="t('view.search.search_placeholder')" class="h-8 w-80" />
<DropdownMenu v-if="viewMode === 'grid'">
<DropdownMenuTrigger as-child>
<Button class="rounded-full" size="icon-sm" variant="ghost">
<SettingsIcon class="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent class="w-60 p-3" align="end">
<div class="grid gap-3">
<div class="flex items-center justify-between" @click.stop>
<span class="text-[13px] font-medium">{{ t('view.friends_locations.scale') }}</span>
<span class="text-xs font-semibold min-w-[42px] text-right">{{ cardScalePercent }}%</span>
</div>
<Slider
v-model="cardScaleValue"
:min="scaleSlider.min"
:max="scaleSlider.max"
:step="scaleSlider.step"
@click.stop />
<div class="flex items-center justify-between" @click.stop>
<span class="text-[13px] font-medium">{{ t('view.friends_locations.spacing') }}</span>
<span class="text-xs font-semibold min-w-[42px] text-right">{{ cardSpacingPercent }}%</span>
</div>
<Slider
v-model="cardSpacingValue"
:min="spacingSlider.min"
:max="spacingSlider.max"
:step="spacingSlider.step"
@click.stop />
</div>
</DropdownMenuContent>
</DropdownMenu>
<Button size="icon-sm" variant="ghost" :disabled="isLoading" @click="refreshAvatars">
<RefreshCw :class="{ 'animate-spin': isLoading }" />
</Button>
</div>
<!-- Table View -->
<DataTableLayout
v-if="viewMode === 'table'"
:table="table"
:table-style="tableHeightStyle"
:page-sizes="pageSizes"
@@ -9,115 +168,7 @@
:on-page-size-change="handlePageSizeChange"
:on-row-click="handleRowClick"
:row-class="getRowClass"
class="cursor-pointer">
<template #toolbar>
<div class="mb-2.5 flex items-center gap-2">
<Popover>
<PopoverTrigger as-child>
<Button variant="outline" size="sm" class="h-8 gap-1.5">
<ListFilter class="size-4" />
{{ t('view.my_avatars.filter') }}
<Badge
v-if="activeFilterCount"
variant="secondary"
class="ml-0.5 h-4.5 min-w-4.5 rounded-full px-1 text-xs">
{{ activeFilterCount }}
</Badge>
</Button>
</PopoverTrigger>
<PopoverContent class="w-auto p-3" align="start">
<div class="flex flex-col gap-3">
<Field>
<FieldLabel>{{ t('dialog.avatar.info.visibility') }}</FieldLabel>
<FieldContent>
<ToggleGroup
type="single"
:model-value="releaseStatusFilter"
variant="outline"
@update:model-value="releaseStatusFilter = $event">
<ToggleGroupItem
v-for="opt in releaseStatusOptions"
:key="opt.value"
:value="opt.value"
class="px-2.5">
{{ opt.label }}
</ToggleGroupItem>
</ToggleGroup>
</FieldContent>
</Field>
<Field>
<FieldLabel>{{ t('dialog.avatar.info.platform') }}</FieldLabel>
<FieldContent>
<ToggleGroup
type="single"
:model-value="platformFilter"
variant="outline"
@update:model-value="platformFilter = $event">
<ToggleGroupItem value="all" class="px-2.5">
{{ t('view.search.avatar.all') }}
</ToggleGroupItem>
<ToggleGroupItem
v-for="plat in platformOptions"
:key="plat.value"
:value="plat.value"
class="px-2.5">
{{ plat.label }}
</ToggleGroupItem>
</ToggleGroup>
</FieldContent>
</Field>
<Field v-if="allTags.length">
<FieldLabel>{{ t('dialog.avatar.info.tags') }}</FieldLabel>
<FieldContent>
<div class="flex flex-wrap gap-1">
<Badge
v-for="tag in allTags"
:key="tag"
:variant="tagFilters.has(tag) ? 'default' : 'outline'"
class="cursor-pointer select-none"
:style="
tagFilters.has(tag)
? {
backgroundColor: getTagColor(tag).bg,
color: getTagColor(tag).text
}
: {
borderColor: getTagColor(tag).bg,
color: getTagColor(tag).text
}
"
@click="toggleTagFilter(tag)">
{{ tag }}
</Badge>
</div>
</FieldContent>
</Field>
<Button
v-if="activeFilterCount"
variant="outline"
size="sm"
class="w-full"
@click="clearFilters">
{{ t('view.my_avatars.clear_filters') }}
</Button>
</div>
</PopoverContent>
</Popover>
<div class="flex-1" />
<span v-if="isLoading" class="text-muted-foreground text-sm">
{{ t('view.friends_locations.loading_more') }}
</span>
<Input v-model="searchText" :placeholder="t('view.search.search_placeholder')" class="h-8 w-80" />
<Button size="icon-sm" variant="ghost" :disabled="isLoading" @click="refreshAvatars">
<RefreshCw :class="{ 'animate-spin': isLoading }" />
</Button>
</div>
</template>
class="cursor-pointer min-h-0">
<template #row-context-menu="{ row }">
<ContextMenuContent>
<ContextMenuItem @click="handleContextMenuAction('manageTags', row.original)">
@@ -166,6 +217,43 @@
</ContextMenuContent>
</template>
</DataTableLayout>
<!-- Grid View -->
<div v-else-if="viewMode === 'grid'" ref="gridScrollRef" class="overflow-auto min-h-0 py-2">
<div
v-if="gridRows.length"
ref="gridContainerRefEl"
class="relative w-full box-border p-1"
:style="{ height: `${virtualizer?.getTotalSize?.() ?? 0}px` }">
<div
v-for="vItem in virtualItems"
:key="String(vItem.virtualItem.key)"
class="absolute left-0 top-0 w-full box-border pb-2"
:data-index="vItem.virtualItem.index"
:ref="virtualizer.measureElement"
:style="{ transform: `translateY(${vItem.virtualItem.start}px)` }">
<div
class="grid gap-[var(--avatar-card-gap,12px)] p-0.5"
:style="{
gridTemplateColumns: `repeat(var(--avatar-grid-columns, 1), minmax(var(--avatar-card-min-width, 200px), var(--avatar-card-target-width, 1fr)))`,
...gridStyle(filteredAvatars.length)
}">
<MyAvatarCard
v-for="avatar in vItem.row.items"
:key="avatar.id"
:avatar="avatar"
:current-avatar-id="currentAvatarId"
:card-scale="cardScale"
@click="handleWearAvatar(avatar.id)"
@context-action="handleContextMenuAction" />
</div>
</div>
</div>
<div v-else class="grid place-items-center min-h-60 text-[15px]">
<DataTableEmpty type="nomatch" />
</div>
</div>
<input
ref="imageUploadInput"
type="file"
@@ -189,11 +277,22 @@
</template>
<script setup>
import { Image as ImageIcon, ListFilter, Pencil, RefreshCw, Tag, User } from 'lucide-vue-next';
import { computed, onMounted, ref, watch } from 'vue';
import {
Image as ImageIcon,
LayoutGrid,
List,
ListFilter,
Pencil,
RefreshCw,
Settings as SettingsIcon,
Tag,
User
} from 'lucide-vue-next';
import { computed, nextTick, onBeforeMount, onMounted, ref, watch } from 'vue';
import { storeToRefs } from 'pinia';
import { toast } from 'vue-sonner';
import { useI18n } from 'vue-i18n';
import { useVirtualizer } from '@tanstack/vue-virtual';
import {
handleImageUploadInput,
@@ -204,24 +303,30 @@
} from '../../shared/utils/imageUpload';
import { useAppearanceSettingsStore, useAvatarStore, useModalStore, useUserStore } from '../../stores';
import { ContextMenuContent, ContextMenuItem, ContextMenuSeparator } from '../../components/ui/context-menu';
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '../../components/ui/dropdown-menu';
import { Field, FieldContent, FieldLabel } from '../../components/ui/field';
import { Popover, PopoverContent, PopoverTrigger } from '../../components/ui/popover';
import { DataTableEmpty, DataTableLayout } from '../../components/ui/data-table';
import { ToggleGroup, ToggleGroupItem } from '../../components/ui/toggle-group';
import { Badge } from '../../components/ui/badge';
import { Button } from '../../components/ui/button';
import { DataTableLayout } from '../../components/ui/data-table';
import { Input } from '../../components/ui/input';
import { Slider } from '../../components/ui/slider';
import { TooltipWrapper } from '../../components/ui/tooltip';
import { avatarRequest } from '../../api';
import { database } from '../../service/database';
import { getColumns } from './columns';
import { getPlatformInfo } from '../../shared/utils/avatar';
import { getTagColor } from '../../shared/constants';
import { processBulk } from '../../service/request';
import { useAvatarCardGrid } from './composables/useAvatarCardGrid';
import { useDataTableScrollHeight } from '../../composables/useDataTableScrollHeight';
import { useVrcxVueTable } from '../../lib/table/useVrcxVueTable';
import ImageCropDialog from '../../components/dialogs/ImageCropDialog.vue';
import ManageTagsDialog from './ManageTagsDialog.vue';
import MyAvatarCard from './components/MyAvatarCard.vue';
import configRepository from '../../service/config.js';
const { t } = useI18n();
const appearanceSettingsStore = useAppearanceSettingsStore();
@@ -241,6 +346,9 @@
const avatars = ref([]);
const avatarTagsMap = ref(new Map());
const imageUploadInput = ref(null);
const viewMode = ref('table');
const gridScrollRef = ref(null);
const gridContainerRefEl = ref(null);
const cropDialogOpen = ref(false);
const cropDialogFile = ref(null);
const changeImageAvatarRef = ref(null);
@@ -572,6 +680,69 @@
const currentAvatarId = computed(() => currentUser.value?.currentAvatar);
// --- Grid view ---
const {
cardScale,
cardSpacing,
cardScalePercent,
cardSpacingPercent,
cardScaleValue,
cardSpacingValue,
scaleSlider,
spacingSlider,
gridContainerRef,
gridStyle,
chunkIntoRows,
estimateRowHeight,
updateContainerWidth
} = useAvatarCardGrid();
const gridRows = computed(() => chunkIntoRows(filteredAvatars.value, 'avatar-row'));
const virtualizer = useVirtualizer(
computed(() => ({
count: gridRows.value.length,
getScrollElement: () => gridScrollRef.value,
estimateSize: (index) => estimateRowHeight(gridRows.value[index]?.items?.length ?? 0),
overscan: 5
}))
);
const virtualItems = computed(() => {
const items = virtualizer.value?.getVirtualItems?.() ?? [];
return items.map((virtualItem) => ({
virtualItem,
row: gridRows.value[virtualItem.index]
}));
});
watch(gridContainerRefEl, (el) => {
gridContainerRef.value = el;
});
watch([cardScale, cardSpacing, gridRows], () => {
nextTick(() => {
updateContainerWidth();
virtualizer.value?.measure?.();
});
});
/**
*
* @param value
*/
function handleViewModeChange(value) {
if (value) {
viewMode.value = value;
configRepository.setString('VRCX_MyAvatarsViewMode', value);
if (value === 'grid') {
nextTick(() => {
updateContainerWidth();
virtualizer.value?.measure?.();
});
}
}
}
const columns = getColumns({
onShowAvatarDialog: handleShowAvatarDialog,
onContextMenuAction: handleContextMenuAction,
@@ -669,6 +840,17 @@
});
}
onBeforeMount(async () => {
try {
const storedMode = await configRepository.getString('VRCX_MyAvatarsViewMode', 'table');
if (storedMode === 'grid' || storedMode === 'table') {
viewMode.value = storedMode;
}
} catch (error) {
console.error('Failed to load view mode preference', error);
}
});
onMounted(() => {
refreshAvatars();
});

View File

@@ -0,0 +1,262 @@
<template>
<HoverCard :open-delay="500" :close-delay="100">
<HoverCardTrigger as="div">
<ContextMenu>
<ContextMenuTrigger as="div">
<div
class="avatar-card-wrapper rounded-lg transition-all duration-150 hover:-translate-y-0.5 hover:shadow-md"
@click="$emit('click')">
<Card
class="avatar-card flex flex-col gap-0 p-0 cursor-pointer overflow-hidden rounded-lg relative"
:class="isActive ? 'border-2 border-primary' : 'border border-border/50'">
<div class="w-full aspect-5/2 overflow-hidden bg-muted relative">
<img
v-if="avatar.thumbnailImageUrl"
:src="avatar.thumbnailImageUrl"
:alt="avatar.name"
loading="lazy"
decoding="async"
fetchpriority="low"
class="w-full h-full object-cover block" />
<div v-else class="w-full h-full grid place-items-center">
<ImageIcon class="size-6 text-muted-foreground" />
</div>
<!-- Platform dots -->
<div
v-if="platformInfo.isQuest || platformInfo.isIos"
class="absolute top-1 right-1 flex -space-x-1">
<span
v-if="platformInfo.isPC"
class="size-2.5 rounded-full border opacity-70"
style="background: #0078d4" />
<span
v-if="platformInfo.isQuest"
class="size-2.5 rounded-full border opacity-70"
style="background: #3ddc84" />
<span
v-if="platformInfo.isIos"
class="size-2.5 rounded-full border opacity-70"
style="background: #8e8e93" />
</div>
</div>
<div
class="min-h-0 flex flex-col gap-0.5"
:style="{ padding: `${Math.round(6 * cardScale)}px ${Math.round(8 * cardScale)}px` }">
<span
class="block leading-snug overflow-hidden line-clamp-2 min-h-[2.75em]"
:style="{ fontSize: `${Math.max(9, Math.round(18 * cardScale))}px` }">
{{ avatar.name }}
</span>
<div
v-if="avatar.$tags?.length"
class="flex gap-0.5 overflow-hidden flex-nowrap"
:style="{ maxHeight: `${Math.max(14, Math.round(22 * cardScale))}px` }">
<Badge
v-for="tagEntry in avatar.$tags"
:key="tagEntry.tag"
variant="outline"
class="shrink-0 px-1 py-0 rounded-sm leading-tight"
:style="{
fontSize: `${Math.max(8, Math.round(14 * cardScale))}px`,
borderColor: getTagColor(tagEntry.tag).bg,
color: getTagColor(tagEntry.tag).text
}">
{{ tagEntry.tag }}
</Badge>
</div>
</div>
</Card>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem @click="emit('context-action', 'details', avatar)">
<Eye class="size-4" />
{{ t('dialog.avatar.actions.view_details') }}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem @click="emit('context-action', 'manageTags', avatar)">
<Tag class="size-4" />
{{ t('dialog.avatar.actions.manage_tags') }}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
@click="
emit(
'context-action',
avatar.releaseStatus === 'public' ? 'makePrivate' : 'makePublic',
avatar
)
">
<User class="size-4" />
{{
avatar.releaseStatus === 'public'
? t('dialog.avatar.actions.make_private')
: t('dialog.avatar.actions.make_public')
}}
</ContextMenuItem>
<ContextMenuItem @click="emit('context-action', 'rename', avatar)">
<Pencil class="size-4" />
{{ t('dialog.avatar.actions.rename') }}
</ContextMenuItem>
<ContextMenuItem @click="emit('context-action', 'changeDescription', avatar)">
<Pencil class="size-4" />
{{ t('dialog.avatar.actions.change_description') }}
</ContextMenuItem>
<ContextMenuItem @click="emit('context-action', 'changeTags', avatar)">
<Pencil class="size-4" />
{{ t('dialog.avatar.actions.change_content_tags') }}
</ContextMenuItem>
<ContextMenuItem @click="emit('context-action', 'changeStyles', avatar)">
<Pencil class="size-4" />
{{ t('dialog.avatar.actions.change_styles_author_tags') }}
</ContextMenuItem>
<ContextMenuItem @click="emit('context-action', 'changeImage', avatar)">
<ImageIcon class="size-4" />
{{ t('dialog.avatar.actions.change_image') }}
</ContextMenuItem>
<ContextMenuItem @click="emit('context-action', 'createImpostor', avatar)">
<RefreshCw class="size-4" />
{{ t('dialog.avatar.actions.create_impostor') }}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
</HoverCardTrigger>
<HoverCardContent class="w-80 p-3 text-sm" side="right" :side-offset="8" align="start">
<div class="flex flex-col gap-2">
<!-- Name -->
<div class="font-medium text-base truncate">{{ avatar.name }}</div>
<!-- Tags -->
<div v-if="avatar.$tags?.length" class="flex flex-wrap gap-1">
<Badge
v-for="tagEntry in avatar.$tags"
:key="tagEntry.tag"
variant="outline"
class="text-xs px-1.5 py-0"
:style="{
borderColor: getTagColor(tagEntry.tag).bg,
color: getTagColor(tagEntry.tag).text
}">
{{ tagEntry.tag }}
</Badge>
</div>
<Separator />
<!-- Info rows -->
<div class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
<span class="text-muted-foreground">{{ t('dialog.avatar.info.visibility') }}</span>
<span>
<Badge variant="outline" class="text-xs px-1.5 py-0">
{{
avatar.releaseStatus === 'public'
? t('dialog.avatar.tags.public')
: t('dialog.avatar.tags.private')
}}
</Badge>
</span>
<span class="text-muted-foreground">{{ t('dialog.avatar.info.version') }}</span>
<span>{{ avatar.version ?? '-' }}</span>
<span class="text-muted-foreground">{{ t('dialog.avatar.info.platform') }}</span>
<div class="flex items-center gap-1">
<Badge v-if="platformInfo.isPC" class="x-tag-platform-pc" variant="outline">
<Monitor class="h-3 w-3" />
</Badge>
<Badge v-if="platformInfo.isQuest" class="x-tag-platform-quest" variant="outline">
<Smartphone class="h-3 w-3" />
</Badge>
<Badge v-if="platformInfo.isIos" class="text-[#8e8e93] border-[#8e8e93]" variant="outline">
<Apple class="h-3 w-3" />
</Badge>
</div>
<template v-if="pcPerf">
<span class="text-muted-foreground">{{ t('dialog.avatar.info.pc_performance') }}</span>
<span>{{ pcPerf }}</span>
</template>
<template v-if="androidPerf">
<span class="text-muted-foreground">{{ t('dialog.avatar.info.android_performance') }}</span>
<span>{{ androidPerf }}</span>
</template>
<template v-if="iosPerf">
<span class="text-muted-foreground">{{ t('dialog.avatar.info.ios_performance') }}</span>
<span>{{ iosPerf }}</span>
</template>
<template v-if="avatar.$timeSpent">
<span class="text-muted-foreground">{{ t('dialog.avatar.info.time_spent') }}</span>
<span>{{ timeToText(avatar.$timeSpent) }}</span>
</template>
<span class="text-muted-foreground">{{ t('dialog.avatar.info.last_updated') }}</span>
<span>{{ formatDateFilter(avatar.updated_at, 'long') }}</span>
<span class="text-muted-foreground">{{ t('dialog.avatar.info.created_at') }}</span>
<span>{{ formatDateFilter(avatar.created_at, 'long') }}</span>
</div>
</div>
</HoverCardContent>
</HoverCard>
</template>
<script setup>
import { Apple, Eye, Image as ImageIcon, Monitor, Pencil, RefreshCw, Smartphone, Tag, User } from 'lucide-vue-next';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger
} from '@/components/ui/context-menu';
import { formatDateFilter, getAvailablePlatforms, getPlatformInfo, timeToText } from '@/shared/utils';
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';
import { Badge } from '@/components/ui/badge';
import { Card } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { computed } from 'vue';
import { getTagColor } from '@/shared/constants';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const props = defineProps({
avatar: {
type: Object,
required: true
},
currentAvatarId: {
type: String,
default: ''
},
cardScale: {
type: Number,
default: 0.6
}
});
const emit = defineEmits(['click', 'context-action']);
const isActive = computed(() => props.avatar.id === props.currentAvatarId);
const platformInfo = computed(() => getAvailablePlatforms(props.avatar.unityPackages));
const perfInfo = computed(() => getPlatformInfo(props.avatar.unityPackages));
const pcPerf = computed(() => perfInfo.value?.pc?.performanceRating ?? '');
const androidPerf = computed(() => perfInfo.value?.android?.performanceRating ?? '');
const iosPerf = computed(() => perfInfo.value?.ios?.performanceRating ?? '');
</script>
<style scoped>
.avatar-card img {
filter: saturate(0.8) contrast(0.8);
transition: filter 0.2s ease;
}
.avatar-card:hover img {
filter: saturate(1) contrast(1);
}
</style>

View File

@@ -0,0 +1,313 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../../../service/config.js', () => ({
default: {
getString: vi.fn().mockResolvedValue('0.6'),
setString: vi.fn()
}
}));
import { useAvatarCardGrid } from '../useAvatarCardGrid';
/**
*
* @param options
*/
function createGrid(options = {}) {
return useAvatarCardGrid(options);
}
describe('useAvatarCardGrid', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('defaults', () => {
it('returns default scale of 0.6', () => {
const { cardScale } = createGrid();
expect(cardScale.value).toBe(0.6);
});
it('returns default spacing of 1', () => {
const { cardSpacing } = createGrid();
expect(cardSpacing.value).toBe(1);
});
it('exposes scale/spacing percentage', () => {
const { cardScalePercent, cardSpacingPercent } = createGrid();
expect(cardScalePercent.value).toBe(60);
expect(cardSpacingPercent.value).toBe(100);
});
});
describe('scale clamping', () => {
it('clamps scale to min', () => {
const { cardScale } = createGrid();
cardScale.value = 0.1;
expect(cardScale.value).toBe(0.3);
});
it('clamps scale to max', () => {
const { cardScale } = createGrid();
cardScale.value = 2.0;
expect(cardScale.value).toBe(0.9);
});
it('handles NaN gracefully', () => {
const { cardScale } = createGrid();
cardScale.value = NaN;
// NaN → Number(NaN) || 1 = 1, clamped to max 0.9
expect(cardScale.value).toBeLessThanOrEqual(0.9);
expect(cardScale.value).toBeGreaterThanOrEqual(0.3);
});
it('uses custom scale range', () => {
const { cardScale } = createGrid({ scaleMin: 0.5, scaleMax: 0.8 });
cardScale.value = 0.2;
expect(cardScale.value).toBe(0.5);
cardScale.value = 1.0;
expect(cardScale.value).toBe(0.8);
});
});
describe('spacing clamping', () => {
it('clamps spacing to min', () => {
const { cardSpacing } = createGrid();
cardSpacing.value = 0.1;
expect(cardSpacing.value).toBe(0.5);
});
it('clamps spacing to max', () => {
const { cardSpacing } = createGrid();
cardSpacing.value = 5;
expect(cardSpacing.value).toBe(1.5);
});
});
describe('slider v-model helpers', () => {
it('cardScaleValue returns array', () => {
const { cardScaleValue } = createGrid();
expect(cardScaleValue.value).toEqual([0.6]);
});
it('cardScaleValue setter updates scale', () => {
const { cardScaleValue, cardScale } = createGrid();
cardScaleValue.value = [0.7];
expect(cardScale.value).toBe(0.7);
});
it('cardSpacingValue returns array', () => {
const { cardSpacingValue } = createGrid();
expect(cardSpacingValue.value).toEqual([1]);
});
it('cardSpacingValue setter updates spacing', () => {
const { cardSpacingValue, cardSpacing } = createGrid();
cardSpacingValue.value = [0.8];
expect(cardSpacing.value).toBe(0.8);
});
});
describe('getGridMetrics', () => {
it('returns 1 column when containerWidth is 0', () => {
const { getGridMetrics } = createGrid();
const result = getGridMetrics(10);
expect(result.columns).toBe(1);
});
it('returns correct minWidth based on scale', () => {
const { getGridMetrics, cardScale } = createGrid();
// default scale 0.6, baseCardWidth 200 → 120
const result = getGridMetrics(1);
expect(result.minWidth).toBe(200 * cardScale.value);
});
it('returns correct gap based on spacing', () => {
const { getGridMetrics } = createGrid();
// default spacing 1, baseGap 12 → max(4, 12) = 12
const result = getGridMetrics(1);
expect(result.gap).toBe(12);
});
it('gap is at least 4', () => {
const { getGridMetrics, cardSpacing } = createGrid();
cardSpacing.value = 0.5;
// spacing 0.5, baseGap 12 → max(4, 6) = 6
const result = getGridMetrics(1);
expect(result.gap).toBeGreaterThanOrEqual(4);
});
it('handles count=0 gracefully', () => {
const { getGridMetrics } = createGrid();
const result = getGridMetrics(0);
expect(result.columns).toBe(1);
});
it('handles negative count gracefully', () => {
const { getGridMetrics } = createGrid();
const result = getGridMetrics(-5);
expect(result.columns).toBe(1);
});
});
describe('chunkIntoRows', () => {
it('returns empty array for empty input', () => {
const { chunkIntoRows } = createGrid();
expect(chunkIntoRows([])).toEqual([]);
});
it('returns empty array for null input', () => {
const { chunkIntoRows } = createGrid();
expect(chunkIntoRows(null)).toEqual([]);
});
it('returns empty array for non-array input', () => {
const { chunkIntoRows } = createGrid();
expect(chunkIntoRows('not-array')).toEqual([]);
});
it('chunks items into rows with key prefix', () => {
const { chunkIntoRows } = createGrid();
// containerWidth=0 → 1 column → each item is its own row
const items = [{ id: 1 }, { id: 2 }, { id: 3 }];
const rows = chunkIntoRows(items, 'test');
expect(rows).toHaveLength(3);
expect(rows[0].key).toBe('test:0');
expect(rows[0].items).toEqual([{ id: 1 }]);
expect(rows[1].key).toBe('test:1');
expect(rows[2].key).toBe('test:2');
});
it('uses default key prefix', () => {
const { chunkIntoRows } = createGrid();
const rows = chunkIntoRows([{ id: 1 }]);
expect(rows[0].key).toBe('row:0');
});
});
describe('estimateRowHeight', () => {
it('returns positive height for any item count', () => {
const { estimateRowHeight } = createGrid();
expect(estimateRowHeight(1)).toBeGreaterThan(0);
expect(estimateRowHeight(10)).toBeGreaterThan(0);
expect(estimateRowHeight(100)).toBeGreaterThan(0);
});
it('returns positive height for 0 items', () => {
const { estimateRowHeight } = createGrid();
expect(estimateRowHeight(0)).toBeGreaterThan(0);
});
it('height increases with more rows', () => {
const { estimateRowHeight } = createGrid();
// containerWidth=0 → 1 column → height scales with count
const h1 = estimateRowHeight(1);
const h5 = estimateRowHeight(5);
expect(h5).toBeGreaterThan(h1);
});
it('height changes with scale', () => {
const { estimateRowHeight, cardScale } = createGrid();
const h1 = estimateRowHeight(5);
cardScale.value = 0.9;
const h2 = estimateRowHeight(5);
expect(h2).toBeGreaterThan(h1);
});
});
describe('gridStyle', () => {
it('returns a function', () => {
const { gridStyle } = createGrid();
expect(typeof gridStyle.value).toBe('function');
});
it('returns CSS variable object', () => {
const { gridStyle } = createGrid();
const style = gridStyle.value(10);
expect(style).toHaveProperty('--avatar-card-min-width');
expect(style).toHaveProperty('--avatar-card-gap');
expect(style).toHaveProperty('--avatar-card-target-width');
expect(style).toHaveProperty('--avatar-grid-columns');
});
it('CSS variables contain px units for widths', () => {
const { gridStyle } = createGrid();
const style = gridStyle.value(1);
expect(style['--avatar-card-min-width']).toMatch(/^\d+px$/);
expect(style['--avatar-card-gap']).toMatch(/^\d+px$/);
expect(style['--avatar-card-target-width']).toMatch(/^\d+px$/);
});
it('columns is a plain number string', () => {
const { gridStyle } = createGrid();
const style = gridStyle.value(1);
expect(style['--avatar-grid-columns']).toMatch(/^\d+$/);
});
});
describe('persistence', () => {
it('calls configRepository.setString when scale changes', async () => {
const config = (await import('../../../../service/config.js'))
.default;
const { cardScale } = createGrid();
cardScale.value = 0.7;
expect(config.setString).toHaveBeenCalledWith(
'VRCX_MyAvatarsCardScale',
'0.7'
);
});
it('calls configRepository.setString when spacing changes', async () => {
const config = (await import('../../../../service/config.js'))
.default;
const { cardSpacing } = createGrid();
cardSpacing.value = 0.8;
expect(config.setString).toHaveBeenCalledWith(
'VRCX_MyAvatarsCardSpacing',
'0.8'
);
});
it('uses custom config keys', async () => {
const config = (await import('../../../../service/config.js'))
.default;
const { cardScale } = createGrid({
scaleConfigKey: 'CUSTOM_SCALE'
});
cardScale.value = 0.5;
expect(config.setString).toHaveBeenCalledWith(
'CUSTOM_SCALE',
'0.5'
);
});
});
describe('slider metadata', () => {
it('exposes scaleSlider with defaults', () => {
const { scaleSlider } = createGrid();
expect(scaleSlider.min).toBe(0.3);
expect(scaleSlider.max).toBe(0.9);
expect(scaleSlider.step).toBe(0.01);
});
it('exposes spacingSlider with defaults', () => {
const { spacingSlider } = createGrid();
expect(spacingSlider.min).toBe(0.5);
expect(spacingSlider.max).toBe(1.5);
expect(spacingSlider.step).toBe(0.05);
});
it('accepts custom slider options', () => {
const { scaleSlider, spacingSlider } = createGrid({
scaleMin: 0.1,
scaleMax: 2.0,
scaleStep: 0.1,
spacingMin: 0.2,
spacingMax: 3.0,
spacingStep: 0.1
});
expect(scaleSlider).toEqual({ min: 0.1, max: 2.0, step: 0.1 });
expect(spacingSlider).toEqual({ min: 0.2, max: 3.0, step: 0.1 });
});
});
});

View File

@@ -0,0 +1,289 @@
import {
computed,
nextTick,
onBeforeMount,
onBeforeUnmount,
ref,
watch
} from 'vue';
import configRepository from '../../../service/config.js';
/**
*
* @param value
* @param min
* @param max
*/
function clamp(value, min, max) {
if (Number.isNaN(value)) return min;
return Math.min(max, Math.max(min, value));
}
/**
* @param options
*/
export function useAvatarCardGrid(options = {}) {
const scaleSlider = {
min: options.scaleMin ?? 0.3,
max: options.scaleMax ?? 0.9,
step: options.scaleStep ?? 0.01
};
const spacingSlider = {
min: options.spacingMin ?? 0.5,
max: options.spacingMax ?? 1.5,
step: options.spacingStep ?? 0.05
};
const baseCardWidth = options.baseCardWidth ?? 200;
const baseGap = options.baseGap ?? 12;
const baseCardHeight = options.baseCardHeight ?? 200;
const scaleConfigKey = options.scaleConfigKey ?? 'VRCX_MyAvatarsCardScale';
const spacingConfigKey =
options.spacingConfigKey ?? 'VRCX_MyAvatarsCardSpacing';
const cardScaleBase = ref(0.6);
const cardSpacingBase = ref(1);
const gridContainerRef = ref(null);
const containerWidth = ref(0);
let resizeObserver;
let cleanupResize;
const updateContainerWidth = (el) => {
const element = el ?? gridContainerRef.value;
if (!element) {
containerWidth.value = 0;
return;
}
containerWidth.value = Math.max(
element.clientWidth ?? element.offsetWidth ?? 0,
0
);
};
const disconnectResize = () => {
if (resizeObserver) {
resizeObserver.disconnect();
resizeObserver = undefined;
}
if (cleanupResize) {
cleanupResize();
cleanupResize = undefined;
}
};
const cardScale = computed({
get: () => cardScaleBase.value,
set: (value) => {
const next = clamp(
Number(value) || 1,
scaleSlider.min,
scaleSlider.max
);
cardScaleBase.value = next;
configRepository.setString(scaleConfigKey, String(next));
}
});
const cardSpacing = computed({
get: () => cardSpacingBase.value,
set: (value) => {
const next = clamp(
Number(value) || 1,
spacingSlider.min,
spacingSlider.max
);
cardSpacingBase.value = next;
configRepository.setString(spacingConfigKey, String(next));
}
});
const cardScalePercent = computed(() => Math.round(cardScale.value * 100));
const cardSpacingPercent = computed(() =>
Math.round(cardSpacing.value * 100)
);
// Slider v-model helpers (shadcn Slider expects array)
const cardScaleValue = computed({
get: () => [cardScale.value],
set: (value) => {
const next = value?.[0];
if (typeof next === 'number') cardScale.value = next;
}
});
const cardSpacingValue = computed({
get: () => [cardSpacing.value],
set: (value) => {
const next = value?.[0];
if (typeof next === 'number') cardSpacing.value = next;
}
});
/**
* @param count
*/
const getGridMetrics = (count = 1) => {
const scale = cardScale.value;
const spacing = cardSpacing.value;
const minWidth = baseCardWidth * scale;
const gap = Math.max(4, baseGap * spacing);
const width = Math.max(containerWidth.value, 0);
const itemCount = Math.max(Number(count) || 0, 0);
const safeCount = itemCount > 0 ? itemCount : 1;
const maxColumns =
width > 0
? Math.max(1, Math.floor((width + gap) / (minWidth + gap)) || 1)
: 1;
const columns = Math.max(1, Math.min(safeCount, maxColumns));
// Stretch cards to fill available width
let cardWidth = minWidth;
if (itemCount >= maxColumns && columns > 0) {
const columnsWidth = width - gap * (columns - 1);
const rawWidth =
columnsWidth > 0 ? columnsWidth / columns : minWidth;
if (Number.isFinite(rawWidth) && rawWidth > 0) {
cardWidth = Math.max(minWidth, rawWidth);
}
}
return { minWidth, gap, columns, cardWidth };
};
/**
*/
const gridStyle = computed(() => {
const scale = cardScale.value;
const spacing = cardSpacing.value;
const minWidth = baseCardWidth * scale;
const gap = Math.max(4, baseGap * spacing);
return (count = 1) => {
const { columns, cardWidth } = getGridMetrics(count);
return {
'--avatar-card-min-width': `${Math.round(minWidth)}px`,
'--avatar-card-gap': `${Math.round(gap)}px`,
'--avatar-card-target-width': `${Math.round(cardWidth)}px`,
'--avatar-grid-columns': `${columns}`
};
};
});
/**
* @param items
* @param keyPrefix
*/
const chunkIntoRows = (items, keyPrefix = 'row') => {
if (!Array.isArray(items) || !items.length) return [];
const { columns } = getGridMetrics(items.length);
const safeColumns = Math.max(1, columns);
const rows = [];
for (let i = 0; i < items.length; i += safeColumns) {
rows.push({
key: `${keyPrefix}:${i}`,
items: items.slice(i, i + safeColumns)
});
}
return rows;
};
/**
* @param itemCount
*/
const estimateRowHeight = (itemCount = 0) => {
const scale = cardScale.value;
const spacing = cardSpacing.value;
const { columns, gap } = getGridMetrics(itemCount);
const safeColumns = Math.max(1, columns);
const rowCount = Math.max(1, Math.ceil(itemCount / safeColumns));
// Card height = image (aspect 4:3 of width) + name area
const cardHeight = baseCardHeight * scale * spacing;
return rowCount * cardHeight + (rowCount - 1) * gap + 4;
};
// Watch container ref for resize
watch(
gridContainerRef,
(element) => {
disconnectResize();
if (!element) {
containerWidth.value = 0;
return;
}
nextTick(() => updateContainerWidth(element));
if (typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver((entries) => {
if (!entries?.length) return;
const [entry] = entries;
containerWidth.value = Math.max(
entry.contentRect?.width ?? element.clientWidth ?? 0,
0
);
});
resizeObserver.observe(element);
return;
}
if (typeof window !== 'undefined') {
const handleResize = () => updateContainerWidth(element);
window.addEventListener('resize', handleResize, {
passive: true
});
cleanupResize = () =>
window.removeEventListener('resize', handleResize);
}
},
{ immediate: true }
);
onBeforeUnmount(() => disconnectResize());
onBeforeMount(async () => {
try {
const [storedScale, storedSpacing] = await Promise.all([
configRepository.getString(scaleConfigKey, '0.6'),
configRepository.getString(spacingConfigKey, '1')
]);
const parsedScale = parseFloat(storedScale);
if (!Number.isNaN(parsedScale)) {
cardScaleBase.value = clamp(
parsedScale,
scaleSlider.min,
scaleSlider.max
);
}
const parsedSpacing = parseFloat(storedSpacing);
if (!Number.isNaN(parsedSpacing)) {
cardSpacingBase.value = clamp(
parsedSpacing,
spacingSlider.min,
spacingSlider.max
);
}
} catch (error) {
console.error('Failed to load avatar card grid preferences', error);
}
});
return {
cardScale,
cardSpacing,
cardScalePercent,
cardSpacingPercent,
cardScaleValue,
cardSpacingValue,
scaleSlider,
spacingSlider,
gridContainerRef,
gridStyle,
getGridMetrics,
chunkIntoRows,
estimateRowHeight,
updateContainerWidth
};
}