fix(ui): clarify multi-server scope counts

This commit is contained in:
cucadmuh
2026-07-21 12:22:52 +03:00
parent ac83f88547
commit 62c30a0d63
23 changed files with 83 additions and 40 deletions
+1
View File
@@ -52,6 +52,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* Psysonic tracks readiness, sync progress and reachability independently for every configured server. Available servers remain browsable while another selected server is offline, still indexing or recovering from an interrupted sync.
* Full and delta sync preserve source ownership through remaps, deletions and tombstones; no-op syncs avoid unnecessary catalogue refreshes, and stale multi-server state is repaired automatically.
* Scope indicators retain the total server count when one is unavailable, while the Library picker reports selected music libraries instead of server profiles.
## Fixed
+9 -6
View File
@@ -87,18 +87,21 @@ describe('ConnectionIndicator Library server selection', () => {
it('crosses out the selected count while an unavailable server is outside the effective scope', () => {
const { a, b } = setupServers();
useAuthStore.setState({ libraryBrowseServerIds: [a, b] });
const c = useAuthStore.getState().addServer({
name: 'Backup', url: 'https://backup.test', username: 'u', password: 'p',
});
useAuthStore.setState({ libraryBrowseServerIds: [a, b, c] });
setServerReachability(b, 'unavailable');
renderIndicator();
expect(screen.getByText('2 servers').tagName).toBe('DEL');
expect(screen.getByText('1 servers')).toBeInTheDocument();
expect(useAuthStore.getState().libraryBrowseServerIds).toEqual([a, b]);
expect(screen.getByText('3').tagName).toBe('DEL');
expect(screen.getByText('3').closest('.connection-server-count')).toHaveTextContent('3→2 servers');
expect(useAuthStore.getState().libraryBrowseServerIds).toEqual([a, b, c]);
act(() => setServerReachability(b, 'available'));
expect(screen.getByText('2 servers').tagName).toBe('SPAN');
expect(screen.queryByText('1 servers')).not.toBeInTheDocument();
expect(screen.getByText('3 servers').tagName).toBe('SPAN');
expect(screen.queryByText('3→2 servers')).not.toBeInTheDocument();
});
});
+11 -8
View File
@@ -1,7 +1,7 @@
import type React from 'react';
import { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { Trans, useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { Check, ChevronDown, RefreshCw } from 'lucide-react';
import type { ConnectionStatus } from '@/lib/hooks/useConnectionStatus';
@@ -147,13 +147,16 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
const label = multiLibraryScope ? t('connection.multiServer') : (isLan ? 'LAN' : t('connection.extern'));
const displayedServerName = multiLibraryScope ? (
unavailableSelection ? (
<>
<del className="connection-server-count--unavailable">
{t('sidebar.serverSelectionCount', { count: libraryBrowseServerIds.length })}
</del>
<span className="connection-server-count-arrow" aria-hidden></span>
<span>{t('sidebar.serverSelectionCount', { count: effectiveLibraryServerIds.length })}</span>
</>
<Trans
i18nKey="sidebar.serverAvailabilityCount"
values={{
total: libraryBrowseServerIds.length,
available: effectiveLibraryServerIds.length,
}}
components={{
unavailable: <del className="connection-server-count--unavailable" />,
}}
/>
) : t('sidebar.serverSelectionCount', { count: libraryBrowseServerIds.length })
) : serverName;
const tooltip = pullInFlight
@@ -145,16 +145,6 @@ export default function Sidebar({
if (legacy === undefined || legacy === 'all') return EMPTY_LIBRARY_IDS;
return [legacy];
}, [serverId, musicLibrarySelectionByServer, musicLibraryFilterByServer]);
const selectionSummary = useMemo(() => {
if (libraryGroups.length > 1) return t('sidebar.serverSelectionCount', { count: libraryGroups.length });
const group = libraryGroups[0];
if (!group || group.selectedLibraryIds.length === 0) return null;
if (group.selectedLibraryIds.length === 1) {
return group.folders.find(folder => folder.id === group.selectedLibraryIds[0])?.name ?? null;
}
return t('sidebar.librarySelectionCount', { count: group.selectedLibraryIds.length });
}, [libraryGroups, t]);
const libraryItemsForReorder = useMemo(
() => getLibraryItemsForReorder(sidebarItems, randomNavMode),
[sidebarItems, randomNavMode],
@@ -314,7 +304,6 @@ export default function Sidebar({
isCollapsed={isCollapsed}
showLibraryPicker={showLibraryPicker}
libraryGroups={libraryGroups}
selectionSummary={selectionSummary}
libraryDropdownOpen={libraryDropdownOpen}
setLibraryDropdownOpen={setLibraryDropdownOpen}
dropdownRect={dropdownRect}
@@ -28,7 +28,6 @@ function renderPicker(
folders,
selectedLibraryIds: [] as string[],
}],
selectionSummary: null as string | null,
libraryDropdownOpen: true,
setLibraryDropdownOpen,
dropdownRect: { top: 0, left: 0, width: 240 },
@@ -46,7 +45,6 @@ describe('SidebarLibraryPicker', () => {
it('shows the folder name when exactly one library is selected', () => {
renderPicker({
groups: [{ serverId: 'server-a', serverLabel: 'Home', folders, selectedLibraryIds: ['lib-b'] }],
selectionSummary: 'Jazz',
libraryDropdownOpen: false,
});
@@ -56,13 +54,43 @@ describe('SidebarLibraryPicker', () => {
it('shows the multi-library count summary', () => {
renderPicker({
groups: [{ serverId: 'server-a', serverLabel: 'Home', folders, selectedLibraryIds: ['lib-a', 'lib-c'] }],
selectionSummary: '2 libraries',
libraryDropdownOpen: false,
});
expect(screen.getByText('2 libraries')).toBeInTheDocument();
});
it('shows All libraries when every server uses its complete library scope', () => {
renderPicker({
groups: [
{ serverId: 'server-a', serverLabel: 'Home', folders, selectedLibraryIds: [] },
{ serverId: 'server-b', serverLabel: 'Remote', folders: [{ id: 'lib-d', name: 'Live' }], selectedLibraryIds: [] },
],
libraryDropdownOpen: false,
});
expect(screen.getByText('All libraries')).toBeInTheDocument();
expect(screen.queryByText('2 servers')).not.toBeInTheDocument();
});
it('counts selected libraries across servers instead of counting servers', () => {
renderPicker({
groups: [
{ serverId: 'server-a', serverLabel: 'Home', folders, selectedLibraryIds: [] },
{
serverId: 'server-b',
serverLabel: 'Remote',
folders: [{ id: 'lib-d', name: 'Live' }, { id: 'lib-e', name: 'Archive' }],
selectedLibraryIds: ['lib-d', 'lib-e'],
},
],
libraryDropdownOpen: false,
});
expect(screen.getByText('5 libraries')).toBeInTheDocument();
expect(screen.queryByText('2 servers')).not.toBeInTheDocument();
});
it('clears the selection when All libraries is chosen', async () => {
const user = userEvent.setup();
const { onSelectionChange, setLibraryDropdownOpen } = renderPicker({
@@ -1,4 +1,4 @@
import React, { useLayoutEffect, useRef, useState } from 'react';
import React, { useLayoutEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { Check, ChevronDown, Music2 } from 'lucide-react';
@@ -13,7 +13,6 @@ export interface SidebarLibraryGroup {
interface Props {
groups: SidebarLibraryGroup[];
selectionSummary: string | null;
libraryDropdownOpen: boolean;
setLibraryDropdownOpen: (open: boolean) => void;
dropdownRect: { top: number; left: number; width: number };
@@ -23,7 +22,6 @@ interface Props {
export default function SidebarLibraryPicker({
groups,
selectionSummary,
libraryDropdownOpen,
setLibraryDropdownOpen,
dropdownRect,
@@ -35,6 +33,21 @@ export default function SidebarLibraryPicker({
const [panelWidth, setPanelWidth] = useState<number | null>(null);
const folderCount = groups.reduce((count, group) => count + group.folders.length, 0);
const hasExplicitSelection = groups.some(group => group.selectedLibraryIds.length > 0);
const selectionSummary = useMemo(() => {
if (groups.length === 0) return null;
if (!hasExplicitSelection) return t('sidebar.allLibraries');
if (groups.length === 1) {
const group = groups[0];
if (group.selectedLibraryIds.length === 1) {
return group.folders.find(folder => folder.id === group.selectedLibraryIds[0])?.name ?? null;
}
}
const selectedLibraryCount = groups.reduce(
(count, group) => count + (group.selectedLibraryIds.length || group.folders.length),
0,
);
return t('sidebar.librarySelectionCount', { count: selectedLibraryCount });
}, [groups, hasExplicitSelection, t]);
useLayoutEffect(() => {
if (!libraryDropdownOpen) {
@@ -17,7 +17,6 @@ const baseProps = () => ({
],
selectedLibraryIds: [] as string[],
}],
selectionSummary: null as string | null,
libraryDropdownOpen: false,
setLibraryDropdownOpen: vi.fn(),
dropdownRect: { top: 0, left: 0, width: 240 },
@@ -22,7 +22,6 @@ interface Props {
isCollapsed: boolean;
showLibraryPicker: boolean;
libraryGroups: SidebarLibraryGroup[];
selectionSummary: string | null;
libraryDropdownOpen: boolean;
setLibraryDropdownOpen: (open: boolean) => void;
dropdownRect: { top: number; left: number; width: number };
@@ -57,7 +56,7 @@ interface Props {
export default function SidebarNavBody(props: Props) {
const {
isCollapsed, showLibraryPicker, libraryGroups, selectionSummary,
isCollapsed, showLibraryPicker, libraryGroups,
libraryDropdownOpen, setLibraryDropdownOpen, dropdownRect, libraryTriggerRef,
onLibrarySelectionChange,
visibleLibraryConfigs,
@@ -94,7 +93,6 @@ export default function SidebarNavBody(props: Props) {
{!isCollapsed && (showLibraryPicker ? (
<SidebarLibraryPicker
groups={libraryGroups}
selectionSummary={selectionSummary}
libraryDropdownOpen={libraryDropdownOpen}
setLibraryDropdownOpen={setLibraryDropdownOpen}
dropdownRect={dropdownRect}
+1
View File
@@ -38,6 +38,7 @@ export const sidebar = {
libraryDeselect: 'Изключи {{name}}',
librarySelectionCount: '{{count}} библиотеки',
serverSelectionCount: '{{count}} сървъра',
serverAvailabilityCount: '<unavailable>{{total}}</unavailable>→{{available}} сървъра',
expandPlaylists: 'Разшири плейлисти',
collapsePlaylists: 'Свий плейлисти',
more: 'Още',
+1
View File
@@ -34,6 +34,7 @@ export const sidebar = {
libraryDeselect: '{{name}} ausschließen',
librarySelectionCount: '{{count}} Bibliotheken',
serverSelectionCount: '{{count}} Server',
serverAvailabilityCount: '<unavailable>{{total}}</unavailable>→{{available}} Server',
expandPlaylists: 'Playlists ausklappen',
collapsePlaylists: 'Playlists einklappen',
more: 'Mehr',
+1
View File
@@ -38,6 +38,7 @@ export const sidebar = {
libraryDeselect: 'Exclude {{name}}',
librarySelectionCount: '{{count}} libraries',
serverSelectionCount: '{{count}} servers',
serverAvailabilityCount: '<unavailable>{{total}}</unavailable>→{{available}} servers',
expandPlaylists: 'Expand playlists',
collapsePlaylists: 'Collapse playlists',
more: 'More',
+1
View File
@@ -35,6 +35,7 @@ export const sidebar = {
libraryDeselect: 'Excluir {{name}}',
librarySelectionCount: '{{count}} bibliotecas',
serverSelectionCount: '{{count}} servidores',
serverAvailabilityCount: '<unavailable>{{total}}</unavailable>→{{available}} servidores',
expandPlaylists: 'Expandir listas',
collapsePlaylists: 'Colapsar listas',
more: 'Más',
+1
View File
@@ -34,6 +34,7 @@ export const sidebar = {
libraryDeselect: 'Exclure {{name}}',
librarySelectionCount: '{{count}} bibliothèques',
serverSelectionCount: '{{count}} serveurs',
serverAvailabilityCount: '<unavailable>{{total}}</unavailable>→{{available}} serveurs',
expandPlaylists: 'Développer les playlists',
collapsePlaylists: 'Réduire les playlists',
more: 'Plus',
+1
View File
@@ -38,6 +38,7 @@ export const sidebar = {
libraryDeselect: '{{name}} kizárása',
librarySelectionCount: '{{count}} könyvtár',
serverSelectionCount: '{{count}} szerver',
serverAvailabilityCount: '<unavailable>{{total}}</unavailable>→{{available}} szerver',
expandPlaylists: 'Lejátszási listák kibontása',
collapsePlaylists: 'Lejátszási listák összecsukása',
more: 'Több',
+1
View File
@@ -38,6 +38,7 @@ export const sidebar = {
libraryDeselect: 'Escludi {{name}}',
librarySelectionCount: '{{count}} librerie',
serverSelectionCount: '{{count}} server',
serverAvailabilityCount: '<unavailable>{{total}}</unavailable>→{{available}} server',
expandPlaylists: 'Espandi playlist',
collapsePlaylists: 'Riduci playlist',
more: 'Altro',
+1
View File
@@ -38,6 +38,7 @@ export const sidebar = {
libraryDeselect: '{{name}} を除外',
librarySelectionCount: '{{count}} 個のライブラリ',
serverSelectionCount: '{{count}} 台のサーバー',
serverAvailabilityCount: '<unavailable>{{total}}</unavailable>→{{available}} 台のサーバー',
expandPlaylists: 'プレイリストを展開',
collapsePlaylists: 'プレイリストを折りたたむ',
more: 'その他',
+1
View File
@@ -34,6 +34,7 @@ export const sidebar = {
libraryDeselect: 'Ekskluder {{name}}',
librarySelectionCount: '{{count}} biblioteker',
serverSelectionCount: '{{count}} servere',
serverAvailabilityCount: '<unavailable>{{total}}</unavailable>→{{available}} servere',
expandPlaylists: 'Utvid spillelister',
collapsePlaylists: 'Skjul spillelister',
more: 'Mer',
+1
View File
@@ -34,6 +34,7 @@ export const sidebar = {
libraryDeselect: '{{name}} uitsluiten',
librarySelectionCount: '{{count}} bibliotheken',
serverSelectionCount: '{{count}} servers',
serverAvailabilityCount: '<unavailable>{{total}}</unavailable>→{{available}} servers',
expandPlaylists: 'Afspeellijsten uitklappen',
collapsePlaylists: 'Afspeellijsten inklappen',
more: 'Meer',
+1
View File
@@ -38,6 +38,7 @@ export const sidebar = {
libraryDeselect: 'Wyklucz {{name}}',
librarySelectionCount: '{{count}} bibliotek',
serverSelectionCount: '{{count}} serwerów',
serverAvailabilityCount: '<unavailable>{{total}}</unavailable>→{{available}} serwerów',
expandPlaylists: 'Rozwiń playlisty',
collapsePlaylists: 'Zwiń playlisty',
more: 'Więcej',
+1
View File
@@ -36,6 +36,7 @@ export const sidebar = {
libraryDeselect: 'Exclude {{name}}',
librarySelectionCount: '{{count}} librării',
serverSelectionCount: '{{count}} servere',
serverAvailabilityCount: '<unavailable>{{total}}</unavailable>→{{available}} servere',
expandPlaylists: 'Extinde playlisturi',
collapsePlaylists: 'Restrânge playlisturi',
more: 'Mai mult',
+1
View File
@@ -37,6 +37,7 @@ export const sidebar = {
libraryDeselect: 'Исключить {{name}}',
librarySelectionCount: '{{count}} библиотек',
serverSelectionCount: '{{count}} серверов',
serverAvailabilityCount: '<unavailable>{{total}}</unavailable>→{{available}} серверов',
expandPlaylists: 'Развернуть плейлисты',
collapsePlaylists: 'Свернуть плейлисты',
more: 'Ещё',
+1
View File
@@ -34,6 +34,7 @@ export const sidebar = {
libraryDeselect: '排除 {{name}}',
librarySelectionCount: '{{count}} 个资料库',
serverSelectionCount: '{{count}} 台服务器',
serverAvailabilityCount: '<unavailable>{{total}}</unavailable>→{{available}} 台服务器',
expandPlaylists: '展开播放列表',
collapsePlaylists: '收起播放列表',
more: '更多',
@@ -188,7 +188,6 @@
display: inline-flex;
min-width: 0;
align-items: center;
gap: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -199,10 +198,6 @@
text-decoration-thickness: 1px;
}
.connection-server-count-arrow {
opacity: 0.75;
}
/* Server card actions collapse to icon-only on narrow viewports — labels
* remain available as native tooltips via the existing `data-tooltip` /
* `aria-label` attributes. Breakpoint is tuned to where the action row