refactor favorites tab

This commit is contained in:
pa
2026-03-09 12:37:49 +09:00
parent cd832fb96a
commit bc5db58b89
19 changed files with 1590 additions and 2869 deletions

View File

@@ -101,7 +101,12 @@ export default defineConfig([
}
},
jsdoc({
config: 'flat/recommended'
config: 'flat/recommended',
rules: {
'jsdoc/require-param-description': 'off',
'jsdoc/require-returns-description': 'off',
'jsdoc/reject-function-type': 'off'
}
}),
{
ignores: [

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -10,9 +10,9 @@
<img v-if="localFavFakeRef.thumbnailImageUrl" :src="smallThumbnail" loading="lazy" />
</div>
<div class="favorites-search-card__detail">
<div class="favorites-search-card__title">
<div class="flex items-center gap-2">
<span class="name text-sm">{{ localFavFakeRef.name }}</span>
<span class="favorites-search-card__badges">
<span class="inline-flex items-center gap-1 text-sm">
<TooltipWrapper
v-if="favorite.deleted"
side="top"
@@ -34,23 +34,21 @@
<template v-if="editMode">
<div
v-if="!isLocalFavorite"
class="favorites-search-card__action favorites-search-card__action--checkbox"
class="flex justify-end w-full favorites-search-card__action--checkbox"
@click.stop>
<Checkbox v-model="isSelected" />
</div>
<div class="favorites-search-card__action-group">
<div
class="favorites-search-card__action favorites-search-card__action--full"
@click.stop>
<div class="flex gap-[var(--favorites-card-action-group-gap,8px)] w-full">
<div class="flex justify-end w-full flex-1" @click.stop>
<FavoritesMoveDropdown
:favoriteGroup="favoriteAvatarGroups"
:currentFavorite="props.favorite"
:currentGroup="group"
class="favorites-search-card__dropdown"
class="w-full"
:is-local-favorite="isLocalFavorite"
type="avatar" />
</div>
<div class="favorites-search-card__action">
<div class="flex justify-end w-full">
<TooltipWrapper
side="left"
:content="
@@ -70,8 +68,8 @@
</div>
</template>
<template v-else>
<div class="favorites-search-card__action-group">
<div class="favorites-search-card__action" v-if="canSelectAvatar">
<div class="flex gap-(--favorites-card-action-group-gap,8px) w-full">
<div class="flex justify-end w-full" v-if="canSelectAvatar">
<TooltipWrapper side="top" :content="t('view.favorite.select_avatar_tooltip')">
<Button
size="icon-sm"
@@ -83,7 +81,7 @@
/></Button>
</TooltipWrapper>
</div>
<div class="favorites-search-card__action">
<div class="flex justify-end w-full">
<TooltipWrapper
v-if="showDangerUnfavorite"
side="bottom"
@@ -121,7 +119,7 @@
</div>
</div>
<div class="favorites-search-card__actions">
<div class="favorites-search-card__action">
<div class="flex justify-end w-full">
<Button
class="rounded-full text-xs h-6 w-6"
size="icon-sm"
@@ -238,13 +236,6 @@
}
</script>
<style scoped>
.favorites-search-card img {
filter: saturate(0.8) contrast(0.8);
transition: filter 0.2s ease;
}
.favorites-search-card:hover img {
filter: saturate(1) contrast(1);
}
<style>
@import './favorites-card.css';
</style>

View File

@@ -7,15 +7,15 @@
<img v-if="favorite.thumbnailImageUrl" :src="smallThumbnail" loading="lazy" />
</div>
<div class="favorites-search-card__detail">
<div class="favorites-search-card__title">
<div class="flex items-center gap-2">
<span class="name text-sm">{{ favorite.name }}</span>
</div>
<span class="text-xs">{{ favorite.authorName }}</span>
</div>
</div>
<div class="favorites-search-card__actions">
<div class="favorites-search-card__action-group">
<div class="favorites-search-card__action">
<div class="flex gap-(--favorites-card-action-group-gap,8px) w-full">
<div class="flex justify-end w-full">
<TooltipWrapper side="top" :content="t('view.favorite.select_avatar_tooltip')">
<Button
size="icon-sm"
@@ -28,7 +28,7 @@
>
</TooltipWrapper>
</div>
<div class="favorites-search-card__action">
<div class="flex justify-end w-full">
<TooltipWrapper side="bottom" :content="t('view.favorite.edit_favorite_tooltip')">
<Button
v-if="favoriteExists"
@@ -99,13 +99,6 @@
});
</script>
<style scoped>
.favorites-search-card img {
filter: saturate(0.8) contrast(0.8);
transition: filter 0.2s ease;
}
.favorites-search-card:hover img {
filter: saturate(1) contrast(1);
}
<style>
@import './favorites-card.css';
</style>

View File

