Allow users to reorder favorite friend groups in the sidebar

This commit is contained in:
pa
2026-02-24 21:34:43 +09:00
committed by Natsumi
parent 304413c1e3
commit 60fc08b472
8 changed files with 344 additions and 5 deletions

130
package-lock.json generated
View File

@@ -11,6 +11,7 @@
"node-api-dotnet": "^0.9.19"
},
"devDependencies": {
"@dnd-kit/vue": "^0.3.2",
"@electron/rebuild": "^4.0.3",
"@eslint/js": "^9.39.2",
"@fontsource-variable/inter": "^5.2.8",
@@ -741,6 +742,124 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/@dnd-kit/abstract": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/abstract/-/abstract-0.3.2.tgz",
"integrity": "sha512-uvPVK+SZYD6Viddn9M0K0JQdXknuVSxA/EbMlFRanve3P/XTc18oLa5zGftKSGjfQGmuzkZ34E26DSbly1zi3Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@dnd-kit/geometry": "^0.3.2",
"@dnd-kit/state": "^0.3.2",
"tslib": "^2.6.2"
}
},
"node_modules/@dnd-kit/abstract/node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
},
"node_modules/@dnd-kit/collision": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/collision/-/collision-0.3.2.tgz",
"integrity": "sha512-pNmNSLCI8S9fNQ7QJ3fBCDjiT0sqBhUFcKgmyYaGvGCAU+kq0AP8OWlh0JSisc9k5mFyxmRpmFQcnJpILz/RPA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@dnd-kit/abstract": "^0.3.2",
"@dnd-kit/geometry": "^0.3.2",
"tslib": "^2.6.2"
}
},
"node_modules/@dnd-kit/collision/node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
},
"node_modules/@dnd-kit/dom": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/dom/-/dom-0.3.2.tgz",
"integrity": "sha512-cIUAVgt2szQyz6JRy7I+0r+xeyOAGH21Y15hb5bIyHoDEaZBvIDH+OOlD9eoLjCbsxDLN9WloU2CBi3OE6LYDg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@dnd-kit/abstract": "^0.3.2",
"@dnd-kit/collision": "^0.3.2",
"@dnd-kit/geometry": "^0.3.2",
"@dnd-kit/state": "^0.3.2",
"tslib": "^2.6.2"
}
},
"node_modules/@dnd-kit/dom/node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
},
"node_modules/@dnd-kit/geometry": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/geometry/-/geometry-0.3.2.tgz",
"integrity": "sha512-3UBPuIS7E3oGiHxOE8h810QA+0pnrnCtGxl4Os1z3yy5YkC/BEYGY+TxWPTQaY1/OMV7GCX7ZNMlama2QN3n3w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@dnd-kit/state": "^0.3.2",
"tslib": "^2.6.2"
}
},
"node_modules/@dnd-kit/geometry/node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
},
"node_modules/@dnd-kit/state": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/state/-/state-0.3.2.tgz",
"integrity": "sha512-dLUIkoYrIJhGXfF2wGLTfb46vUokEsO/OoE21TSfmahYrx7ysTmnwbePsznFaHlwgZhQEh6AlLvthLCeY21b1A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@preact/signals-core": "^1.10.0",
"tslib": "^2.6.2"
}
},
"node_modules/@dnd-kit/state/node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
},
"node_modules/@dnd-kit/vue": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/vue/-/vue-0.3.2.tgz",
"integrity": "sha512-tYX0YzylmkPyjTM8Kv//o90sWNVRCFBtzSu7/Ung2aFMJhfEEUKJoEYRL2V4Cz+fLkOPQFaFqNxSDpNKTeWNxQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@dnd-kit/abstract": "^0.3.2",
"@dnd-kit/dom": "^0.3.2",
"@dnd-kit/state": "^0.3.2",
"tslib": "^2.6.2"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/@dnd-kit/vue/node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
},
"node_modules/@electron/asar": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz",
@@ -2874,6 +2993,17 @@
"url": "https://opencollective.com/pkgr"
}
},
"node_modules/@preact/signals-core": {
"version": "1.13.0",
"resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.13.0.tgz",
"integrity": "sha512-slT6XeTCAbdql61GVLlGU4x7XHI7kCZV5Um5uhE4zLX4ApgiiXc0UYFvVOKq06xcovzp7p+61l68oPi563ARKg==",
"dev": true,
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.2",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz",

View File

@@ -31,6 +31,7 @@
},
"homepage": "https://github.com/vrcx-team/VRCX#readme",
"devDependencies": {
"@dnd-kit/vue": "^0.3.2",
"@electron/rebuild": "^4.0.3",
"@eslint/js": "^9.39.2",
"@fontsource-variable/inter": "^5.2.8",

View File

@@ -7,7 +7,8 @@
"actions": {
"open": "Open",
"confirm": "Confirm",
"clear": "Clear"
"clear": "Clear",
"reset": "Reset"
},
"time_units": {
"y": "y",
@@ -93,7 +94,8 @@
"sort_secondary": "Then by",
"sort_tertiary": "Then by",
"favorite_groups": "Favorite Groups",
"favorite_groups_placeholder": "All Groups"
"favorite_groups_placeholder": "All Groups",
"edit_group_order": "Edit Group Order"
},
"notifications": "Notifications",
"notification_center": {

View File

@@ -78,6 +78,7 @@ export const useAppearanceSettingsStore = defineStore(
const isHideFriendsInSameInstance = ref(false);
const isSidebarDivideByFriendGroup = ref(false);
const sidebarFavoriteGroups = ref([]);
const sidebarFavoriteGroupOrder = ref([]);
const hideUserNotes = ref(false);
const hideUserMemos = ref(false);
const hideUnfriends = ref(false);
@@ -152,6 +153,7 @@ export const useAppearanceSettingsStore = defineStore(
isHideFriendsInSameInstanceConfig,
isSidebarDivideByFriendGroupConfig,
sidebarFavoriteGroupsConfig,
sidebarFavoriteGroupOrderConfig,
hideUserNotesConfig,
hideUserMemosConfig,
hideUnfriendsConfig,
@@ -208,6 +210,10 @@ export const useAppearanceSettingsStore = defineStore(
true
),
configRepository.getString('VRCX_sidebarFavoriteGroups', '[]'),
configRepository.getString(
'VRCX_sidebarFavoriteGroupOrder',
'[]'
),
configRepository.getBool('VRCX_hideUserNotes', false),
configRepository.getBool('VRCX_hideUserMemos', false),
configRepository.getBool('VRCX_hideUnfriends', false),
@@ -299,6 +305,9 @@ export const useAppearanceSettingsStore = defineStore(
sidebarFavoriteGroups.value = JSON.parse(
sidebarFavoriteGroupsConfig
);
sidebarFavoriteGroupOrder.value = JSON.parse(
sidebarFavoriteGroupOrderConfig
);
hideUserNotes.value = hideUserNotesConfig;
hideUserMemos.value = hideUserMemosConfig;
hideUnfriends.value = hideUnfriendsConfig;
@@ -717,6 +726,16 @@ export const useAppearanceSettingsStore = defineStore(
JSON.stringify(value)
);
}
/**
* @param {string[]} value
*/
function setSidebarFavoriteGroupOrder(value) {
sidebarFavoriteGroupOrder.value = value;
configRepository.setString(
'VRCX_sidebarFavoriteGroupOrder',
JSON.stringify(value)
);
}
function setHideUserNotes() {
hideUserNotes.value = !hideUserNotes.value;
configRepository.setBool('VRCX_hideUserNotes', hideUserNotes.value);
@@ -975,6 +994,7 @@ export const useAppearanceSettingsStore = defineStore(
isHideFriendsInSameInstance,
isSidebarDivideByFriendGroup,
sidebarFavoriteGroups,
sidebarFavoriteGroupOrder,
hideUserNotes,
hideUserMemos,
hideUnfriends,
@@ -1013,6 +1033,7 @@ export const useAppearanceSettingsStore = defineStore(
setIsHideFriendsInSameInstance,
setIsSidebarDivideByFriendGroup,
setSidebarFavoriteGroups,
setSidebarFavoriteGroupOrder,
setHideUserNotes,
setHideUserMemos,
setHideUnfriends,

View File

@@ -86,7 +86,7 @@
class="absolute top-1 right-1.25 size-1.5 rounded-full bg-red-500" />
</Button>
</TooltipWrapper>
<Popover>
<Popover v-model:open="isSettingsPopoverOpen">
<PopoverTrigger as-child>
<Button class="rounded-full" variant="ghost" size="icon-sm">
<Settings />
@@ -112,6 +112,17 @@
:model-value="isSidebarDivideByFriendGroup"
@update:modelValue="setIsSidebarDivideByFriendGroup" />
</div>
<Button
v-if="isSidebarDivideByFriendGroup"
variant="outline"
size="sm"
class="w-full text-xs"
@click="
isSettingsPopoverOpen = false;
isGroupOrderSheetOpen = true;
">
{{ t('side_panel.settings.edit_group_order') }}
</Button>
<div class="flex flex-col gap-1.5">
<span>{{ t('side_panel.settings.favorite_groups') }}</span>
<Select
@@ -248,6 +259,7 @@
</template>
</TabsUnderline>
<NotificationCenterSheet />
<GroupOrderSheet v-model:open="isGroupOrderSheetOpen" />
</div>
</template>
@@ -285,6 +297,7 @@
import { debounce, userImage } from '../../shared/utils';
import FriendsSidebar from './components/FriendsSidebar.vue';
import GroupOrderSheet from './components/GroupOrderSheet.vue';
import GroupsSidebar from './components/GroupsSidebar.vue';
import NotificationCenterSheet from './components/NotificationCenterSheet.vue';
@@ -357,6 +370,8 @@
});
const CLEAR_VALUE = '__clear__';
const isGroupOrderSheetOpen = ref(false);
const isSettingsPopoverOpen = ref(false);
const sortOptions = computed(() => [
{ value: 'Sort Alphabetically', label: t('view.settings.appearance.side_panel.sorting.alphabetical') },

View File

@@ -120,7 +120,8 @@
isSidebarGroupByInstance,
isHideFriendsInSameInstance,
isSidebarDivideByFriendGroup,
sidebarFavoriteGroups
sidebarFavoriteGroups,
sidebarFavoriteGroupOrder
} = storeToRefs(useAppearanceSettingsStore());
const { gameLogDisabled } = storeToRefs(useAdvancedSettingsStore());
const { showUserDialog } = useUserStore();
@@ -228,7 +229,15 @@
}
}
return result.sort((a, b) => a[0].key.localeCompare(b[0].key));
const order = sidebarFavoriteGroupOrder.value;
return result.sort((a, b) => {
const idxA = order.indexOf(a[0]?.key);
const idxB = order.indexOf(b[0]?.key);
if (idxA !== -1 && idxB !== -1) return idxA - idxB;
if (idxA !== -1) return -1;
if (idxB !== -1) return 1;
return (a[0]?.key ?? '').localeCompare(b[0]?.key ?? '');
});
});
const buildToggleRow = ({

View File

@@ -0,0 +1,132 @@
<template>
<Sheet v-model:open="isOpen">
<SheetContent side="right" class="w-80 flex flex-col" @open-auto-focus.prevent>
<SheetHeader>
<SheetTitle>{{ t('side_panel.settings.edit_group_order') }}</SheetTitle>
</SheetHeader>
<div class="flex-1 overflow-auto p-3">
<DragDropProvider @dragEnd="onDragEnd">
<div class="flex flex-col gap-1.5">
<SortableGroupItem
v-for="(item, index) in localOrder"
:key="item.key"
:id="item.key"
:index="index"
:label="item.displayName" />
</div>
</DragDropProvider>
</div>
<div class="flex justify-end gap-2 border-t p-3">
<Button variant="secondary" size="sm" @click="resetOrder">
{{ t('common.actions.reset') }}
</Button>
<Button size="sm" @click="confirmOrder">
{{ t('common.actions.confirm') }}
</Button>
</div>
</SheetContent>
</Sheet>
</template>
<script setup>
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { computed, ref, watch } from 'vue';
import { Button } from '@/components/ui/button';
import { DragDropProvider } from '@dnd-kit/vue';
import { isSortable } from '@dnd-kit/vue/sortable';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';
import { useAppearanceSettingsStore, useFavoriteStore } from '../../../stores';
import SortableGroupItem from './SortableGroupItem.vue';
const isOpen = defineModel('open', { type: Boolean, default: false });
const { t } = useI18n();
const appearanceStore = useAppearanceSettingsStore();
const { sidebarFavoriteGroups, sidebarFavoriteGroupOrder } = storeToRefs(appearanceStore);
const { setSidebarFavoriteGroupOrder } = appearanceStore;
const favoriteStore = useFavoriteStore();
const { favoriteFriendGroups, localFriendFavoriteGroups } = storeToRefs(favoriteStore);
const allGroupItems = computed(() => {
const items = [];
for (const group of favoriteFriendGroups.value) {
items.push({ key: group.key, displayName: group.displayName });
}
for (const name of localFriendFavoriteGroups.value) {
items.push({ key: `local:${name}`, displayName: name });
}
return items;
});
const selectedGroupKeys = computed(() => {
if (sidebarFavoriteGroups.value.length === 0) {
return allGroupItems.value.map((g) => g.key);
}
return sidebarFavoriteGroups.value;
});
const localOrder = ref([]);
function buildOrderedList() {
const selected = new Set(selectedGroupKeys.value);
const persistedOrder = sidebarFavoriteGroupOrder.value;
const itemMap = new Map(allGroupItems.value.map((g) => [g.key, g]));
const ordered = [];
for (const key of persistedOrder) {
if (selected.has(key) && itemMap.has(key)) {
ordered.push(itemMap.get(key));
selected.delete(key);
}
}
for (const key of selectedGroupKeys.value) {
if (selected.has(key) && itemMap.has(key)) {
ordered.push(itemMap.get(key));
}
}
return ordered;
}
watch(isOpen, (open) => {
if (open) {
localOrder.value = buildOrderedList();
}
});
function onDragEnd(event) {
if (event.canceled) return;
const { source } = event.operation;
if (isSortable(source)) {
const { initialIndex, index } = source;
if (initialIndex !== index) {
const newOrder = [...localOrder.value];
const [removed] = newOrder.splice(initialIndex, 1);
newOrder.splice(index, 0, removed);
localOrder.value = newOrder;
}
}
}
function confirmOrder() {
const currentKeys = localOrder.value.map((g) => g.key);
const persistedOrder = sidebarFavoriteGroupOrder.value;
const merged = [...currentKeys];
for (const key of persistedOrder) {
if (!merged.includes(key)) {
merged.push(key);
}
}
setSidebarFavoriteGroupOrder(merged);
isOpen.value = false;
}
function resetOrder() {
setSidebarFavoriteGroupOrder([]);
localOrder.value = buildOrderedList();
}
</script>

View File

@@ -0,0 +1,29 @@
<script setup>
import { computed, ref } from 'vue';
import { GripVertical } from 'lucide-vue-next';
import { useSortable } from '@dnd-kit/vue/sortable';
const props = defineProps({
id: { type: String, required: true },
index: { type: Number, required: true },
label: { type: String, required: true }
});
const element = ref(null);
const { isDragSource } = useSortable({
id: computed(() => props.id),
index: computed(() => props.index),
element
});
</script>
<template>
<div
ref="element"
class="flex items-center gap-2 rounded-md border bg-background px-3 py-2 text-sm select-none cursor-grab active:cursor-grabbing"
:class="{ 'opacity-50': isDragSource }">
<GripVertical class="size-4 shrink-0 text-muted-foreground" />
<span class="truncate">{{ label }}</span>
</div>
</template>