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
+295 -113
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();
});