@@ -0,0 +1,54 @@
<template>
<div class="flex items-center justify-between gap-3 mb-3">
<div class="flex flex-col gap-0.5 text-base font-semibold pl-0.5 [&_small]:text-xs [&_small]:font-normal">
<slot name="title" />
</div>
<div class="flex items-center gap-2 text-[13px]">
<span>{{ t('view.favorite.edit_mode') }}</span>
<Switch
:model-value="editMode"
:disabled="editModeDisabled"
@update:modelValue="$emit('update:editMode', $event)" />
</div>
</div>
<div class="flex items-center justify-end">
<div v-if="editModeVisible" class="flex flex-wrap gap-2 mb-3">
<Button size="sm" variant="outline" @click="$emit('toggle-select-all')">
{{ isAllSelected ? t('view.favorite.deselect_all') : t('view.favorite.select_all') }}
</Button>
<Button size="sm" variant="secondary" :disabled="!hasSelection" @click="$emit('clear-selection')">
{{ t('view.favorite.clear') }}
</Button>
<Button
v-if="showCopyButton"
size="sm"
variant="outline"
:disabled="!hasSelection"
@click="$emit('copy-selection')">
{{ t('view.favorite.copy') }}
</Button>
<Button size="sm" variant="outline" :disabled="!hasSelection" @click="$emit('bulk-unfavorite')">
{{ t('view.favorite.bulk_unfavorite') }}
</Button>
</div>
</div>
</template>
<script setup>
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { useI18n } from 'vue-i18n';
defineProps({
editMode: { type: Boolean, default: false },
editModeDisabled: { type: Boolean, default: false },
editModeVisible: { type: Boolean, default: false },
isAllSelected: { type: Boolean, default: false },
hasSelection: { type: Boolean, default: false },
showCopyButton: { type: Boolean, default: true }
});
defineEmits(['update:editMode', 'toggle-select-all', 'clear-selection', 'copy-selection', 'bulk-unfavorite']);
const { t } = useI18n();
</script>

View File

