Relocate sidebar settings from the Appearance tab to a new popover within the Sidebar

This commit is contained in:
pa
2026-02-14 20:04:48 +09:00
parent ad3346427f
commit 2e627ba6f5
7 changed files with 349 additions and 220 deletions

View File

@@ -62,19 +62,150 @@
</PopoverContent>
</Popover>
</div>
<div>
<div class="flex items-center mx-1 gap-1">
<TooltipWrapper side="bottom" :content="t('side_panel.refresh_tooltip')">
<Button
class="rounded-full"
variant="ghost"
size="icon-sm"
:disabled="isRefreshFriendsLoading"
style="margin-right: 10px"
@click="refreshFriendsList">
<Spinner v-if="isRefreshFriendsLoading" />
<RefreshCw v-else />
</Button>
</TooltipWrapper>
<Popover>
<PopoverTrigger as-child>
<Button class="rounded-full" variant="ghost" size="icon-sm">
<Settings />
</Button>
</PopoverTrigger>
<PopoverContent side="bottom" align="end" class="w-64 p-3" @open-auto-focus.prevent>
<div class="flex flex-col gap-2.5 text-xs">
<div class="flex items-center justify-between">
<span>{{ t('side_panel.settings.group_by_instance') }}</span>
<Switch
:model-value="isSidebarGroupByInstance"
@update:modelValue="setIsSidebarGroupByInstance" />
</div>
<div v-if="isSidebarGroupByInstance" class="flex items-center justify-between">
<span>{{ t('side_panel.settings.hide_friends_in_same_instance') }}</span>
<Switch
:model-value="isHideFriendsInSameInstance"
@update:modelValue="setIsHideFriendsInSameInstance" />
</div>
<div class="flex items-center justify-between">
<span>{{ t('side_panel.settings.split_favorite_friends') }}</span>
<Switch
:model-value="isSidebarDivideByFriendGroup"
@update:modelValue="setIsSidebarDivideByFriendGroup" />
</div>
<div class="flex flex-col gap-1.5">
<span>{{ t('side_panel.settings.favorite_groups') }}</span>
<Select
:model-value="resolvedSidebarFavoriteGroups"
multiple
@update:modelValue="handleFavoriteGroupsChange">
<SelectTrigger size="sm" class="w-full overflow-hidden">
<SelectValue
:placeholder="t('side_panel.settings.favorite_groups_placeholder')">
<template v-if="resolvedSidebarFavoriteGroups.length">
<span class="truncate">{{ selectedFavGroupLabel }}</span>
<span
v-if="resolvedSidebarFavoriteGroups.length > 1"
class="bg-primary text-primary-foreground shrink-0 rounded px-1 text-xs">
+{{ resolvedSidebarFavoriteGroups.length - 1 }}
</span>
</template>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem
v-for="group in favoriteFriendGroups"
:key="group.key"
:value="group.key">
{{ group.displayName }}
</SelectItem>
</SelectGroup>
<template v-if="localFriendFavoriteGroups.length">
<SelectSeparator />
<SelectGroup>
<SelectItem
v-for="group in localFriendFavoriteGroups"
:key="'local:' + group"
:value="'local:' + group">
{{ group }}
</SelectItem>
</SelectGroup>
</template>
</SelectContent>
</Select>
</div>
<Separator />
<div class="flex flex-col gap-1.5">
<span>{{ t('side_panel.settings.sort_primary') }}</span>
<Select :model-value="sidebarSortMethod1" @update:modelValue="setSidebarSortMethod1">
<SelectTrigger size="sm">
<SelectValue
:placeholder="
t('view.settings.appearance.side_panel.sorting.placeholder')
" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="opt in sortOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="flex flex-col gap-1.5">
<span>{{ t('side_panel.settings.sort_secondary') }}</span>
<Select
:model-value="sidebarSortMethod2"
:disabled="!sidebarSortMethod1"
@update:modelValue="(v) => setSidebarSortMethod2(v === CLEAR_VALUE ? '' : v)">
<SelectTrigger size="sm">
<SelectValue
:placeholder="
t('view.settings.appearance.side_panel.sorting.placeholder')
" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="CLEAR_VALUE">{{
t('dialog.gallery_select.none')
}}</SelectItem>
<SelectItem v-for="opt in sortOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="flex flex-col gap-1.5">
<span>{{ t('side_panel.settings.sort_tertiary') }}</span>
<Select
:model-value="sidebarSortMethod3"
:disabled="!sidebarSortMethod2"
@update:modelValue="(v) => setSidebarSortMethod3(v === CLEAR_VALUE ? '' : v)">
<SelectTrigger size="sm">
<SelectValue
:placeholder="
t('view.settings.appearance.side_panel.sorting.placeholder')
" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="CLEAR_VALUE">{{
t('dialog.gallery_select.none')
}}</SelectItem>
<SelectItem v-for="opt in sortOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</PopoverContent>
</Popover>
</div>
</div>
<TabsUnderline
@@ -108,18 +239,35 @@
</template>
<script setup>
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectSeparator,
SelectTrigger,
SelectValue
} from '@/components/ui/select';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { computed, ref, watch } from 'vue';
import { RefreshCw, Settings } from 'lucide-vue-next';
import { Button } from '@/components/ui/button';
import { DataTableEmpty } from '@/components/ui/data-table';
import { Input } from '@/components/ui/input';
import { RefreshCw } from 'lucide-vue-next';
import { Separator } from '@/components/ui/separator';
import { Spinner } from '@/components/ui/spinner';
import { Switch } from '@/components/ui/switch';
import { TabsUnderline } from '@/components/ui/tabs';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';
import { useFriendStore, useGroupStore, useSearchStore } from '../../stores';
import {
useAppearanceSettingsStore,
useFavoriteStore,
useFriendStore,
useGroupStore,
useSearchStore
} from '../../stores';
import { debounce, userImage } from '../../shared/utils';
import FriendsSidebar from './components/FriendsSidebar.vue';
@@ -131,6 +279,79 @@
const { quickSearchItems } = storeToRefs(useSearchStore());
const { groupInstances } = storeToRefs(useGroupStore());
const { t } = useI18n();
const appearanceSettingsStore = useAppearanceSettingsStore();
const {
sidebarSortMethod1,
sidebarSortMethod2,
sidebarSortMethod3,
isSidebarGroupByInstance,
isHideFriendsInSameInstance,
isSidebarDivideByFriendGroup,
sidebarFavoriteGroups
} = storeToRefs(appearanceSettingsStore);
const {
setSidebarSortMethod1,
setSidebarSortMethod2,
setSidebarSortMethod3,
setIsSidebarGroupByInstance,
setIsHideFriendsInSameInstance,
setIsSidebarDivideByFriendGroup,
setSidebarFavoriteGroups
} = appearanceSettingsStore;
const favoriteStore = useFavoriteStore();
const { favoriteFriendGroups, localFriendFavoriteGroups } = storeToRefs(favoriteStore);
const allFavoriteGroupKeys = computed(() => {
const keys = favoriteFriendGroups.value.map((g) => g.key);
for (const group of localFriendFavoriteGroups.value) {
keys.push('local:' + group);
}
return keys;
});
const resolvedSidebarFavoriteGroups = computed(() => {
if (sidebarFavoriteGroups.value.length === 0) {
return allFavoriteGroupKeys.value;
}
return sidebarFavoriteGroups.value;
});
function handleFavoriteGroupsChange(value) {
if (!value || value.length === 0) {
// Deselected all → reset to all (store as empty)
setSidebarFavoriteGroups([]);
return;
}
// If all groups are selected, store as empty (= all)
const allKeys = allFavoriteGroupKeys.value;
if (value.length >= allKeys.length && allKeys.every((k) => value.includes(k))) {
setSidebarFavoriteGroups([]);
return;
}
setSidebarFavoriteGroups(value);
}
const selectedFavGroupLabel = computed(() => {
const key = resolvedSidebarFavoriteGroups.value[0];
if (!key) return '';
if (key.startsWith('local:')) return key.slice(6);
return favoriteFriendGroups.value.find((g) => g.key === key)?.displayName || key;
});
const CLEAR_VALUE = '__clear__';
const sortOptions = computed(() => [
{ value: 'Sort Alphabetically', label: t('view.settings.appearance.side_panel.sorting.alphabetical') },
{ value: 'Sort by Status', label: t('view.settings.appearance.side_panel.sorting.status') },
{ value: 'Sort Private to Bottom', label: t('view.settings.appearance.side_panel.sorting.private_to_bottom') },
{ value: 'Sort by Last Active', label: t('view.settings.appearance.side_panel.sorting.last_active') },
{ value: 'Sort by Last Seen', label: t('view.settings.appearance.side_panel.sorting.last_seen') },
{ value: 'Sort by Time in Instance', label: t('view.settings.appearance.side_panel.sorting.time_in_instance') },
{ value: 'Sort by Location', label: t('view.settings.appearance.side_panel.sorting.location') }
]);
const sidebarTabs = computed(() => [
{ value: 'friends', label: t('side_panel.friends') },
{ value: 'groups', label: t('side_panel.groups') }

View File

@@ -59,13 +59,6 @@
</div>
</template>
<template v-else-if="item.row.type === 'vip-subheader'">
<div>
<span class="text-xs">{{ item.row.label }}</span>
<span class="text-xs ml-1.5">{{ `(${item.row.count})` }}</span>
</div>
</template>
<template v-else-if="item.row.type === 'instance-header'">
<div class="mb-1 flex items-center">
<Location class="inline text-xs" :location="item.row.location" />
@@ -89,7 +82,7 @@
</template>
<script setup>
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue';
import { ChevronDown } from 'lucide-vue-next';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';
@@ -101,7 +94,6 @@
useFavoriteStore,
useFriendStore,
useGameStore,
useGeneralSettingsStore,
useLocationStore,
useUserStore
} from '../../../stores';
@@ -115,13 +107,15 @@
const { t } = useI18n();
const generalSettingsStore = useGeneralSettingsStore();
const friendStore = useFriendStore();
const { vipFriends, onlineFriends, activeFriends, offlineFriends, friendsInSameInstance } =
storeToRefs(friendStore);
const { isSidebarGroupByInstance, isHideFriendsInSameInstance, isSidebarDivideByFriendGroup } =
storeToRefs(useAppearanceSettingsStore());
const {
isSidebarGroupByInstance,
isHideFriendsInSameInstance,
isSidebarDivideByFriendGroup,
sidebarFavoriteGroups
} = storeToRefs(useAppearanceSettingsStore());
const { gameLogDisabled } = storeToRefs(useAdvancedSettingsStore());
const { showUserDialog } = useUserStore();
const { favoriteFriendGroups, groupedByGroupKeyFavoriteFriends, localFriendFavorites } =
@@ -136,6 +130,7 @@
const isActiveFriends = ref(true);
const isOfflineFriends = ref(true);
const isSidebarGroupByInstanceCollapsed = ref(false);
const collapsedFavGroups = reactive(new Set());
const scrollViewportRef = ref(null);
const scrollRootRef = ref(null);
@@ -164,30 +159,53 @@
const onlineFriendsByGroupStatus = computed(() => excludeSameInstance(onlineFriends.value));
const vipFriendsByGroupStatus = computed(() => excludeSameInstance(vipFriends.value));
const vipFriendsByGroupStatus = computed(() => {
const selectedGroups = sidebarFavoriteGroups.value;
const hasFilter = selectedGroups.length > 0;
if (!hasFilter) {
return excludeSameInstance(vipFriends.value);
}
// Filter to only include VIP friends whose group key is in selectedGroups
const allowedIds = new Set();
const remoteFriendsByGroup = groupedByGroupKeyFavoriteFriends.value;
for (const key of selectedGroups) {
if (key.startsWith('local:')) {
const groupName = key.slice(6);
const userIds = localFriendFavorites.value?.[groupName];
if (userIds) {
for (const id of userIds) allowedIds.add(id);
}
} else if (remoteFriendsByGroup[key]) {
for (const f of remoteFriendsByGroup[key]) allowedIds.add(f.id);
}
}
return excludeSameInstance(vipFriends.value.filter((f) => allowedIds.has(f.id)));
});
// VIP friends divide by group
const vipFriendsDivideByGroup = computed(() => {
const remoteFriendsByGroup = groupedByGroupKeyFavoriteFriends.value;
const selectedGroups = sidebarFavoriteGroups.value;
const hasFilter = selectedGroups.length > 0;
// Build a normalized list of { key, groupName, memberIds }
const groups = [];
for (const key in remoteFriendsByGroup) {
if (Object.hasOwn(remoteFriendsByGroup, key)) {
if (hasFilter && !selectedGroups.includes(key)) continue;
const groupName = favoriteFriendGroups.value.find((g) => g.key === key)?.displayName || '';
const memberIds = new Set(remoteFriendsByGroup[key].map((f) => f.id));
groups.push({ key, groupName, memberIds });
}
}
for (const selectedKey of generalSettingsStore.localFavoriteFriendsGroups) {
if (selectedKey.startsWith('local:')) {
const groupName = selectedKey.slice(6);
const userIds = localFriendFavorites.value?.[groupName];
if (userIds?.length) {
groups.push({ key: selectedKey, groupName, memberIds: new Set(userIds) });
}
for (const groupName in localFriendFavorites.value) {
const selectedKey = `local:${groupName}`;
if (hasFilter && !selectedGroups.includes(selectedKey)) continue;
const userIds = localFriendFavorites.value[groupName];
if (userIds?.length) {
groups.push({ key: selectedKey, groupName, memberIds: new Set(userIds) });
}
}
@@ -229,13 +247,7 @@
paddingBottom: options.paddingBottom,
itemStyle: options.itemStyle
});
const buildVipSubheaderRow = (label, count, key) => ({
type: 'vip-subheader',
key,
label,
count,
paddingBottom: 4
});
const buildInstanceHeaderRow = (location, count, key) => ({
type: 'instance-header',
key,
@@ -262,7 +274,7 @@
}
const vipFriendCount = isSidebarDivideByFriendGroup.value
? vipFriendsDivideByGroup.value.length
? vipFriendsDivideByGroup.value.reduce((sum, group) => sum + group.length, 0)
: vipFriendsByGroupStatus.value.length;
if (vipFriendCount) {
@@ -282,16 +294,34 @@
vipFriendsDivideByGroup.value.forEach((group, groupIndex) => {
const groupName = group?.[0]?.groupName ?? '';
const groupKey = group?.[0]?.key ?? groupIndex;
const isExpanded = !collapsedFavGroups.has(groupKey);
if (groupName) {
rows.push(buildVipSubheaderRow(groupName, group.length, `vip-subheader:${groupKey}`));
}
group.forEach((friend, idx) => {
rows.push(
buildFriendRow(friend, `vip:${groupKey}:${friend?.id ?? idx}`, {
paddingBottom: idx === group.length - 1 ? 10 : undefined
buildToggleRow({
key: `vip-subheader:${groupKey}`,
label: groupName,
count: group.length,
expanded: isExpanded,
headerPadding: '4px 0 4px 4px',
onClick: () => {
if (collapsedFavGroups.has(groupKey)) {
collapsedFavGroups.delete(groupKey);
} else {
collapsedFavGroups.add(groupKey);
}
}
})
);
});
}
if (isExpanded) {
group.forEach((friend, idx) => {
rows.push(
buildFriendRow(friend, `vip:${groupKey}:${friend?.id ?? idx}`, {
paddingBottom: idx === group.length - 1 ? 10 : undefined
})
);
});
}
});
} else {
vipFriendsByGroupStatus.value.forEach((friend, idx) => {