mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
fix(ui): clarify multi-server scope counts
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -38,6 +38,7 @@ export const sidebar = {
|
||||
libraryDeselect: 'Изключи {{name}}',
|
||||
librarySelectionCount: '{{count}} библиотеки',
|
||||
serverSelectionCount: '{{count}} сървъра',
|
||||
serverAvailabilityCount: '<unavailable>{{total}}</unavailable>→{{available}} сървъра',
|
||||
expandPlaylists: 'Разшири плейлисти',
|
||||
collapsePlaylists: 'Свий плейлисти',
|
||||
more: 'Още',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -38,6 +38,7 @@ export const sidebar = {
|
||||
libraryDeselect: '{{name}} を除外',
|
||||
librarySelectionCount: '{{count}} 個のライブラリ',
|
||||
serverSelectionCount: '{{count}} 台のサーバー',
|
||||
serverAvailabilityCount: '<unavailable>{{total}}</unavailable>→{{available}} 台のサーバー',
|
||||
expandPlaylists: 'プレイリストを展開',
|
||||
collapsePlaylists: 'プレイリストを折りたたむ',
|
||||
more: 'その他',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -37,6 +37,7 @@ export const sidebar = {
|
||||
libraryDeselect: 'Исключить {{name}}',
|
||||
librarySelectionCount: '{{count}} библиотек',
|
||||
serverSelectionCount: '{{count}} серверов',
|
||||
serverAvailabilityCount: '<unavailable>{{total}}</unavailable>→{{available}} серверов',
|
||||
expandPlaylists: 'Развернуть плейлисты',
|
||||
collapsePlaylists: 'Свернуть плейлисты',
|
||||
more: 'Ещё',
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user