@@ -6,10 +6,10 @@
<img :src="userImage(favorite.ref, true)" loading="lazy" />
</div>
<div class="favorites-search-card__detail">
<div class="favorites-search-card__title">
<div class="flex items-center gap-2">
<span class="name text-sm" :style="displayNameStyle">{{ favorite.ref.displayName }}</span>
</div>
<div v-if="favorite.ref.location !== 'offline'" class="favorites-search-card__location">
<div v-if="favorite.ref.location !== 'offline'" class="text-xs truncate">
<Location
:location="favorite.ref.location"
:traveling="favorite.ref.travelingToLocation"
@@ -20,22 +20,19 @@
</div>
<div class="favorites-search-card__actions">
<template v-if="editMode">
<div class="favorites-search-card__action favorites-search-card__action--checkbox" @click.stop>
<div class="flex justify-end w-full favorites-search-card__action--checkbox" @click.stop>
<Checkbox v-model="isSelected" />
</div>
<div class="favorites-search-card__action-group">
<div
v-if="group?.type !== 'local'"
class="favorites-search-card__action favorites-search-card__action--full"
@click.stop>
<div class="flex gap-[var(--favorites-card-action-group-gap,8px)] w-full">
<div v-if="group?.type !== 'local'" class="flex justify-end w-full flex-1" @click.stop>
<FavoritesMoveDropdown
:favoriteGroup="favoriteFriendGroups"
:currentGroup="group"
:currentFavorite="favorite"
class="favorites-search-card__dropdown"
class="w-full"
type="friend" />
</div>
<div class="favorites-search-card__action">
<div class="flex justify-end w-full">
<TooltipWrapper side="left" :content="t('view.favorite.unfavorite_tooltip')">
<Button
size="icon-sm"
@@ -49,7 +46,7 @@
</div>
</template>
<template v-else>
<div class="favorites-search-card__action">
<div class="flex justify-end w-full">
<TooltipWrapper side="right" :content="t('view.favorite.edit_favorite_tooltip')">
<Button
size="icon-sm"
@@ -71,7 +68,7 @@
</div>
</div>
<div class="favorites-search-card__actions">
<div class="favorites-search-card__action">
<div class="flex justify-end w-full">
<Button
class="rounded-full text-xs h-6 w-6"
size="icon-sm"
@@ -135,6 +132,9 @@
return {};
});
/**
* @returns {void}
*/
function handleDeleteFavorite() {
if (props.group?.type === 'local') {
removeLocalFriendFavorite(props.favorite.id, props.group.key);
@@ -146,13 +146,6 @@
}
</script>
<style scoped>
.favorites-search-card img {
filter: saturate(0.8) contrast(0.8);
transition: filter 0.2s ease;
}
.favorites-search-card:hover img {
filter: saturate(1) contrast(1);
}
<style>
@import './favorites-card.css';
</style>

View File

@@ -5,7 +5,7 @@
><ArrowLeft class="h-4 w-4"
/></Button>
</DropdownMenuTrigger>
<DropdownMenuContent class="favorites-dropdown">
<DropdownMenuContent class="p-2">
<span style="font-weight: bold; display: block; text-align: center">
{{ t(tooltipContent) }}
</span>

View File

@@ -0,0 +1,118 @@
<template>
<div class="flex items-center justify-between gap-3 mb-3 flex-wrap">
<div>
<Select :model-value="sortFavorites" @update:modelValue="$emit('update:sortFavorites', $event)">
<SelectTrigger size="sm" class="min-w-[200px]">
<span class="flex items-center gap-2">
<ArrowUpDown class="h-4 w-4" />
<SelectValue :placeholder="t('view.settings.appearance.appearance.sort_favorite_by_name')" />
</span>
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem
:value="false"
:text-value="t('view.settings.appearance.appearance.sort_favorite_by_name')">
{{ t('view.settings.appearance.appearance.sort_favorite_by_name') }}
</SelectItem>
<SelectItem
:value="true"
:text-value="t('view.settings.appearance.appearance.sort_favorite_by_date')">
{{ t('view.settings.appearance.appearance.sort_favorite_by_date') }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
<div class="flex items-center gap-2 flex-1">
<InputGroupSearch
:model-value="searchQuery"
class="flex-1"
:placeholder="searchPlaceholder"
@update:modelValue="$emit('update:searchQuery', $event)"
@input="$emit('search')" />
<DropdownMenu :open="toolbarMenuOpen" @update:open="$emit('update:toolbarMenuOpen', $event)">
<DropdownMenuTrigger as-child>
<Button class="rounded-full" size="icon-sm" variant="ghost"><Ellipsis /></Button>
</DropdownMenuTrigger>
<DropdownMenuContent class="p-2">
<li class="list-none px-4 pt-3 pb-2 min-w-[220px] cursor-default" @click.stop>
<div class="flex items-center justify-between text-[13px] font-semibold mb-2">
<span>{{ t('view.friends_locations.scale') }}</span>
<span class="text-xs">{{ cardScalePercent }}%</span>
</div>
<Slider
:model-value="cardScaleValue"
class="px-1 pb-1"
:min="cardScaleSlider.min"
:max="cardScaleSlider.max"
:step="cardScaleSlider.step"
@update:modelValue="$emit('update:cardScaleValue', $event)" />
</li>
<li class="list-none px-4 pt-3 pb-2 min-w-[220px] cursor-default" @click.stop>
<div class="flex items-center justify-between text-[13px] font-semibold mb-2">
<span>{{ t('view.friends_locations.spacing') }}</span>
<span class="text-xs"> {{ cardSpacingPercent }}% </span>
</div>
<Slider
:model-value="cardSpacingValue"
class="px-1 pb-1"
:min="cardSpacingSlider.min"
:max="cardSpacingSlider.max"
:step="cardSpacingSlider.step"
@update:modelValue="$emit('update:cardSpacingValue', $event)" />
</li>
<DropdownMenuSeparator />
<DropdownMenuItem @click="$emit('import')">
{{ t('view.favorite.import') }}
</DropdownMenuItem>
<DropdownMenuItem @click="$emit('export')">
{{ t('view.favorite.export') }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</template>
<script setup>
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu';
import { ArrowUpDown, Ellipsis } from 'lucide-vue-next';
import { Button } from '@/components/ui/button';
import { InputGroupSearch } from '@/components/ui/input-group';
import { Slider } from '@/components/ui/slider';
import { useI18n } from 'vue-i18n';
defineProps({
sortFavorites: { type: Boolean, default: false },
searchQuery: { type: String, default: '' },
searchPlaceholder: { type: String, default: '' },
toolbarMenuOpen: { type: Boolean, default: false },
cardScaleValue: { type: Array, default: () => [50] },
cardScalePercent: { type: Number, default: 100 },
cardScaleSlider: { type: Object, default: () => ({ min: 0, max: 100, step: 1 }) },
cardSpacingValue: { type: Array, default: () => [50] },
cardSpacingPercent: { type: Number, default: 100 },
cardSpacingSlider: { type: Object, default: () => ({ min: 0, max: 100, step: 1 }) }
});
defineEmits([
'update:sortFavorites',
'update:searchQuery',
'update:toolbarMenuOpen',
'update:cardScaleValue',
'update:cardSpacingValue',
'search',
'import',
'export'
]);
const { t } = useI18n();
</script>

View File

@@ -2,75 +2,82 @@
<ContextMenu>
<ContextMenuTrigger as-child>
<div :class="cardClasses" @click="$emit('click')">
<template v-if="favorite.ref">
<template v-if="localFavRef?.name">
<div class="favorites-search-card__content">
<div
class="favorites-search-card__avatar"
:class="{ 'is-empty': !favorite.ref.thumbnailImageUrl }"
:class="{ 'is-empty': !localFavRef.thumbnailImageUrl }"
v-once>
<img
v-if="favorite.ref.thumbnailImageUrl"
v-if="localFavRef.thumbnailImageUrl"
:src="smallThumbnail"
loading="lazy"
decoding="async"
fetchpriority="low" />
</div>
<div class="favorites-search-card__detail">
<div class="favorites-search-card__title">
<span class="name text-sm">{{ props.favorite.ref.name }}</span>
<div class="flex items-center gap-2">
<span class="name text-sm">{{ localFavRef.name }}</span>
<span
v-if="favorite.deleted || favorite.ref.releaseStatus === 'private'"
class="favorites-search-card__badges">
v-if="
!isLocalFavorite &&
(favorite.deleted || localFavRef.releaseStatus === 'private')
"
class="inline-flex items-center gap-1 text-sm">
<AlertTriangle
v-if="favorite.deleted"
:title="t('view.favorite.unavailable_tooltip')"
class="h-4 w-4" />
<Lock
v-if="favorite.ref.releaseStatus === 'private'"
v-if="localFavRef.releaseStatus === 'private'"
:title="t('view.favorite.private')"
class="h-4 w-4" />
</span>
</div>
<span class="text-xs text-muted-foreground">
{{ props.favorite.ref.authorName }}
<template v-if="props.favorite.ref.occupants">
({{ props.favorite.ref.occupants }})
</template>
{{ localFavRef.authorName }}
<template v-if="localFavRef.occupants"> ({{ localFavRef.occupants }}) </template>
</span>
</div>
</div>
<div class="favorites-search-card__actions">
<template v-if="editMode">
<div
class="favorites-search-card__action favorites-search-card__action--checkbox"
v-if="!isLocalFavorite"
class="flex justify-end w-full favorites-search-card__action--checkbox"
@click.stop>
<Checkbox v-model="isSelected" />
</div>
<div class="favorites-search-card__action-group">
<div
class="favorites-search-card__action favorites-search-card__action--full"
@click.stop>
<div class="flex gap-[var(--favorites-card-action-group-gap,8px)] w-full">
<div class="flex justify-end w-full flex-1" @click.stop>
<FavoritesMoveDropdown
:favoriteGroup="favoriteWorldGroups"
:currentFavorite="props.favorite"
:currentGroup="group"
class="favorites-search-card__dropdown"
class="w-full"
:is-local-favorite="isLocalFavorite"
type="world" />
</div>
<div class="favorites-search-card__action">
<div class="flex justify-end w-full">
<Button
size="icon-sm"
variant="ghost"
:variant="
isLocalFavorite && shiftHeld
? 'destructive'
: isLocalFavorite
? 'outline'
: 'ghost'
"
class="rounded-full text-xs h-6 w-6"
@click.stop="handleDeleteFavorite">
@click.stop="handlePrimaryDeleteAction">
<Trash2 class="h-4 w-4" />
</Button>
</div>
</div>
</template>
<template v-else>
<div class="favorites-search-card__action-group">
<div class="favorites-search-card__action">
<div class="flex gap-[var(--favorites-card-action-group-gap,8px)] w-full">
<div class="flex justify-end w-full">
<TooltipWrapper side="top" :content="inviteOrLaunchText">
<Button
size="icon-sm"
@@ -81,7 +88,7 @@
/></Button>
</TooltipWrapper>
</div>
<div class="favorites-search-card__action">
<div class="flex justify-end w-full">
<TooltipWrapper
v-if="showDangerUnfavorite"
side="top"
@@ -117,17 +124,17 @@
<div class="favorites-search-card__detail" v-once>
<span>{{ favorite.name || favorite.id }}</span>
<AlertTriangle
v-if="favorite.deleted"
v-if="!isLocalFavorite && favorite.deleted"
:title="t('view.favorite.unavailable_tooltip')"
class="h-4 w-4" />
</div>
</div>
<div class="favorites-search-card__actions">
<div class="favorites-search-card__action">
<div class="flex justify-end w-full">
<Button
class="rounded-full text-xs h-6 w-6"
size="icon-sm"
variant="ghost"
:variant="isLocalFavorite ? 'outline' : 'ghost'"
@click.stop="handleDeleteFavorite">
<Trash2 class="h-4 w-4" />
</Button>
@@ -184,6 +191,8 @@
set: (value) => emit('toggle-select', value)
});
const localFavRef = computed(() => (props.isLocalFavorite ? props.favorite : props.favorite?.ref));
const showDangerUnfavorite = computed(() => {
return shiftHeld.value;
});
@@ -198,8 +207,8 @@
]);
const smallThumbnail = computed(() => {
const url = props.favorite.ref.thumbnailImageUrl?.replace('256', '128');
return url || props.favorite.ref.thumbnailImageUrl;
const url = localFavRef.value?.thumbnailImageUrl?.replace('256', '128');
return url || localFavRef.value?.thumbnailImageUrl;
});
const inviteOrLaunchText = computed(() => {
@@ -208,6 +217,21 @@
: t('dialog.world.actions.new_instance_and_self_invite');
});
/**
* @returns {void}
*/
function handlePrimaryDeleteAction() {
if (props.isLocalFavorite) {
if (shiftHeld.value) {
emit('remove-local-world-favorite', props.favorite.id, props.group);
return;
}
showFavoriteDialog('world', props.favorite.id);
return;
}
deleteFavorite(props.favorite.id);
}
/**
*
*/
@@ -237,13 +261,6 @@
}
</script>
<style scoped>
.favorites-search-card img {
filter: saturate(0.8) contrast(0.8);
transition: filter 0.2s ease;
}
.favorites-search-card:hover img {
filter: saturate(1) contrast(1);
}
<style>
@import './favorites-card.css';
</style>

