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
+6 -1
View File
@@ -101,7 +101,12 @@ export default defineConfig([
} }
}, },
jsdoc({ jsdoc({
config: 'flat/recommended' config: 'flat/recommended',
rules: {
'jsdoc/require-param-description': 'off',
'jsdoc/require-returns-description': 'off',
'jsdoc/reject-function-type': 'off'
}
}), }),
{ {
ignores: [ 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
@@ -10,9 +10,9 @@
<img v-if="localFavFakeRef.thumbnailImageUrl" :src="smallThumbnail" loading="lazy" /> <img v-if="localFavFakeRef.thumbnailImageUrl" :src="smallThumbnail" loading="lazy" />
</div> </div>
<div class="favorites-search-card__detail"> <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="name text-sm">{{ localFavFakeRef.name }}</span>
<span class="favorites-search-card__badges"> <span class="inline-flex items-center gap-1 text-sm">
<TooltipWrapper <TooltipWrapper
v-if="favorite.deleted" v-if="favorite.deleted"
side="top" side="top"
@@ -34,23 +34,21 @@
<template v-if="editMode"> <template v-if="editMode">
<div <div
v-if="!isLocalFavorite" 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> @click.stop>
<Checkbox v-model="isSelected" /> <Checkbox v-model="isSelected" />
</div> </div>
<div class="favorites-search-card__action-group"> <div class="flex gap-[var(--favorites-card-action-group-gap,8px)] w-full">
<div <div class="flex justify-end w-full flex-1" @click.stop>
class="favorites-search-card__action favorites-search-card__action--full"
@click.stop>
<FavoritesMoveDropdown <FavoritesMoveDropdown
:favoriteGroup="favoriteAvatarGroups" :favoriteGroup="favoriteAvatarGroups"
:currentFavorite="props.favorite" :currentFavorite="props.favorite"
:currentGroup="group" :currentGroup="group"
class="favorites-search-card__dropdown" class="w-full"
:is-local-favorite="isLocalFavorite" :is-local-favorite="isLocalFavorite"
type="avatar" /> type="avatar" />
</div> </div>
<div class="favorites-search-card__action"> <div class="flex justify-end w-full">
<TooltipWrapper <TooltipWrapper
side="left" side="left"
:content=" :content="
@@ -70,8 +68,8 @@
</div> </div>
</template> </template>
<template v-else> <template v-else>
<div class="favorites-search-card__action-group"> <div class="flex gap-(--favorites-card-action-group-gap,8px) w-full">
<div class="favorites-search-card__action" v-if="canSelectAvatar"> <div class="flex justify-end w-full" v-if="canSelectAvatar">
<TooltipWrapper side="top" :content="t('view.favorite.select_avatar_tooltip')"> <TooltipWrapper side="top" :content="t('view.favorite.select_avatar_tooltip')">
<Button <Button
size="icon-sm" size="icon-sm"
@@ -83,7 +81,7 @@
/></Button> /></Button>
</TooltipWrapper> </TooltipWrapper>
</div> </div>
<div class="favorites-search-card__action"> <div class="flex justify-end w-full">
<TooltipWrapper <TooltipWrapper
v-if="showDangerUnfavorite" v-if="showDangerUnfavorite"
side="bottom" side="bottom"
@@ -121,7 +119,7 @@
</div> </div>
</div> </div>
<div class="favorites-search-card__actions"> <div class="favorites-search-card__actions">
<div class="favorites-search-card__action"> <div class="flex justify-end w-full">
<Button <Button
class="rounded-full text-xs h-6 w-6" class="rounded-full text-xs h-6 w-6"
size="icon-sm" size="icon-sm"
@@ -238,13 +236,6 @@
} }
</script> </script>
<style scoped> <style>
.favorites-search-card img { @import './favorites-card.css';
filter: saturate(0.8) contrast(0.8);
transition: filter 0.2s ease;
}
.favorites-search-card:hover img {
filter: saturate(1) contrast(1);
}
</style> </style>
@@ -7,15 +7,15 @@
<img v-if="favorite.thumbnailImageUrl" :src="smallThumbnail" loading="lazy" /> <img v-if="favorite.thumbnailImageUrl" :src="smallThumbnail" loading="lazy" />
</div> </div>
<div class="favorites-search-card__detail"> <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> <span class="name text-sm">{{ favorite.name }}</span>
</div> </div>
<span class="text-xs">{{ favorite.authorName }}</span> <span class="text-xs">{{ favorite.authorName }}</span>
</div> </div>
</div> </div>
<div class="favorites-search-card__actions"> <div class="favorites-search-card__actions">
<div class="favorites-search-card__action-group"> <div class="flex gap-(--favorites-card-action-group-gap,8px) w-full">
<div class="favorites-search-card__action"> <div class="flex justify-end w-full">
<TooltipWrapper side="top" :content="t('view.favorite.select_avatar_tooltip')"> <TooltipWrapper side="top" :content="t('view.favorite.select_avatar_tooltip')">
<Button <Button
size="icon-sm" size="icon-sm"
@@ -28,7 +28,7 @@
> >
</TooltipWrapper> </TooltipWrapper>
</div> </div>
<div class="favorites-search-card__action"> <div class="flex justify-end w-full">
<TooltipWrapper side="bottom" :content="t('view.favorite.edit_favorite_tooltip')"> <TooltipWrapper side="bottom" :content="t('view.favorite.edit_favorite_tooltip')">
<Button <Button
v-if="favoriteExists" v-if="favoriteExists"
@@ -99,13 +99,6 @@
}); });
</script> </script>
<style scoped> <style>
.favorites-search-card img { @import './favorites-card.css';
filter: saturate(0.8) contrast(0.8);
transition: filter 0.2s ease;
}
.favorites-search-card:hover img {
filter: saturate(1) contrast(1);
}
</style> </style>
@@ -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>
@@ -6,10 +6,10 @@
<img :src="userImage(favorite.ref, true)" loading="lazy" /> <img :src="userImage(favorite.ref, true)" loading="lazy" />
</div> </div>
<div class="favorites-search-card__detail"> <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> <span class="name text-sm" :style="displayNameStyle">{{ favorite.ref.displayName }}</span>
</div> </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
:location="favorite.ref.location" :location="favorite.ref.location"
:traveling="favorite.ref.travelingToLocation" :traveling="favorite.ref.travelingToLocation"
@@ -20,22 +20,19 @@
</div> </div>
<div class="favorites-search-card__actions"> <div class="favorites-search-card__actions">
<template v-if="editMode"> <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" /> <Checkbox v-model="isSelected" />
</div> </div>
<div class="favorites-search-card__action-group"> <div class="flex gap-[var(--favorites-card-action-group-gap,8px)] w-full">
<div <div v-if="group?.type !== 'local'" class="flex justify-end w-full flex-1" @click.stop>
v-if="group?.type !== 'local'"
class="favorites-search-card__action favorites-search-card__action--full"
@click.stop>
<FavoritesMoveDropdown <FavoritesMoveDropdown
:favoriteGroup="favoriteFriendGroups" :favoriteGroup="favoriteFriendGroups"
:currentGroup="group" :currentGroup="group"
:currentFavorite="favorite" :currentFavorite="favorite"
class="favorites-search-card__dropdown" class="w-full"
type="friend" /> type="friend" />
</div> </div>
<div class="favorites-search-card__action"> <div class="flex justify-end w-full">
<TooltipWrapper side="left" :content="t('view.favorite.unfavorite_tooltip')"> <TooltipWrapper side="left" :content="t('view.favorite.unfavorite_tooltip')">
<Button <Button
size="icon-sm" size="icon-sm"
@@ -49,7 +46,7 @@
</div> </div>
</template> </template>
<template v-else> <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')"> <TooltipWrapper side="right" :content="t('view.favorite.edit_favorite_tooltip')">
<Button <Button
size="icon-sm" size="icon-sm"
@@ -71,7 +68,7 @@
</div> </div>
</div> </div>
<div class="favorites-search-card__actions"> <div class="favorites-search-card__actions">
<div class="favorites-search-card__action"> <div class="flex justify-end w-full">
<Button <Button
class="rounded-full text-xs h-6 w-6" class="rounded-full text-xs h-6 w-6"
size="icon-sm" size="icon-sm"
@@ -135,6 +132,9 @@
return {}; return {};
}); });
/**
* @returns {void}
*/
function handleDeleteFavorite() { function handleDeleteFavorite() {
if (props.group?.type === 'local') { if (props.group?.type === 'local') {
removeLocalFriendFavorite(props.favorite.id, props.group.key); removeLocalFriendFavorite(props.favorite.id, props.group.key);
@@ -146,13 +146,6 @@
} }
</script> </script>
<style scoped> <style>
.favorites-search-card img { @import './favorites-card.css';
filter: saturate(0.8) contrast(0.8);
transition: filter 0.2s ease;
}
.favorites-search-card:hover img {
filter: saturate(1) contrast(1);
}
</style> </style>
@@ -5,7 +5,7 @@
><ArrowLeft class="h-4 w-4" ><ArrowLeft class="h-4 w-4"
/></Button> /></Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent class="favorites-dropdown"> <DropdownMenuContent class="p-2">
<span style="font-weight: bold; display: block; text-align: center"> <span style="font-weight: bold; display: block; text-align: center">
{{ t(tooltipContent) }} {{ t(tooltipContent) }}
</span> </span>
@@ -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>
@@ -2,75 +2,82 @@
<ContextMenu> <ContextMenu>
<ContextMenuTrigger as-child> <ContextMenuTrigger as-child>
<div :class="cardClasses" @click="$emit('click')"> <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__content">
<div <div
class="favorites-search-card__avatar" class="favorites-search-card__avatar"
:class="{ 'is-empty': !favorite.ref.thumbnailImageUrl }" :class="{ 'is-empty': !localFavRef.thumbnailImageUrl }"
v-once> v-once>
<img <img
v-if="favorite.ref.thumbnailImageUrl" v-if="localFavRef.thumbnailImageUrl"
:src="smallThumbnail" :src="smallThumbnail"
loading="lazy" loading="lazy"
decoding="async" decoding="async"
fetchpriority="low" /> fetchpriority="low" />
</div> </div>
<div class="favorites-search-card__detail"> <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.ref.name }}</span> <span class="name text-sm">{{ localFavRef.name }}</span>
<span <span
v-if="favorite.deleted || favorite.ref.releaseStatus === 'private'" v-if="
class="favorites-search-card__badges"> !isLocalFavorite &&
(favorite.deleted || localFavRef.releaseStatus === 'private')
"
class="inline-flex items-center gap-1 text-sm">
<AlertTriangle <AlertTriangle
v-if="favorite.deleted" v-if="favorite.deleted"
:title="t('view.favorite.unavailable_tooltip')" :title="t('view.favorite.unavailable_tooltip')"
class="h-4 w-4" /> class="h-4 w-4" />
<Lock <Lock
v-if="favorite.ref.releaseStatus === 'private'" v-if="localFavRef.releaseStatus === 'private'"
:title="t('view.favorite.private')" :title="t('view.favorite.private')"
class="h-4 w-4" /> class="h-4 w-4" />
</span> </span>
</div> </div>
<span class="text-xs text-muted-foreground"> <span class="text-xs text-muted-foreground">
{{ props.favorite.ref.authorName }} {{ localFavRef.authorName }}
<template v-if="props.favorite.ref.occupants"> <template v-if="localFavRef.occupants"> ({{ localFavRef.occupants }}) </template>
({{ props.favorite.ref.occupants }})
</template>
</span> </span>
</div> </div>
</div> </div>
<div class="favorites-search-card__actions"> <div class="favorites-search-card__actions">
<template v-if="editMode"> <template v-if="editMode">
<div <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> @click.stop>
<Checkbox v-model="isSelected" /> <Checkbox v-model="isSelected" />
</div> </div>
<div class="favorites-search-card__action-group"> <div class="flex gap-[var(--favorites-card-action-group-gap,8px)] w-full">
<div <div class="flex justify-end w-full flex-1" @click.stop>
class="favorites-search-card__action favorites-search-card__action--full"
@click.stop>
<FavoritesMoveDropdown <FavoritesMoveDropdown
:favoriteGroup="favoriteWorldGroups" :favoriteGroup="favoriteWorldGroups"
:currentFavorite="props.favorite" :currentFavorite="props.favorite"
:currentGroup="group" :currentGroup="group"
class="favorites-search-card__dropdown" class="w-full"
:is-local-favorite="isLocalFavorite"
type="world" /> type="world" />
</div> </div>
<div class="favorites-search-card__action"> <div class="flex justify-end w-full">
<Button <Button
size="icon-sm" size="icon-sm"
variant="ghost" :variant="
isLocalFavorite && shiftHeld
? 'destructive'
: isLocalFavorite
? 'outline'
: 'ghost'
"
class="rounded-full text-xs h-6 w-6" class="rounded-full text-xs h-6 w-6"
@click.stop="handleDeleteFavorite"> @click.stop="handlePrimaryDeleteAction">
<Trash2 class="h-4 w-4" /> <Trash2 class="h-4 w-4" />
</Button> </Button>
</div> </div>
</div> </div>
</template> </template>
<template v-else> <template v-else>
<div class="favorites-search-card__action-group"> <div class="flex gap-[var(--favorites-card-action-group-gap,8px)] w-full">
<div class="favorites-search-card__action"> <div class="flex justify-end w-full">
<TooltipWrapper side="top" :content="inviteOrLaunchText"> <TooltipWrapper side="top" :content="inviteOrLaunchText">
<Button <Button
size="icon-sm" size="icon-sm"
@@ -81,7 +88,7 @@
/></Button> /></Button>
</TooltipWrapper> </TooltipWrapper>
</div> </div>
<div class="favorites-search-card__action"> <div class="flex justify-end w-full">
<TooltipWrapper <TooltipWrapper
v-if="showDangerUnfavorite" v-if="showDangerUnfavorite"
side="top" side="top"
@@ -117,17 +124,17 @@
<div class="favorites-search-card__detail" v-once> <div class="favorites-search-card__detail" v-once>
<span>{{ favorite.name || favorite.id }}</span> <span>{{ favorite.name || favorite.id }}</span>
<AlertTriangle <AlertTriangle
v-if="favorite.deleted" v-if="!isLocalFavorite && favorite.deleted"
:title="t('view.favorite.unavailable_tooltip')" :title="t('view.favorite.unavailable_tooltip')"
class="h-4 w-4" /> class="h-4 w-4" />
</div> </div>
</div> </div>
<div class="favorites-search-card__actions"> <div class="favorites-search-card__actions">
<div class="favorites-search-card__action"> <div class="flex justify-end w-full">
<Button <Button
class="rounded-full text-xs h-6 w-6" class="rounded-full text-xs h-6 w-6"
size="icon-sm" size="icon-sm"
variant="ghost" :variant="isLocalFavorite ? 'outline' : 'ghost'"
@click.stop="handleDeleteFavorite"> @click.stop="handleDeleteFavorite">
<Trash2 class="h-4 w-4" /> <Trash2 class="h-4 w-4" />
</Button> </Button>
@@ -184,6 +191,8 @@
set: (value) => emit('toggle-select', value) set: (value) => emit('toggle-select', value)
}); });
const localFavRef = computed(() => (props.isLocalFavorite ? props.favorite : props.favorite?.ref));
const showDangerUnfavorite = computed(() => { const showDangerUnfavorite = computed(() => {
return shiftHeld.value; return shiftHeld.value;
}); });
@@ -198,8 +207,8 @@
]); ]);
const smallThumbnail = computed(() => { const smallThumbnail = computed(() => {
const url = props.favorite.ref.thumbnailImageUrl?.replace('256', '128'); const url = localFavRef.value?.thumbnailImageUrl?.replace('256', '128');
return url || props.favorite.ref.thumbnailImageUrl; return url || localFavRef.value?.thumbnailImageUrl;
}); });
const inviteOrLaunchText = computed(() => { const inviteOrLaunchText = computed(() => {
@@ -208,6 +217,21 @@
: t('dialog.world.actions.new_instance_and_self_invite'); : 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> </script>
<style scoped> <style>
.favorites-search-card img { @import './favorites-card.css';
filter: saturate(0.8) contrast(0.8);
transition: filter 0.2s ease;
}
.favorites-search-card:hover img {
filter: saturate(1) contrast(1);
}
</style> </style>
@@ -13,7 +13,7 @@
fetchpriority="low" /> fetchpriority="low" />
</div> </div>
<div class="favorites-search-card__detail"> <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> <span class="name text-sm">{{ props.favorite.name }}</span>
</div> </div>
<span class="text-xs"> <span class="text-xs">
@@ -24,18 +24,16 @@
</div> </div>
<div class="favorites-search-card__actions"> <div class="favorites-search-card__actions">
<template v-if="editMode"> <template v-if="editMode">
<div class="favorites-search-card__action-group"> <div class="flex gap-[var(--favorites-card-action-group-gap,8px)] w-full">
<div <div class="flex justify-end w-full flex-1" @click.stop>
class="favorites-search-card__action favorites-search-card__action--full"
@click.stop>
<FavoritesMoveDropdown <FavoritesMoveDropdown
:favoriteGroup="favoriteWorldGroups" :favoriteGroup="favoriteWorldGroups"
:currentFavorite="props.favorite" :currentFavorite="props.favorite"
class="favorites-search-card__dropdown" class="w-full"
isLocalFavorite isLocalFavorite
type="world" /> type="world" />
</div> </div>
<div class="favorites-search-card__action"> <div class="flex justify-end w-full">
<Button <Button
size="icon-sm" size="icon-sm"
:variant="shiftHeld ? 'destructive' : 'outline'" :variant="shiftHeld ? 'destructive' : 'outline'"
@@ -47,8 +45,8 @@
</div> </div>
</template> </template>
<template v-else> <template v-else>
<div class="favorites-search-card__action-group"> <div class="flex gap-[var(--favorites-card-action-group-gap,8px)] w-full">
<div class="favorites-search-card__action"> <div class="flex justify-end w-full">
<TooltipWrapper side="top" :content="inviteOrLaunchText"> <TooltipWrapper side="top" :content="inviteOrLaunchText">
<Button <Button
size="icon-sm" size="icon-sm"
@@ -59,7 +57,7 @@
/></Button> /></Button>
</TooltipWrapper> </TooltipWrapper>
</div> </div>
<div class="favorites-search-card__action"> <div class="flex justify-end w-full">
<TooltipWrapper <TooltipWrapper
v-if="showDangerUnfavorite" v-if="showDangerUnfavorite"
side="top" side="top"
@@ -97,7 +95,7 @@
</div> </div>
</div> </div>
<div class="favorites-search-card__actions"> <div class="favorites-search-card__actions">
<div class="favorites-search-card__action"> <div class="flex justify-end w-full">
<Button <Button
class="rounded-full text-xs h-6 w-6" class="rounded-full text-xs h-6 w-6"
size="icon-sm" size="icon-sm"
@@ -173,7 +171,7 @@
}); });
/** /**
* * @returns {void}
*/ */
function handlePrimaryDeleteAction() { function handlePrimaryDeleteAction() {
if (shiftHeld.value) { if (shiftHeld.value) {
@@ -200,13 +198,6 @@
} }
</script> </script>
<style scoped> <style>
.favorites-search-card img { @import './favorites-card.css';
filter: saturate(0.8) contrast(0.8);
transition: filter 0.2s ease;
}
.favorites-search-card:hover img {
filter: saturate(1) contrast(1);
}
</style> </style>
@@ -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();
});
});
@@ -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);
}
@@ -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'
});
});
});
@@ -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
};
}
@@ -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
};
}
@@ -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
};
}
+76
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: '';
}