mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-05-06 22:46:06 +02:00
fead: add my avatar grid view
This commit is contained in:
+295
-113
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user