View File

@@ -13,7 +13,7 @@
fetchpriority="low" />
</div>
<div class="favorites-search-card__detail">
<div class="favorites-search-card__title">
<div class="flex items-center gap-2">
<span class="name text-sm">{{ props.favorite.name }}</span>
</div>
<span class="text-xs">
@@ -24,18 +24,16 @@
</div>
<div class="favorites-search-card__actions">
<template v-if="editMode">
<div class="favorites-search-card__action-group">
<div
class="favorites-search-card__action favorites-search-card__action--full"
@click.stop>
<div class="flex gap-[var(--favorites-card-action-group-gap,8px)] w-full">
<div class="flex justify-end w-full flex-1" @click.stop>
<FavoritesMoveDropdown
:favoriteGroup="favoriteWorldGroups"
:currentFavorite="props.favorite"
class="favorites-search-card__dropdown"
class="w-full"
isLocalFavorite
type="world" />
</div>
<div class="favorites-search-card__action">
<div class="flex justify-end w-full">
<Button
size="icon-sm"
:variant="shiftHeld ? 'destructive' : 'outline'"
@@ -47,8 +45,8 @@
</div>
</template>
<template v-else>
<div class="favorites-search-card__action-group">
<div class="favorites-search-card__action">
<div class="flex gap-[var(--favorites-card-action-group-gap,8px)] w-full">
<div class="flex justify-end w-full">
<TooltipWrapper side="top" :content="inviteOrLaunchText">
<Button
size="icon-sm"
@@ -59,7 +57,7 @@
/></Button>
</TooltipWrapper>
</div>
<div class="favorites-search-card__action">
<div class="flex justify-end w-full">
<TooltipWrapper
v-if="showDangerUnfavorite"
side="top"
@@ -97,7 +95,7 @@
</div>
</div>
<div class="favorites-search-card__actions">
<div class="favorites-search-card__action">
<div class="flex justify-end w-full">
<Button
class="rounded-full text-xs h-6 w-6"
size="icon-sm"
@@ -173,7 +171,7 @@
});
/**
*
* @returns {void}
*/
function handlePrimaryDeleteAction() {
if (shiftHeld.value) {
@@ -200,13 +198,6 @@
}
</script>
<style scoped>
.favorites-search-card img {
filter: saturate(0.8) contrast(0.8);
transition: filter 0.2s ease;
}
.favorites-search-card:hover img {
filter: saturate(1) contrast(1);
}
<style>
@import './favorites-card.css';
</style>

View File

@@ -0,0 +1,175 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { ref } from 'vue';
const mocks = vi.hoisted(() => ({
favoriteWorldGroups: null,
shiftHeld: null,
showFavoriteDialog: vi.fn(),
deleteFavorite: vi.fn(),
newInstanceSelfInvite: vi.fn(),
createNewInstance: vi.fn()
}));
vi.mock('pinia', () => ({
storeToRefs: (store) => store
}));
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key) => key
})
}));
vi.mock('@/components/ui/context-menu', () => ({
ContextMenu: {
template: '<div><slot /></div>'
},
ContextMenuTrigger: {
template: '<div><slot /></div>'
},
ContextMenuContent: {
template: '<div><slot /></div>'
},
ContextMenuItem: {
emits: ['click'],
template: '<button @click="$emit(\'click\')"><slot /></button>'
}
}));
vi.mock('@/components/ui/button', () => ({
Button: {
emits: ['click'],
template:
'<button data-testid="btn" @click="$emit(\'click\', $event)"><slot /></button>'
}
}));
vi.mock('@/components/ui/checkbox', () => ({
Checkbox: {
props: ['modelValue'],
emits: ['update:modelValue'],
template:
'<input type="checkbox" :checked="modelValue" @change="$emit(\'update:modelValue\', $event.target.checked)" />'
}
}));
vi.mock('lucide-vue-next', () => ({
AlertTriangle: { template: '<i />' },
Lock: { template: '<i />' },
Mail: { template: '<i />' },
Plus: { template: '<i />' },
Star: { template: '<i />' },
Trash2: { template: '<i />' }
}));
vi.mock('../../../../stores', () => ({
useFavoriteStore: () => ({
favoriteWorldGroups: mocks.favoriteWorldGroups,
showFavoriteDialog: (...args) => mocks.showFavoriteDialog(...args)
}),
useInviteStore: () => ({
newInstanceSelfInvite: (...args) => mocks.newInstanceSelfInvite(...args),
canOpenInstanceInGame: false
}),
useInstanceStore: () => ({
createNewInstance: (...args) => mocks.createNewInstance(...args)
}),
useUiStore: () => ({
shiftHeld: mocks.shiftHeld
})
}));
vi.mock('../../../../api', () => ({
favoriteRequest: {
deleteFavorite: (...args) => mocks.deleteFavorite(...args)
}
}));
import FavoritesWorldItem from '../FavoritesWorldItem.vue';
/**
*
* @param props
*/
function mountItem(props = {}) {
return mount(FavoritesWorldItem, {
props: {
favorite: {
id: 'wrld_default',
name: 'Default World',
authorName: 'Author'
},
group: 'Favorites',
isLocalFavorite: true,
editMode: false,
...props
},
global: {
stubs: {
TooltipWrapper: {
template: '<div><slot /></div>'
},
FavoritesMoveDropdown: {
template: '<div />'
}
}
}
});
}
describe('FavoritesWorldItem.vue', () => {
beforeEach(() => {
mocks.favoriteWorldGroups = ref([]);
mocks.shiftHeld = ref(false);
mocks.showFavoriteDialog.mockReset();
mocks.deleteFavorite.mockReset();
mocks.newInstanceSelfInvite.mockReset();
mocks.createNewInstance.mockReset();
});
it('renders fallback text when local favorite has no name', () => {
const wrapper = mountItem({
favorite: {
id: 'wrld_missing_name'
}
});
expect(wrapper.text()).toContain('wrld_missing_name');
});
it('emits local remove event in fallback mode when delete is clicked', async () => {
const wrapper = mountItem({
favorite: {
id: 'wrld_missing_name'
},
group: 'LocalGroup'
});
await wrapper.get('[data-testid="btn"]').trigger('click');
expect(wrapper.emitted('remove-local-world-favorite')).toEqual([
['wrld_missing_name', 'LocalGroup']
]);
expect(mocks.deleteFavorite).not.toHaveBeenCalled();
});
it('opens local favorite dialog in edit mode when shift is not held', async () => {
const wrapper = mountItem({
favorite: {
id: 'wrld_local_1',
name: 'Local World',
authorName: 'Author'
},
editMode: true
});
await wrapper.get('[data-testid="btn"]').trigger('click');
expect(mocks.showFavoriteDialog).toHaveBeenCalledWith(
'world',
'wrld_local_1'
);
expect(wrapper.emitted('remove-local-world-favorite')).toBeUndefined();
});
});

