mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-04-18 06:13:52 +02:00
fead: add my avatar grid view
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
262
src/views/MyAvatars/components/MyAvatarCard.vue
Normal file
262
src/views/MyAvatars/components/MyAvatarCard.vue
Normal 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>
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
289
src/views/MyAvatars/composables/useAvatarCardGrid.js
Normal file
289
src/views/MyAvatars/composables/useAvatarCardGrid.js
Normal 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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user