mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-04-17 22:03:50 +02:00
refactor favorites tab
This commit is contained in:
@@ -101,7 +101,12 @@ export default defineConfig([
|
||||
}
|
||||
},
|
||||
jsdoc({
|
||||
config: 'flat/recommended'
|
||||
config: 'flat/recommended',
|
||||
rules: {
|
||||
'jsdoc/require-param-description': 'off',
|
||||
'jsdoc/require-returns-description': 'off',
|
||||
'jsdoc/reject-function-type': 'off'
|
||||
}
|
||||
}),
|
||||
{
|
||||
ignores: [
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -10,9 +10,9 @@
|
||||
<img v-if="localFavFakeRef.thumbnailImageUrl" :src="smallThumbnail" loading="lazy" />
|
||||
</div>
|
||||
<div class="favorites-search-card__detail">
|
||||
<div class="favorites-search-card__title">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="name text-sm">{{ localFavFakeRef.name }}</span>
|
||||
<span class="favorites-search-card__badges">
|
||||
<span class="inline-flex items-center gap-1 text-sm">
|
||||
<TooltipWrapper
|
||||
v-if="favorite.deleted"
|
||||
side="top"
|
||||
@@ -34,23 +34,21 @@
|
||||
<template v-if="editMode">
|
||||
<div
|
||||
v-if="!isLocalFavorite"
|
||||
class="favorites-search-card__action favorites-search-card__action--checkbox"
|
||||
class="flex justify-end w-full favorites-search-card__action--checkbox"
|
||||
@click.stop>
|
||||
<Checkbox v-model="isSelected" />
|
||||
</div>
|
||||
<div class="favorites-search-card__action-group">
|
||||
<div
|
||||
class="favorites-search-card__action favorites-search-card__action--full"
|
||||
@click.stop>
|
||||
<div class="flex gap-[var(--favorites-card-action-group-gap,8px)] w-full">
|
||||
<div class="flex justify-end w-full flex-1" @click.stop>
|
||||
<FavoritesMoveDropdown
|
||||
:favoriteGroup="favoriteAvatarGroups"
|
||||
:currentFavorite="props.favorite"
|
||||
:currentGroup="group"
|
||||
class="favorites-search-card__dropdown"
|
||||
class="w-full"
|
||||
:is-local-favorite="isLocalFavorite"
|
||||
type="avatar" />
|
||||
</div>
|
||||
<div class="favorites-search-card__action">
|
||||
<div class="flex justify-end w-full">
|
||||
<TooltipWrapper
|
||||
side="left"
|
||||
:content="
|
||||
@@ -70,8 +68,8 @@
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="favorites-search-card__action-group">
|
||||
<div class="favorites-search-card__action" v-if="canSelectAvatar">
|
||||
<div class="flex gap-(--favorites-card-action-group-gap,8px) w-full">
|
||||
<div class="flex justify-end w-full" v-if="canSelectAvatar">
|
||||
<TooltipWrapper side="top" :content="t('view.favorite.select_avatar_tooltip')">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
@@ -83,7 +81,7 @@
|
||||
/></Button>
|
||||
</TooltipWrapper>
|
||||
</div>
|
||||
<div class="favorites-search-card__action">
|
||||
<div class="flex justify-end w-full">
|
||||
<TooltipWrapper
|
||||
v-if="showDangerUnfavorite"
|
||||
side="bottom"
|
||||
@@ -121,7 +119,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="favorites-search-card__actions">
|
||||
<div class="favorites-search-card__action">
|
||||
<div class="flex justify-end w-full">
|
||||
<Button
|
||||
class="rounded-full text-xs h-6 w-6"
|
||||
size="icon-sm"
|
||||
@@ -238,13 +236,6 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.favorites-search-card img {
|
||||
filter: saturate(0.8) contrast(0.8);
|
||||
transition: filter 0.2s ease;
|
||||
}
|
||||
|
||||
.favorites-search-card:hover img {
|
||||
filter: saturate(1) contrast(1);
|
||||
}
|
||||
<style>
|
||||
@import './favorites-card.css';
|
||||
</style>
|
||||
|
||||
@@ -7,15 +7,15 @@
|
||||
<img v-if="favorite.thumbnailImageUrl" :src="smallThumbnail" loading="lazy" />
|
||||
</div>
|
||||
<div class="favorites-search-card__detail">
|
||||
<div class="favorites-search-card__title">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="name text-sm">{{ favorite.name }}</span>
|
||||
</div>
|
||||
<span class="text-xs">{{ favorite.authorName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="favorites-search-card__actions">
|
||||
<div class="favorites-search-card__action-group">
|
||||
<div class="favorites-search-card__action">
|
||||
<div class="flex gap-(--favorites-card-action-group-gap,8px) w-full">
|
||||
<div class="flex justify-end w-full">
|
||||
<TooltipWrapper side="top" :content="t('view.favorite.select_avatar_tooltip')">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
@@ -28,7 +28,7 @@
|
||||
>
|
||||
</TooltipWrapper>
|
||||
</div>
|
||||
<div class="favorites-search-card__action">
|
||||
<div class="flex justify-end w-full">
|
||||
<TooltipWrapper side="bottom" :content="t('view.favorite.edit_favorite_tooltip')">
|
||||
<Button
|
||||
v-if="favoriteExists"
|
||||
@@ -99,13 +99,6 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.favorites-search-card img {
|
||||
filter: saturate(0.8) contrast(0.8);
|
||||
transition: filter 0.2s ease;
|
||||
}
|
||||
|
||||
.favorites-search-card:hover img {
|
||||
filter: saturate(1) contrast(1);
|
||||
}
|
||||
<style>
|
||||
@import './favorites-card.css';
|
||||
</style>
|
||||
|
||||
54
src/views/Favorites/components/FavoritesContentHeader.vue
Normal file
54
src/views/Favorites/components/FavoritesContentHeader.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-between gap-3 mb-3">
|
||||
<div class="flex flex-col gap-0.5 text-base font-semibold pl-0.5 [&_small]:text-xs [&_small]:font-normal">
|
||||
<slot name="title" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-[13px]">
|
||||
<span>{{ t('view.favorite.edit_mode') }}</span>
|
||||
<Switch
|
||||
:model-value="editMode"
|
||||
:disabled="editModeDisabled"
|
||||
@update:modelValue="$emit('update:editMode', $event)" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-end">
|
||||
<div v-if="editModeVisible" class="flex flex-wrap gap-2 mb-3">
|
||||
<Button size="sm" variant="outline" @click="$emit('toggle-select-all')">
|
||||
{{ isAllSelected ? t('view.favorite.deselect_all') : t('view.favorite.select_all') }}
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" :disabled="!hasSelection" @click="$emit('clear-selection')">
|
||||
{{ t('view.favorite.clear') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="showCopyButton"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
:disabled="!hasSelection"
|
||||
@click="$emit('copy-selection')">
|
||||
{{ t('view.favorite.copy') }}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" :disabled="!hasSelection" @click="$emit('bulk-unfavorite')">
|
||||
{{ t('view.favorite.bulk_unfavorite') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
defineProps({
|
||||
editMode: { type: Boolean, default: false },
|
||||
editModeDisabled: { type: Boolean, default: false },
|
||||
editModeVisible: { type: Boolean, default: false },
|
||||
isAllSelected: { type: Boolean, default: false },
|
||||
hasSelection: { type: Boolean, default: false },
|
||||
showCopyButton: { type: Boolean, default: true }
|
||||
});
|
||||
|
||||
defineEmits(['update:editMode', 'toggle-select-all', 'clear-selection', 'copy-selection', 'bulk-unfavorite']);
|
||||
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
@@ -6,10 +6,10 @@
|
||||
<img :src="userImage(favorite.ref, true)" loading="lazy" />
|
||||
</div>
|
||||
<div class="favorites-search-card__detail">
|
||||
<div class="favorites-search-card__title">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="name text-sm" :style="displayNameStyle">{{ favorite.ref.displayName }}</span>
|
||||
</div>
|
||||
<div v-if="favorite.ref.location !== 'offline'" class="favorites-search-card__location">
|
||||
<div v-if="favorite.ref.location !== 'offline'" class="text-xs truncate">
|
||||
<Location
|
||||
:location="favorite.ref.location"
|
||||
:traveling="favorite.ref.travelingToLocation"
|
||||
@@ -20,22 +20,19 @@
|
||||
</div>
|
||||
<div class="favorites-search-card__actions">
|
||||
<template v-if="editMode">
|
||||
<div class="favorites-search-card__action favorites-search-card__action--checkbox" @click.stop>
|
||||
<div class="flex justify-end w-full favorites-search-card__action--checkbox" @click.stop>
|
||||
<Checkbox v-model="isSelected" />
|
||||
</div>
|
||||
<div class="favorites-search-card__action-group">
|
||||
<div
|
||||
v-if="group?.type !== 'local'"
|
||||
class="favorites-search-card__action favorites-search-card__action--full"
|
||||
@click.stop>
|
||||
<div class="flex gap-[var(--favorites-card-action-group-gap,8px)] w-full">
|
||||
<div v-if="group?.type !== 'local'" class="flex justify-end w-full flex-1" @click.stop>
|
||||
<FavoritesMoveDropdown
|
||||
:favoriteGroup="favoriteFriendGroups"
|
||||
:currentGroup="group"
|
||||
:currentFavorite="favorite"
|
||||
class="favorites-search-card__dropdown"
|
||||
class="w-full"
|
||||
type="friend" />
|
||||
</div>
|
||||
<div class="favorites-search-card__action">
|
||||
<div class="flex justify-end w-full">
|
||||
<TooltipWrapper side="left" :content="t('view.favorite.unfavorite_tooltip')">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
@@ -49,7 +46,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="favorites-search-card__action">
|
||||
<div class="flex justify-end w-full">
|
||||
<TooltipWrapper side="right" :content="t('view.favorite.edit_favorite_tooltip')">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
@@ -71,7 +68,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="favorites-search-card__actions">
|
||||
<div class="favorites-search-card__action">
|
||||
<div class="flex justify-end w-full">
|
||||
<Button
|
||||
class="rounded-full text-xs h-6 w-6"
|
||||
size="icon-sm"
|
||||
@@ -135,6 +132,9 @@
|
||||
return {};
|
||||
});
|
||||
|
||||
/**
|
||||
* @returns {void}
|
||||
*/
|
||||
function handleDeleteFavorite() {
|
||||
if (props.group?.type === 'local') {
|
||||
removeLocalFriendFavorite(props.favorite.id, props.group.key);
|
||||
@@ -146,13 +146,6 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.favorites-search-card img {
|
||||
filter: saturate(0.8) contrast(0.8);
|
||||
transition: filter 0.2s ease;
|
||||
}
|
||||
|
||||
.favorites-search-card:hover img {
|
||||
filter: saturate(1) contrast(1);
|
||||
}
|
||||
<style>
|
||||
@import './favorites-card.css';
|
||||
</style>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
><ArrowLeft class="h-4 w-4"
|
||||
/></Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent class="favorites-dropdown">
|
||||
<DropdownMenuContent class="p-2">
|
||||
<span style="font-weight: bold; display: block; text-align: center">
|
||||
{{ t(tooltipContent) }}
|
||||
</span>
|
||||
|
||||
118
src/views/Favorites/components/FavoritesToolbar.vue
Normal file
118
src/views/Favorites/components/FavoritesToolbar.vue
Normal file
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-between gap-3 mb-3 flex-wrap">
|
||||
<div>
|
||||
<Select :model-value="sortFavorites" @update:modelValue="$emit('update:sortFavorites', $event)">
|
||||
<SelectTrigger size="sm" class="min-w-[200px]">
|
||||
<span class="flex items-center gap-2">
|
||||
<ArrowUpDown class="h-4 w-4" />
|
||||
<SelectValue :placeholder="t('view.settings.appearance.appearance.sort_favorite_by_name')" />
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem
|
||||
:value="false"
|
||||
:text-value="t('view.settings.appearance.appearance.sort_favorite_by_name')">
|
||||
{{ t('view.settings.appearance.appearance.sort_favorite_by_name') }}
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
:value="true"
|
||||
:text-value="t('view.settings.appearance.appearance.sort_favorite_by_date')">
|
||||
{{ t('view.settings.appearance.appearance.sort_favorite_by_date') }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-1">
|
||||
<InputGroupSearch
|
||||
:model-value="searchQuery"
|
||||
class="flex-1"
|
||||
:placeholder="searchPlaceholder"
|
||||
@update:modelValue="$emit('update:searchQuery', $event)"
|
||||
@input="$emit('search')" />
|
||||
<DropdownMenu :open="toolbarMenuOpen" @update:open="$emit('update:toolbarMenuOpen', $event)">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button class="rounded-full" size="icon-sm" variant="ghost"><Ellipsis /></Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent class="p-2">
|
||||
<li class="list-none px-4 pt-3 pb-2 min-w-[220px] cursor-default" @click.stop>
|
||||
<div class="flex items-center justify-between text-[13px] font-semibold mb-2">
|
||||
<span>{{ t('view.friends_locations.scale') }}</span>
|
||||
<span class="text-xs">{{ cardScalePercent }}%</span>
|
||||
</div>
|
||||
<Slider
|
||||
:model-value="cardScaleValue"
|
||||
class="px-1 pb-1"
|
||||
:min="cardScaleSlider.min"
|
||||
:max="cardScaleSlider.max"
|
||||
:step="cardScaleSlider.step"
|
||||
@update:modelValue="$emit('update:cardScaleValue', $event)" />
|
||||
</li>
|
||||
<li class="list-none px-4 pt-3 pb-2 min-w-[220px] cursor-default" @click.stop>
|
||||
<div class="flex items-center justify-between text-[13px] font-semibold mb-2">
|
||||
<span>{{ t('view.friends_locations.spacing') }}</span>
|
||||
<span class="text-xs"> {{ cardSpacingPercent }}% </span>
|
||||
</div>
|
||||
<Slider
|
||||
:model-value="cardSpacingValue"
|
||||
class="px-1 pb-1"
|
||||
:min="cardSpacingSlider.min"
|
||||
:max="cardSpacingSlider.max"
|
||||
:step="cardSpacingSlider.step"
|
||||
@update:modelValue="$emit('update:cardSpacingValue', $event)" />
|
||||
</li>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem @click="$emit('import')">
|
||||
{{ t('view.favorite.import') }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="$emit('export')">
|
||||
{{ t('view.favorite.export') }}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { ArrowUpDown, Ellipsis } from 'lucide-vue-next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { InputGroupSearch } from '@/components/ui/input-group';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
defineProps({
|
||||
sortFavorites: { type: Boolean, default: false },
|
||||
searchQuery: { type: String, default: '' },
|
||||
searchPlaceholder: { type: String, default: '' },
|
||||
toolbarMenuOpen: { type: Boolean, default: false },
|
||||
cardScaleValue: { type: Array, default: () => [50] },
|
||||
cardScalePercent: { type: Number, default: 100 },
|
||||
cardScaleSlider: { type: Object, default: () => ({ min: 0, max: 100, step: 1 }) },
|
||||
cardSpacingValue: { type: Array, default: () => [50] },
|
||||
cardSpacingPercent: { type: Number, default: 100 },
|
||||
cardSpacingSlider: { type: Object, default: () => ({ min: 0, max: 100, step: 1 }) }
|
||||
});
|
||||
|
||||
defineEmits([
|
||||
'update:sortFavorites',
|
||||
'update:searchQuery',
|
||||
'update:toolbarMenuOpen',
|
||||
'update:cardScaleValue',
|
||||
'update:cardSpacingValue',
|
||||
'search',
|
||||
'import',
|
||||
'export'
|
||||
]);
|
||||
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
@@ -2,75 +2,82 @@
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger as-child>
|
||||
<div :class="cardClasses" @click="$emit('click')">
|
||||
<template v-if="favorite.ref">
|
||||
<template v-if="localFavRef?.name">
|
||||
<div class="favorites-search-card__content">
|
||||
<div
|
||||
class="favorites-search-card__avatar"
|
||||
:class="{ 'is-empty': !favorite.ref.thumbnailImageUrl }"
|
||||
:class="{ 'is-empty': !localFavRef.thumbnailImageUrl }"
|
||||
v-once>
|
||||
<img
|
||||
v-if="favorite.ref.thumbnailImageUrl"
|
||||
v-if="localFavRef.thumbnailImageUrl"
|
||||
:src="smallThumbnail"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
fetchpriority="low" />
|
||||
</div>
|
||||
<div class="favorites-search-card__detail">
|
||||
<div class="favorites-search-card__title">
|
||||
<span class="name text-sm">{{ props.favorite.ref.name }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="name text-sm">{{ localFavRef.name }}</span>
|
||||
<span
|
||||
v-if="favorite.deleted || favorite.ref.releaseStatus === 'private'"
|
||||
class="favorites-search-card__badges">
|
||||
v-if="
|
||||
!isLocalFavorite &&
|
||||
(favorite.deleted || localFavRef.releaseStatus === 'private')
|
||||
"
|
||||
class="inline-flex items-center gap-1 text-sm">
|
||||
<AlertTriangle
|
||||
v-if="favorite.deleted"
|
||||
:title="t('view.favorite.unavailable_tooltip')"
|
||||
class="h-4 w-4" />
|
||||
<Lock
|
||||
v-if="favorite.ref.releaseStatus === 'private'"
|
||||
v-if="localFavRef.releaseStatus === 'private'"
|
||||
:title="t('view.favorite.private')"
|
||||
class="h-4 w-4" />
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ props.favorite.ref.authorName }}
|
||||
<template v-if="props.favorite.ref.occupants">
|
||||
({{ props.favorite.ref.occupants }})
|
||||
</template>
|
||||
{{ localFavRef.authorName }}
|
||||
<template v-if="localFavRef.occupants"> ({{ localFavRef.occupants }}) </template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="favorites-search-card__actions">
|
||||
<template v-if="editMode">
|
||||
<div
|
||||
class="favorites-search-card__action favorites-search-card__action--checkbox"
|
||||
v-if="!isLocalFavorite"
|
||||
class="flex justify-end w-full favorites-search-card__action--checkbox"
|
||||
@click.stop>
|
||||
<Checkbox v-model="isSelected" />
|
||||
</div>
|
||||
<div class="favorites-search-card__action-group">
|
||||
<div
|
||||
class="favorites-search-card__action favorites-search-card__action--full"
|
||||
@click.stop>
|
||||
<div class="flex gap-[var(--favorites-card-action-group-gap,8px)] w-full">
|
||||
<div class="flex justify-end w-full flex-1" @click.stop>
|
||||
<FavoritesMoveDropdown
|
||||
:favoriteGroup="favoriteWorldGroups"
|
||||
:currentFavorite="props.favorite"
|
||||
:currentGroup="group"
|
||||
class="favorites-search-card__dropdown"
|
||||
class="w-full"
|
||||
:is-local-favorite="isLocalFavorite"
|
||||
type="world" />
|
||||
</div>
|
||||
<div class="favorites-search-card__action">
|
||||
<div class="flex justify-end w-full">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
:variant="
|
||||
isLocalFavorite && shiftHeld
|
||||
? 'destructive'
|
||||
: isLocalFavorite
|
||||
? 'outline'
|
||||
: 'ghost'
|
||||
"
|
||||
class="rounded-full text-xs h-6 w-6"
|
||||
@click.stop="handleDeleteFavorite">
|
||||
@click.stop="handlePrimaryDeleteAction">
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="favorites-search-card__action-group">
|
||||
<div class="favorites-search-card__action">
|
||||
<div class="flex gap-[var(--favorites-card-action-group-gap,8px)] w-full">
|
||||
<div class="flex justify-end w-full">
|
||||
<TooltipWrapper side="top" :content="inviteOrLaunchText">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
@@ -81,7 +88,7 @@
|
||||
/></Button>
|
||||
</TooltipWrapper>
|
||||
</div>
|
||||
<div class="favorites-search-card__action">
|
||||
<div class="flex justify-end w-full">
|
||||
<TooltipWrapper
|
||||
v-if="showDangerUnfavorite"
|
||||
side="top"
|
||||
@@ -117,17 +124,17 @@
|
||||
<div class="favorites-search-card__detail" v-once>
|
||||
<span>{{ favorite.name || favorite.id }}</span>
|
||||
<AlertTriangle
|
||||
v-if="favorite.deleted"
|
||||
v-if="!isLocalFavorite && favorite.deleted"
|
||||
:title="t('view.favorite.unavailable_tooltip')"
|
||||
class="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="favorites-search-card__actions">
|
||||
<div class="favorites-search-card__action">
|
||||
<div class="flex justify-end w-full">
|
||||
<Button
|
||||
class="rounded-full text-xs h-6 w-6"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
:variant="isLocalFavorite ? 'outline' : 'ghost'"
|
||||
@click.stop="handleDeleteFavorite">
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -184,6 +191,8 @@
|
||||
set: (value) => emit('toggle-select', value)
|
||||
});
|
||||
|
||||
const localFavRef = computed(() => (props.isLocalFavorite ? props.favorite : props.favorite?.ref));
|
||||
|
||||
const showDangerUnfavorite = computed(() => {
|
||||
return shiftHeld.value;
|
||||
});
|
||||
@@ -198,8 +207,8 @@
|
||||
]);
|
||||
|
||||
const smallThumbnail = computed(() => {
|
||||
const url = props.favorite.ref.thumbnailImageUrl?.replace('256', '128');
|
||||
return url || props.favorite.ref.thumbnailImageUrl;
|
||||
const url = localFavRef.value?.thumbnailImageUrl?.replace('256', '128');
|
||||
return url || localFavRef.value?.thumbnailImageUrl;
|
||||
});
|
||||
|
||||
const inviteOrLaunchText = computed(() => {
|
||||
@@ -208,6 +217,21 @@
|
||||
: t('dialog.world.actions.new_instance_and_self_invite');
|
||||
});
|
||||
|
||||
/**
|
||||
* @returns {void}
|
||||
*/
|
||||
function handlePrimaryDeleteAction() {
|
||||
if (props.isLocalFavorite) {
|
||||
if (shiftHeld.value) {
|
||||
emit('remove-local-world-favorite', props.favorite.id, props.group);
|
||||
return;
|
||||
}
|
||||
showFavoriteDialog('world', props.favorite.id);
|
||||
return;
|
||||
}
|
||||
deleteFavorite(props.favorite.id);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
@@ -237,13 +261,6 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.favorites-search-card img {
|
||||
filter: saturate(0.8) contrast(0.8);
|
||||
transition: filter 0.2s ease;
|
||||
}
|
||||
|
||||
.favorites-search-card:hover img {
|
||||
filter: saturate(1) contrast(1);
|
||||
}
|
||||
<style>
|
||||
@import './favorites-card.css';
|
||||
</style>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
fetchpriority="low" />
|
||||
</div>
|
||||
<div class="favorites-search-card__detail">
|
||||
<div class="favorites-search-card__title">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="name text-sm">{{ props.favorite.name }}</span>
|
||||
</div>
|
||||
<span class="text-xs">
|
||||
@@ -24,18 +24,16 @@
|
||||
</div>
|
||||
<div class="favorites-search-card__actions">
|
||||
<template v-if="editMode">
|
||||
<div class="favorites-search-card__action-group">
|
||||
<div
|
||||
class="favorites-search-card__action favorites-search-card__action--full"
|
||||
@click.stop>
|
||||
<div class="flex gap-[var(--favorites-card-action-group-gap,8px)] w-full">
|
||||
<div class="flex justify-end w-full flex-1" @click.stop>
|
||||
<FavoritesMoveDropdown
|
||||
:favoriteGroup="favoriteWorldGroups"
|
||||
:currentFavorite="props.favorite"
|
||||
class="favorites-search-card__dropdown"
|
||||
class="w-full"
|
||||
isLocalFavorite
|
||||
type="world" />
|
||||
</div>
|
||||
<div class="favorites-search-card__action">
|
||||
<div class="flex justify-end w-full">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
:variant="shiftHeld ? 'destructive' : 'outline'"
|
||||
@@ -47,8 +45,8 @@
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="favorites-search-card__action-group">
|
||||
<div class="favorites-search-card__action">
|
||||
<div class="flex gap-[var(--favorites-card-action-group-gap,8px)] w-full">
|
||||
<div class="flex justify-end w-full">
|
||||
<TooltipWrapper side="top" :content="inviteOrLaunchText">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
@@ -59,7 +57,7 @@
|
||||
/></Button>
|
||||
</TooltipWrapper>
|
||||
</div>
|
||||
<div class="favorites-search-card__action">
|
||||
<div class="flex justify-end w-full">
|
||||
<TooltipWrapper
|
||||
v-if="showDangerUnfavorite"
|
||||
side="top"
|
||||
@@ -97,7 +95,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="favorites-search-card__actions">
|
||||
<div class="favorites-search-card__action">
|
||||
<div class="flex justify-end w-full">
|
||||
<Button
|
||||
class="rounded-full text-xs h-6 w-6"
|
||||
size="icon-sm"
|
||||
@@ -173,7 +171,7 @@
|
||||
});
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
function handlePrimaryDeleteAction() {
|
||||
if (shiftHeld.value) {
|
||||
@@ -200,13 +198,6 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.favorites-search-card img {
|
||||
filter: saturate(0.8) contrast(0.8);
|
||||
transition: filter 0.2s ease;
|
||||
}
|
||||
|
||||
.favorites-search-card:hover img {
|
||||
filter: saturate(1) contrast(1);
|
||||
}
|
||||
<style>
|
||||
@import './favorites-card.css';
|
||||
</style>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
119
src/views/Favorites/components/favorites-card.css
Normal file
119
src/views/Favorites/components/favorites-card.css
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Shared card styles for all Favorites item components.
|
||||
* FavoritesAvatar.vue, FavoritesFriend.vue, and FavoritesWorld.vue.
|
||||
*/
|
||||
|
||||
.favorites-search-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: calc(var(--radius-lg) * var(--favorites-card-scale, 1));
|
||||
padding: var(--favorites-card-padding-y, 8px)
|
||||
var(--favorites-card-padding-x, 8px);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
width: 100%;
|
||||
min-width: var(--favorites-card-min-width, 240px);
|
||||
max-width: var(--favorites-card-target-width, 320px);
|
||||
}
|
||||
|
||||
.favorites-search-card:hover {
|
||||
background-color: var(--accent);
|
||||
}
|
||||
|
||||
.favorites-search-card__content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--favorites-card-content-gap, 8px);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.favorites-search-card__avatar {
|
||||
width: calc(48px * var(--favorites-card-scale, 1));
|
||||
height: calc(48px * var(--favorites-card-scale, 1));
|
||||
border-radius: calc(var(--radius-lg) * var(--favorites-card-scale, 1));
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.favorites-search-card__avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
filter: saturate(0.8) contrast(0.8);
|
||||
transition: filter 0.2s ease;
|
||||
}
|
||||
|
||||
.favorites-search-card:hover .favorites-search-card__avatar img {
|
||||
filter: saturate(1) contrast(1);
|
||||
}
|
||||
|
||||
.favorites-search-card__avatar.is-empty {
|
||||
background: repeating-linear-gradient(
|
||||
-45deg,
|
||||
rgba(148, 163, 184, 0.25),
|
||||
rgba(148, 163, 184, 0.25) 10px,
|
||||
rgba(255, 255, 255, 0.35) 10px,
|
||||
rgba(255, 255, 255, 0.35) 20px
|
||||
);
|
||||
}
|
||||
|
||||
.favorites-search-card__detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: calc(13px * var(--favorites-card-scale, 1));
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.favorites-search-card__detail .name {
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.favorites-search-card__detail .extra {
|
||||
font-size: calc(12px * var(--favorites-card-scale, 1));
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.favorites-search-card__actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--favorites-card-action-gap, 8px);
|
||||
margin-left: var(--favorites-card-action-margin, 8px);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
min-width: 48px;
|
||||
}
|
||||
|
||||
.favorites-search-card__actions:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.favorites-search-card__action-group .favorites-search-card__action--full {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.favorites-search-card__action--checkbox {
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
margin-right: var(--favorites-card-checkbox-margin, 8px);
|
||||
}
|
||||
|
||||
.favorites-search-card__action--checkbox [data-slot='checkbox'] {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Entity-specific overrides */
|
||||
|
||||
.favorites-search-card--avatar {
|
||||
min-width: var(--favorites-card-min-width, 240px);
|
||||
max-width: var(--favorites-card-target-width, 320px);
|
||||
}
|
||||
@@ -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'
|
||||
});
|
||||
});
|
||||
});
|
||||
206
src/views/Favorites/composables/useFavoritesGroupPanel.js
Normal file
206
src/views/Favorites/composables/useFavoritesGroupPanel.js
Normal file
@@ -0,0 +1,206 @@
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
/**
|
||||
* @param {object} options
|
||||
* @param {import('vue').Ref<Array>} options.remoteGroups - remote groups ref
|
||||
* @param {import('vue').Ref<Array>} options.localGroups - local groups ref (string keys)
|
||||
* @param {import('vue').Ref<object>} options.localFavorites - local favorites map { groupKey: items[] }
|
||||
* @param {Function} options.clearSelection - callback to clear entity selection
|
||||
* @param {Array} [options.placeholders] - placeholder groups when remote data not yet loaded
|
||||
* @param {boolean} [options.hasHistory] - whether history group type is supported (Avatar only)
|
||||
* @param {import('vue').Ref<Array>} [options.historyItems] - items for history group
|
||||
* @returns {object}
|
||||
*/
|
||||
export function useFavoritesGroupPanel(options = {}) {
|
||||
const {
|
||||
remoteGroups,
|
||||
localGroups,
|
||||
localFavorites,
|
||||
clearSelection,
|
||||
placeholders = [],
|
||||
hasHistory = false,
|
||||
historyItems = null
|
||||
} = options;
|
||||
|
||||
const selectedGroup = ref(null);
|
||||
const activeGroupMenu = ref(null);
|
||||
const hasUserSelectedGroup = ref(false);
|
||||
const remoteGroupsResolved = ref(false);
|
||||
|
||||
const isRemoteGroupSelected = computed(
|
||||
() => selectedGroup.value?.type === 'remote'
|
||||
);
|
||||
const isLocalGroupSelected = computed(
|
||||
() => selectedGroup.value?.type === 'local'
|
||||
);
|
||||
const isHistorySelected = computed(
|
||||
() => hasHistory && selectedGroup.value?.type === 'history'
|
||||
);
|
||||
|
||||
const remoteGroupMenuKey = (key) => `remote:${key}`;
|
||||
const localGroupMenuKey = (key) => `local:${key}`;
|
||||
|
||||
const activeRemoteGroup = computed(() => {
|
||||
if (!isRemoteGroupSelected.value) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
remoteGroups.value.find(
|
||||
(group) => group.key === selectedGroup.value.key
|
||||
) || null
|
||||
);
|
||||
});
|
||||
|
||||
const activeLocalGroupName = computed(() => {
|
||||
if (!isLocalGroupSelected.value) {
|
||||
return '';
|
||||
}
|
||||
return selectedGroup.value.key;
|
||||
});
|
||||
|
||||
const activeLocalGroupCount = computed(() => {
|
||||
if (!activeLocalGroupName.value) {
|
||||
return 0;
|
||||
}
|
||||
const favorites = localFavorites.value[activeLocalGroupName.value];
|
||||
return favorites ? favorites.length : 0;
|
||||
});
|
||||
|
||||
/**
|
||||
*
|
||||
* @param key {string}
|
||||
* @param visible {boolean}
|
||||
*/
|
||||
function handleGroupMenuVisible(key, visible) {
|
||||
if (visible) {
|
||||
activeGroupMenu.value = key;
|
||||
return;
|
||||
}
|
||||
if (activeGroupMenu.value === key) {
|
||||
activeGroupMenu.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param type {string}
|
||||
* @param key {string}
|
||||
* @param options {object}
|
||||
* @param opts {object}
|
||||
*/
|
||||
function selectGroup(type, key, opts = {}) {
|
||||
if (
|
||||
selectedGroup.value?.type === type &&
|
||||
selectedGroup.value?.key === key
|
||||
) {
|
||||
return;
|
||||
}
|
||||
selectedGroup.value = { type, key };
|
||||
if (opts.userInitiated) {
|
||||
hasUserSelectedGroup.value = true;
|
||||
}
|
||||
clearSelection();
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param type {string}
|
||||
* @param key {string}
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isGroupActive(type, key) {
|
||||
return (
|
||||
selectedGroup.value?.type === type &&
|
||||
selectedGroup.value?.key === key
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param group {object}
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isGroupAvailable(group) {
|
||||
if (!group) {
|
||||
return false;
|
||||
}
|
||||
if (group.type === 'remote') {
|
||||
if (placeholders.length && !remoteGroupsResolved.value) {
|
||||
return true;
|
||||
}
|
||||
return remoteGroups.value.some((item) => item.key === group.key);
|
||||
}
|
||||
if (group.type === 'local') {
|
||||
return localGroups.value.includes(group.key);
|
||||
}
|
||||
if (group.type === 'history' && hasHistory && historyItems) {
|
||||
return historyItems.value.length > 0;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {void}
|
||||
*/
|
||||
function selectDefaultGroup() {
|
||||
if (!hasUserSelectedGroup.value && placeholders.length) {
|
||||
const remote =
|
||||
remoteGroups.value.find((group) => group.count > 0) ||
|
||||
remoteGroups.value[0] ||
|
||||
placeholders[0];
|
||||
if (remote) {
|
||||
selectGroup('remote', remote.key);
|
||||
return;
|
||||
}
|
||||
} else if (remoteGroups.value.length) {
|
||||
const remote =
|
||||
remoteGroups.value.find((group) => group.count > 0) ||
|
||||
remoteGroups.value[0];
|
||||
if (remote) {
|
||||
selectGroup('remote', remote.key);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (localGroups.value.length) {
|
||||
selectGroup('local', localGroups.value[0]);
|
||||
return;
|
||||
}
|
||||
if (hasHistory && historyItems && historyItems.value.length) {
|
||||
selectGroup('history', 'local-history');
|
||||
return;
|
||||
}
|
||||
selectedGroup.value = null;
|
||||
clearSelection();
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {void}
|
||||
*/
|
||||
function ensureSelectedGroup() {
|
||||
if (selectedGroup.value && isGroupAvailable(selectedGroup.value)) {
|
||||
return;
|
||||
}
|
||||
selectDefaultGroup();
|
||||
}
|
||||
|
||||
return {
|
||||
selectedGroup,
|
||||
activeGroupMenu,
|
||||
hasUserSelectedGroup,
|
||||
remoteGroupsResolved,
|
||||
isRemoteGroupSelected,
|
||||
isLocalGroupSelected,
|
||||
isHistorySelected,
|
||||
remoteGroupMenuKey,
|
||||
localGroupMenuKey,
|
||||
activeRemoteGroup,
|
||||
activeLocalGroupName,
|
||||
activeLocalGroupCount,
|
||||
handleGroupMenuVisible,
|
||||
selectGroup,
|
||||
isGroupActive,
|
||||
isGroupAvailable,
|
||||
selectDefaultGroup,
|
||||
ensureSelectedGroup
|
||||
};
|
||||
}
|
||||
64
src/views/Favorites/composables/useFavoritesLocalGroups.js
Normal file
64
src/views/Favorites/composables/useFavoritesLocalGroups.js
Normal file
@@ -0,0 +1,64 @@
|
||||
import { nextTick, ref } from 'vue';
|
||||
|
||||
/**
|
||||
* @param {object} options
|
||||
* @param {Function} options.createGroup - store function to create a new local group
|
||||
* @param {Function} options.selectGroup - function to select a group after creation
|
||||
* @returns {object}
|
||||
*/
|
||||
export function useFavoritesLocalGroups(options = {}) {
|
||||
const { createGroup, selectGroup, canCreate = () => true } = options;
|
||||
|
||||
const isCreatingLocalGroup = ref(false);
|
||||
const newLocalGroupName = ref('');
|
||||
const newLocalGroupInput = ref(null);
|
||||
|
||||
/**
|
||||
* @returns {void}
|
||||
*/
|
||||
function startLocalGroupCreation() {
|
||||
if (!canCreate() || isCreatingLocalGroup.value) {
|
||||
return;
|
||||
}
|
||||
isCreatingLocalGroup.value = true;
|
||||
newLocalGroupName.value = '';
|
||||
nextTick(() => {
|
||||
const el =
|
||||
newLocalGroupInput.value?.$el ?? newLocalGroupInput.value;
|
||||
el?.focus?.();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {void}
|
||||
*/
|
||||
function cancelLocalGroupCreation() {
|
||||
isCreatingLocalGroup.value = false;
|
||||
newLocalGroupName.value = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {void}
|
||||
*/
|
||||
function handleLocalGroupCreationConfirm() {
|
||||
const name = newLocalGroupName.value.trim();
|
||||
if (!name) {
|
||||
cancelLocalGroupCreation();
|
||||
return;
|
||||
}
|
||||
createGroup(name);
|
||||
cancelLocalGroupCreation();
|
||||
nextTick(() => {
|
||||
selectGroup('local', name, { userInitiated: true });
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
isCreatingLocalGroup,
|
||||
newLocalGroupName,
|
||||
newLocalGroupInput,
|
||||
startLocalGroupCreation,
|
||||
cancelLocalGroupCreation,
|
||||
handleLocalGroupCreationConfirm
|
||||
};
|
||||
}
|
||||
176
src/views/Favorites/composables/useFavoritesSplitter.js
Normal file
176
src/views/Favorites/composables/useFavoritesSplitter.js
Normal file
@@ -0,0 +1,176 @@
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onBeforeMount,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
watch
|
||||
} from 'vue';
|
||||
|
||||
import configRepository from '../../../service/config.js';
|
||||
|
||||
/**
|
||||
* @param {object} options
|
||||
* @param {string} options.configKey
|
||||
* @param {number} [options.defaultSize]
|
||||
* @param {number} [options.maxPx]
|
||||
* @param {number} [options.minPx]
|
||||
* @returns {object}
|
||||
*/
|
||||
export function useFavoritesSplitter(options = {}) {
|
||||
const configKey = options.configKey ?? '';
|
||||
const defaultSize = options.defaultSize ?? 260;
|
||||
const maxPx = options.maxPx ?? 360;
|
||||
const minPx = options.minPx ?? 0;
|
||||
|
||||
const splitterFallbackWidth =
|
||||
typeof window !== 'undefined' && window.innerWidth
|
||||
? window.innerWidth
|
||||
: 1200;
|
||||
|
||||
const splitterSize = ref(defaultSize);
|
||||
const splitterGroupRef = ref(null);
|
||||
const splitterPanelRef = ref(null);
|
||||
const splitterWidth = ref(splitterFallbackWidth);
|
||||
const splitterDraggingCount = ref(0);
|
||||
let splitterObserver = null;
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
async function loadSplitterPreferences() {
|
||||
const storedSize = await configRepository.getString(
|
||||
configKey,
|
||||
String(defaultSize)
|
||||
);
|
||||
const parsedSize = Number(storedSize);
|
||||
if (Number.isFinite(parsedSize) && parsedSize >= 0) {
|
||||
splitterSize.value = parsedSize;
|
||||
}
|
||||
}
|
||||
|
||||
const getSplitterWidthRaw = () => {
|
||||
const element = splitterGroupRef.value?.$el ?? splitterGroupRef.value;
|
||||
const width = element?.getBoundingClientRect?.().width;
|
||||
return Number.isFinite(width) ? width : null;
|
||||
};
|
||||
|
||||
const getSplitterWidth = () => {
|
||||
const width = getSplitterWidthRaw();
|
||||
return Number.isFinite(width) && width > 0
|
||||
? width
|
||||
: splitterFallbackWidth;
|
||||
};
|
||||
|
||||
const resolveDraggingPayload = (payload) => {
|
||||
if (typeof payload === 'boolean') {
|
||||
return payload;
|
||||
}
|
||||
if (payload && typeof payload === 'object') {
|
||||
if (typeof payload.detail === 'boolean') {
|
||||
return payload.detail;
|
||||
}
|
||||
if (typeof payload.dragging === 'boolean') {
|
||||
return payload.dragging;
|
||||
}
|
||||
}
|
||||
return Boolean(payload);
|
||||
};
|
||||
|
||||
const setDragging = (payload) => {
|
||||
const isDragging = resolveDraggingPayload(payload);
|
||||
const next = splitterDraggingCount.value + (isDragging ? 1 : -1);
|
||||
splitterDraggingCount.value = Math.max(0, next);
|
||||
};
|
||||
|
||||
const pxToPercent = (px, groupWidth, min = 0) => {
|
||||
const width = groupWidth ?? getSplitterWidth();
|
||||
return Math.min(100, Math.max(min, (px / width) * 100));
|
||||
};
|
||||
|
||||
const percentToPx = (percent, groupWidth) => (percent / 100) * groupWidth;
|
||||
|
||||
const defaultSizePercent = computed(() =>
|
||||
pxToPercent(splitterSize.value, splitterWidth.value, 0)
|
||||
);
|
||||
const minSizePercent = computed(() =>
|
||||
pxToPercent(minPx, splitterWidth.value, 0)
|
||||
);
|
||||
const maxSizePercent = computed(() =>
|
||||
pxToPercent(maxPx, splitterWidth.value, 0)
|
||||
);
|
||||
|
||||
const handleLayout = (sizes) => {
|
||||
if (!Array.isArray(sizes) || !sizes.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (splitterDraggingCount.value === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rawWidth = getSplitterWidthRaw();
|
||||
if (!Number.isFinite(rawWidth) || rawWidth <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSize = sizes[0];
|
||||
if (!Number.isFinite(nextSize)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextPx = Math.round(percentToPx(nextSize, rawWidth));
|
||||
const clampedPx = Math.min(maxPx, Math.max(minPx, nextPx));
|
||||
splitterSize.value = clampedPx;
|
||||
configRepository.setString(configKey, clampedPx.toString());
|
||||
};
|
||||
|
||||
const updateSplitterWidth = () => {
|
||||
const width = getSplitterWidth();
|
||||
splitterWidth.value = width;
|
||||
const targetSize = pxToPercent(splitterSize.value, width, 0);
|
||||
splitterPanelRef.value?.resize?.(targetSize);
|
||||
};
|
||||
|
||||
onBeforeMount(() => {
|
||||
loadSplitterPreferences();
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
updateSplitterWidth();
|
||||
const element = splitterGroupRef.value?.$el ?? splitterGroupRef.value;
|
||||
if (element && typeof ResizeObserver !== 'undefined') {
|
||||
splitterObserver = new ResizeObserver(updateSplitterWidth);
|
||||
splitterObserver.observe(element);
|
||||
}
|
||||
});
|
||||
|
||||
watch(splitterSize, (value, previous) => {
|
||||
if (value === previous) {
|
||||
return;
|
||||
}
|
||||
if (splitterDraggingCount.value > 0) {
|
||||
return;
|
||||
}
|
||||
updateSplitterWidth();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (splitterObserver) {
|
||||
splitterObserver.disconnect();
|
||||
splitterObserver = null;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
splitterGroupRef,
|
||||
splitterPanelRef,
|
||||
defaultSize: defaultSizePercent,
|
||||
minSize: minSizePercent,
|
||||
maxSize: maxSizePercent,
|
||||
handleLayout,
|
||||
setDragging
|
||||
};
|
||||
}
|
||||
76
src/views/Favorites/favorites-layout.css
Normal file
76
src/views/Favorites/favorites-layout.css
Normal 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: '';
|
||||
}
|
||||
Reference in New Issue
Block a user