View File

@@ -0,0 +1,119 @@
/**
* Shared card styles for all Favorites item components.
* FavoritesAvatar.vue, FavoritesFriend.vue, and FavoritesWorld.vue.
*/
.favorites-search-card {
display: flex;
align-items: center;
box-sizing: border-box;
border: 1px solid var(--border);
border-radius: calc(var(--radius-lg) * var(--favorites-card-scale, 1));
padding: var(--favorites-card-padding-y, 8px)
var(--favorites-card-padding-x, 8px);
cursor: pointer;
transition: background-color 0.15s ease;
width: 100%;
min-width: var(--favorites-card-min-width, 240px);
max-width: var(--favorites-card-target-width, 320px);
}
.favorites-search-card:hover {
background-color: var(--accent);
}
.favorites-search-card__content {
display: flex;
align-items: center;
gap: var(--favorites-card-content-gap, 8px);
flex: 1;
min-width: 0;
}
.favorites-search-card__avatar {
width: calc(48px * var(--favorites-card-scale, 1));
height: calc(48px * var(--favorites-card-scale, 1));
border-radius: calc(var(--radius-lg) * var(--favorites-card-scale, 1));
overflow: hidden;
flex-shrink: 0;
}
.favorites-search-card__avatar img {
width: 100%;
height: 100%;
object-fit: cover;
filter: saturate(0.8) contrast(0.8);
transition: filter 0.2s ease;
}
.favorites-search-card:hover .favorites-search-card__avatar img {
filter: saturate(1) contrast(1);
}
.favorites-search-card__avatar.is-empty {
background: repeating-linear-gradient(
-45deg,
rgba(148, 163, 184, 0.25),
rgba(148, 163, 184, 0.25) 10px,
rgba(255, 255, 255, 0.35) 10px,
rgba(255, 255, 255, 0.35) 20px
);
}
.favorites-search-card__detail {
display: flex;
flex-direction: column;
gap: 4px;
font-size: calc(13px * var(--favorites-card-scale, 1));
min-width: 0;
}
.favorites-search-card__detail .name {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.favorites-search-card__detail .extra {
font-size: calc(12px * var(--favorites-card-scale, 1));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.favorites-search-card__actions {
display: flex;
flex-direction: column;
gap: var(--favorites-card-action-gap, 8px);
margin-left: var(--favorites-card-action-margin, 8px);
align-items: center;
justify-content: center;
flex: 0 0 auto;
min-width: 48px;
}
.favorites-search-card__actions:empty {
display: none;
}
.favorites-search-card__action-group .favorites-search-card__action--full {
flex: 1;
}
.favorites-search-card__action--checkbox {
align-items: center;
justify-content: flex-end;
margin-right: var(--favorites-card-checkbox-margin, 8px);
}
.favorites-search-card__action--checkbox [data-slot='checkbox'] {
margin: 0;
}
/* Entity-specific overrides */
.favorites-search-card--avatar {
min-width: var(--favorites-card-min-width, 240px);
max-width: var(--favorites-card-target-width, 320px);
}

View File

@@ -0,0 +1,86 @@
import { describe, expect, it, vi } from 'vitest';
import { ref } from 'vue';
import { useFavoritesGroupPanel } from '../useFavoritesGroupPanel';
/**
*
* @param options
*/
function createPanel(options = {}) {
const remoteGroups = options.remoteGroups ?? ref([]);
const localGroups = options.localGroups ?? ref([]);
const localFavorites = options.localFavorites ?? ref({});
const clearSelection = options.clearSelection ?? vi.fn();
const placeholders = options.placeholders ?? [];
const hasHistory = options.hasHistory ?? false;
const historyItems = options.historyItems ?? null;
const panel = useFavoritesGroupPanel({
remoteGroups,
localGroups,
localFavorites,
clearSelection,
placeholders,
hasHistory,
historyItems
});
return {
panel,
remoteGroups,
localGroups,
localFavorites,
clearSelection,
historyItems
};
}
describe('useFavoritesGroupPanel', () => {
it('selects remote placeholder by default when remote groups are unresolved', () => {
const placeholders = [{ key: 'avatar:avatars1', displayName: 'Group 1' }];
const { panel, clearSelection } = createPanel({ placeholders });
panel.ensureSelectedGroup();
expect(panel.selectedGroup.value).toEqual({
type: 'remote',
key: 'avatar:avatars1'
});
expect(clearSelection).toHaveBeenCalledTimes(1);
});
it('falls back to another local group when selected local group is removed', () => {
const { panel, localGroups, clearSelection } = createPanel({
localGroups: ref(['A', 'B']),
localFavorites: ref({
A: [{ id: 'a1' }],
B: [{ id: 'b1' }]
})
});
panel.selectGroup('local', 'A', { userInitiated: true });
clearSelection.mockClear();
localGroups.value = ['B'];
panel.ensureSelectedGroup();
expect(panel.selectedGroup.value).toEqual({ type: 'local', key: 'B' });
expect(clearSelection).toHaveBeenCalledTimes(1);
});
it('falls back to history group when no remote/local groups are available', () => {
const historyItems = ref([{ id: 'avtr_1' }]);
const { panel } = createPanel({
hasHistory: true,
historyItems
});
panel.ensureSelectedGroup();
expect(panel.selectedGroup.value).toEqual({
type: 'history',
key: 'local-history'
});
});
});

View File

@@ -0,0 +1,206 @@
import { computed, ref } from 'vue';
/**
* @param {object} options
* @param {import('vue').Ref<Array>} options.remoteGroups - remote groups ref
* @param {import('vue').Ref<Array>} options.localGroups - local groups ref (string keys)
* @param {import('vue').Ref<object>} options.localFavorites - local favorites map { groupKey: items[] }
* @param {Function} options.clearSelection - callback to clear entity selection
* @param {Array} [options.placeholders] - placeholder groups when remote data not yet loaded
* @param {boolean} [options.hasHistory] - whether history group type is supported (Avatar only)
* @param {import('vue').Ref<Array>} [options.historyItems] - items for history group
* @returns {object}
*/
export function useFavoritesGroupPanel(options = {}) {
const {
remoteGroups,
localGroups,
localFavorites,
clearSelection,
placeholders = [],
hasHistory = false,
historyItems = null
} = options;
const selectedGroup = ref(null);
const activeGroupMenu = ref(null);
const hasUserSelectedGroup = ref(false);
const remoteGroupsResolved = ref(false);
const isRemoteGroupSelected = computed(
() => selectedGroup.value?.type === 'remote'
);
const isLocalGroupSelected = computed(
() => selectedGroup.value?.type === 'local'
);
const isHistorySelected = computed(
() => hasHistory && selectedGroup.value?.type === 'history'
);
const remoteGroupMenuKey = (key) => `remote:${key}`;
const localGroupMenuKey = (key) => `local:${key}`;
const activeRemoteGroup = computed(() => {
if (!isRemoteGroupSelected.value) {
return null;
}
return (
remoteGroups.value.find(
(group) => group.key === selectedGroup.value.key
) || null
);
});
const activeLocalGroupName = computed(() => {
if (!isLocalGroupSelected.value) {
return '';
}
return selectedGroup.value.key;
});
const activeLocalGroupCount = computed(() => {
if (!activeLocalGroupName.value) {
return 0;
}
const favorites = localFavorites.value[activeLocalGroupName.value];
return favorites ? favorites.length : 0;
});
/**
*
* @param key {string}
* @param visible {boolean}
*/
function handleGroupMenuVisible(key, visible) {
if (visible) {
activeGroupMenu.value = key;
return;
}
if (activeGroupMenu.value === key) {
activeGroupMenu.value = null;
}
}
/**
*
* @param type {string}
* @param key {string}
* @param options {object}
* @param opts {object}
*/
function selectGroup(type, key, opts = {}) {
if (
selectedGroup.value?.type === type &&
selectedGroup.value?.key === key
) {
return;
}
selectedGroup.value = { type, key };
if (opts.userInitiated) {
hasUserSelectedGroup.value = true;
}
clearSelection();
}
/**
*
* @param type {string}
* @param key {string}
* @returns {boolean}
*/
function isGroupActive(type, key) {
return (
selectedGroup.value?.type === type &&
selectedGroup.value?.key === key
);
}
/**
*
* @param group {object}
* @returns {boolean}
*/
function isGroupAvailable(group) {
if (!group) {
return false;
}
if (group.type === 'remote') {
if (placeholders.length && !remoteGroupsResolved.value) {
return true;
}
return remoteGroups.value.some((item) => item.key === group.key);
}
if (group.type === 'local') {
return localGroups.value.includes(group.key);
}
if (group.type === 'history' && hasHistory && historyItems) {
return historyItems.value.length > 0;
}
return false;
}
/**
* @returns {void}
*/
function selectDefaultGroup() {
if (!hasUserSelectedGroup.value && placeholders.length) {
const remote =
remoteGroups.value.find((group) => group.count > 0) ||
remoteGroups.value[0] ||
placeholders[0];
if (remote) {
selectGroup('remote', remote.key);
return;
}
} else if (remoteGroups.value.length) {
const remote =
remoteGroups.value.find((group) => group.count > 0) ||
remoteGroups.value[0];
if (remote) {
selectGroup('remote', remote.key);
return;
}
}
if (localGroups.value.length) {
selectGroup('local', localGroups.value[0]);
return;
}
if (hasHistory && historyItems && historyItems.value.length) {
selectGroup('history', 'local-history');
return;
}
selectedGroup.value = null;
clearSelection();
}
/**
* @returns {void}
*/
function ensureSelectedGroup() {
if (selectedGroup.value && isGroupAvailable(selectedGroup.value)) {
return;
}
selectDefaultGroup();
}
return {
selectedGroup,
activeGroupMenu,
hasUserSelectedGroup,
remoteGroupsResolved,
isRemoteGroupSelected,
isLocalGroupSelected,
isHistorySelected,
remoteGroupMenuKey,
localGroupMenuKey,
activeRemoteGroup,
activeLocalGroupName,
activeLocalGroupCount,
handleGroupMenuVisible,
selectGroup,
isGroupActive,
isGroupAvailable,
selectDefaultGroup,
ensureSelectedGroup
};
}

View File

@@ -0,0 +1,64 @@
import { nextTick, ref } from 'vue';
/**
* @param {object} options
* @param {Function} options.createGroup - store function to create a new local group
* @param {Function} options.selectGroup - function to select a group after creation
* @returns {object}
*/
export function useFavoritesLocalGroups(options = {}) {
const { createGroup, selectGroup, canCreate = () => true } = options;
const isCreatingLocalGroup = ref(false);
const newLocalGroupName = ref('');
const newLocalGroupInput = ref(null);
/**
* @returns {void}
*/
function startLocalGroupCreation() {
if (!canCreate() || isCreatingLocalGroup.value) {
return;
}
isCreatingLocalGroup.value = true;
newLocalGroupName.value = '';
nextTick(() => {
const el =
newLocalGroupInput.value?.$el ?? newLocalGroupInput.value;
el?.focus?.();
});
}
/**
* @returns {void}
*/
function cancelLocalGroupCreation() {
isCreatingLocalGroup.value = false;
newLocalGroupName.value = '';
}
/**
* @returns {void}
*/
function handleLocalGroupCreationConfirm() {
const name = newLocalGroupName.value.trim();
if (!name) {
cancelLocalGroupCreation();
return;
}
createGroup(name);
cancelLocalGroupCreation();
nextTick(() => {
selectGroup('local', name, { userInitiated: true });
});
}
return {
isCreatingLocalGroup,
newLocalGroupName,
newLocalGroupInput,
startLocalGroupCreation,
cancelLocalGroupCreation,
handleLocalGroupCreationConfirm
};
}

View File

@@ -0,0 +1,176 @@
import {
computed,
nextTick,
onBeforeMount,
onBeforeUnmount,
onMounted,
ref,
watch
} from 'vue';
import configRepository from '../../../service/config.js';
/**
* @param {object} options
* @param {string} options.configKey
* @param {number} [options.defaultSize]
* @param {number} [options.maxPx]
* @param {number} [options.minPx]
* @returns {object}
*/
export function useFavoritesSplitter(options = {}) {
const configKey = options.configKey ?? '';
const defaultSize = options.defaultSize ?? 260;
const maxPx = options.maxPx ?? 360;
const minPx = options.minPx ?? 0;
const splitterFallbackWidth =
typeof window !== 'undefined' && window.innerWidth
? window.innerWidth
: 1200;
const splitterSize = ref(defaultSize);
const splitterGroupRef = ref(null);
const splitterPanelRef = ref(null);
const splitterWidth = ref(splitterFallbackWidth);
const splitterDraggingCount = ref(0);
let splitterObserver = null;
/**
*
*/
async function loadSplitterPreferences() {
const storedSize = await configRepository.getString(
configKey,
String(defaultSize)
);
const parsedSize = Number(storedSize);
if (Number.isFinite(parsedSize) && parsedSize >= 0) {
splitterSize.value = parsedSize;
}
}
const getSplitterWidthRaw = () => {
const element = splitterGroupRef.value?.$el ?? splitterGroupRef.value;
const width = element?.getBoundingClientRect?.().width;
return Number.isFinite(width) ? width : null;
};
const getSplitterWidth = () => {
const width = getSplitterWidthRaw();
return Number.isFinite(width) && width > 0
? width
: splitterFallbackWidth;
};
const resolveDraggingPayload = (payload) => {
if (typeof payload === 'boolean') {
return payload;
}
if (payload && typeof payload === 'object') {
if (typeof payload.detail === 'boolean') {
return payload.detail;
}
if (typeof payload.dragging === 'boolean') {
return payload.dragging;
}
}
return Boolean(payload);
};
const setDragging = (payload) => {
const isDragging = resolveDraggingPayload(payload);
const next = splitterDraggingCount.value + (isDragging ? 1 : -1);
splitterDraggingCount.value = Math.max(0, next);
};
const pxToPercent = (px, groupWidth, min = 0) => {
const width = groupWidth ?? getSplitterWidth();
return Math.min(100, Math.max(min, (px / width) * 100));
};
const percentToPx = (percent, groupWidth) => (percent / 100) * groupWidth;
const defaultSizePercent = computed(() =>
pxToPercent(splitterSize.value, splitterWidth.value, 0)
);
const minSizePercent = computed(() =>
pxToPercent(minPx, splitterWidth.value, 0)
);
const maxSizePercent = computed(() =>
pxToPercent(maxPx, splitterWidth.value, 0)
);
const handleLayout = (sizes) => {
if (!Array.isArray(sizes) || !sizes.length) {
return;
}
if (splitterDraggingCount.value === 0) {
return;
}
const rawWidth = getSplitterWidthRaw();
if (!Number.isFinite(rawWidth) || rawWidth <= 0) {
return;
}
const nextSize = sizes[0];
if (!Number.isFinite(nextSize)) {
return;
}
const nextPx = Math.round(percentToPx(nextSize, rawWidth));
const clampedPx = Math.min(maxPx, Math.max(minPx, nextPx));
splitterSize.value = clampedPx;
configRepository.setString(configKey, clampedPx.toString());
};
const updateSplitterWidth = () => {
const width = getSplitterWidth();
splitterWidth.value = width;
const targetSize = pxToPercent(splitterSize.value, width, 0);
splitterPanelRef.value?.resize?.(targetSize);
};
onBeforeMount(() => {
loadSplitterPreferences();
});
onMounted(async () => {
await nextTick();
updateSplitterWidth();
const element = splitterGroupRef.value?.$el ?? splitterGroupRef.value;
if (element && typeof ResizeObserver !== 'undefined') {
splitterObserver = new ResizeObserver(updateSplitterWidth);
splitterObserver.observe(element);
}
});
watch(splitterSize, (value, previous) => {
if (value === previous) {
return;
}
if (splitterDraggingCount.value > 0) {
return;
}
updateSplitterWidth();
});
onBeforeUnmount(() => {
if (splitterObserver) {
splitterObserver.disconnect();
splitterObserver = null;
}
});
return {
splitterGroupRef,
splitterPanelRef,
defaultSize: defaultSizePercent,
minSize: minSizePercent,
maxSize: maxSizePercent,
handleLayout,
setDragging
};
}

View File

@@ -0,0 +1,76 @@
/**
* Shared layout styles for Favorites tab pages.
* FavoritesAvatar.vue, FavoritesFriend.vue, and FavoritesWorld.vue.
*/
/* ============ Splitter ============ */
.favorites-splitter [data-slot='resizable-handle'] {
opacity: 0;
transition: opacity 0.2s ease;
}
.favorites-splitter [data-slot='resizable-handle']:hover,
.favorites-splitter [data-slot='resizable-handle']:focus-visible {
opacity: 1;
}
/* ============ Group Item ============ */
.group-item {
border-radius: var(--radius-lg);
border: 1px solid var(--border);
padding: 8px;
cursor: pointer;
transition: background-color 0.15s ease;
}
.group-item:hover {
background-color: var(--accent);
}
.group-item--public {
border-left: 3px solid var(--visibility-public);
}
.group-item--friends {
border-left: 3px solid var(--visibility-friends);
}
.group-item--private {
border-left: 3px solid var(--visibility-private);
}
/* ============ Grid Layouts ============ */
.favorites-search-grid {
display: grid;
grid-template-columns: repeat(
var(--favorites-grid-columns, 1),
minmax(
var(--favorites-card-min-width, 240px),
var(--favorites-card-target-width, 1fr)
)
);
gap: var(--favorites-card-gap, 12px);
justify-content: start;
padding-bottom: 12px;
}
.favorites-card-list {
display: grid;
grid-template-columns: repeat(
var(--favorites-grid-columns, 1),
minmax(
var(--favorites-card-min-width, 260px),
var(--favorites-card-target-width, 1fr)
)
);
gap: var(--favorites-card-gap, 12px);
justify-content: start;
padding: 4px 2px 12px 2px;
}
.favorites-card-list::after {
content: '';
}