mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-05-06 14:46:04 +02:00
refactor favorites tab
This commit is contained in:
+6
-1
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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: '';
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user