mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 23:35:44 +00:00
fix(ui): refine server choice popovers
This commit is contained in:
+1
-1
@@ -35,7 +35,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
* Playlist creation, smart-playlist editing, radio actions and other destination-sensitive flows select the target server explicitly instead of silently using the active server.
|
||||
* Context menus, ratings, favourites, sharing, offline pins, device sync and Orbit carry the item's owner through the complete action. Creating an Orbit session from a multi-server scope now asks which server should host it, temporarily keeps only that server in the library scope and shared queue, then restores the previous scope when the session ends.
|
||||
* Sharing a mixed-server queue asks which server's tracks to include in the link; single-server queues still copy immediately without an extra prompt.
|
||||
* Sharing a mixed-server queue opens a compact server picker directly below the share button; choosing a server copies its tracks immediately, while single-server queues still copy without an extra prompt. Unavailable servers are marked with an explanatory warning.
|
||||
|
||||
## Changed
|
||||
|
||||
|
||||
@@ -5,10 +5,15 @@ import { renderWithProviders } from '@/test/helpers/renderWithProviders';
|
||||
import { resetAuthStore } from '@/test/helpers/storeReset';
|
||||
import { makeServer } from '@/test/helpers/factories';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import {
|
||||
resetServerReachabilitySnapshot,
|
||||
setServerReachability,
|
||||
} from '@/lib/network/serverReachability';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
startOrbitSession: vi.fn(),
|
||||
}));
|
||||
let secondServerId = '';
|
||||
|
||||
vi.mock('@/features/orbit/utils/orbit', () => ({
|
||||
buildOrbitShareLink: (serverBase: string, sid: string) => `psysonic2-orbit:${serverBase}:${sid}`,
|
||||
@@ -23,9 +28,11 @@ import OrbitStartModal from '@/features/orbit/components/OrbitStartModal';
|
||||
|
||||
beforeEach(() => {
|
||||
resetAuthStore();
|
||||
resetServerReachabilitySnapshot();
|
||||
mocks.startOrbitSession.mockReset().mockResolvedValue({});
|
||||
const first = makeServer({ id: 'srv-a', name: 'Server A', url: 'https://a.example' });
|
||||
const second = makeServer({ id: 'srv-b', name: 'Server B', url: 'https://b.example' });
|
||||
secondServerId = second.id;
|
||||
useAuthStore.setState({
|
||||
servers: [first, second],
|
||||
activeServerId: first.id,
|
||||
@@ -48,4 +55,17 @@ describe('OrbitStartModal', () => {
|
||||
clearQueue: false,
|
||||
}));
|
||||
});
|
||||
|
||||
it('shows unavailable servers with a warning and no empty checkbox squares', () => {
|
||||
setServerReachability(secondServerId, 'unavailable');
|
||||
|
||||
const { container } = renderWithProviders(<OrbitStartModal onClose={vi.fn()} />);
|
||||
|
||||
const unavailable = screen.getByRole('radio', { name: /Server B.*Cannot reach Server B/ });
|
||||
expect(unavailable.querySelector('.server-choice-warning')).toHaveAttribute(
|
||||
'data-tooltip',
|
||||
'Cannot reach Server B. Check your network or server.',
|
||||
);
|
||||
expect(container.querySelector('.nav-library-dropdown-item-toggle-box')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ import { isLanUrl, serverShareBaseUrl } from '@/lib/server/serverEndpoint';
|
||||
import { serverListDisplayLabel } from '@/lib/server/serverDisplayName';
|
||||
import { ORBIT_DEFAULT_MAX_USERS } from '@/features/orbit/api/orbit';
|
||||
import ServerChoiceList from '@/ui/ServerChoiceList';
|
||||
import { useUnavailableServerIds } from '@/lib/network/serverReachability';
|
||||
|
||||
interface Props { onClose: () => void; }
|
||||
|
||||
@@ -40,6 +41,7 @@ export default function OrbitStartModal({ onClose }: Props) {
|
||||
|
||||
const servers = useAuthStore(state => state.servers);
|
||||
const libraryBrowseServerIds = useAuthStore(state => state.libraryBrowseServerIds);
|
||||
const unavailableServerIds = useUnavailableServerIds();
|
||||
const [serverId, setServerId] = useState(() => {
|
||||
const auth = useAuthStore.getState();
|
||||
return auth.libraryBrowseServerIds.includes(auth.activeServerId ?? '')
|
||||
@@ -48,7 +50,16 @@ export default function OrbitStartModal({ onClose }: Props) {
|
||||
});
|
||||
const serverOptions = servers
|
||||
.filter(server => libraryBrowseServerIds.includes(server.id))
|
||||
.map(server => ({ id: server.id, label: serverListDisplayLabel(server, servers) }));
|
||||
.map(server => {
|
||||
const label = serverListDisplayLabel(server, servers);
|
||||
return {
|
||||
id: server.id,
|
||||
label,
|
||||
warning: unavailableServerIds.has(server.id)
|
||||
? t('connection.offlineSubtitle', { server: label })
|
||||
: undefined,
|
||||
};
|
||||
});
|
||||
const server = servers.find(candidate => candidate.id === serverId);
|
||||
// Orbit links go to remote guests — use the share URL (public by default
|
||||
// when both are set; LAN only if shareUsesLocalUrl is on). The LAN warning
|
||||
|
||||
@@ -19,7 +19,6 @@ import { NowPlayingInfo } from '@/features/nowPlaying';
|
||||
import { useLuckyMixStore } from '@/features/randomMix';
|
||||
import { useQueueToolbarStore } from '@/store/queueToolbarStore';
|
||||
import { SavePlaylistModal } from '@/features/queue/components/SavePlaylistModal';
|
||||
import { ShareQueueModal } from '@/features/queue/components/ShareQueueModal';
|
||||
import { LoadPlaylistModal } from '@/features/queue/components/LoadPlaylistModal';
|
||||
import { QueueHeader } from '@/features/queue/components/QueueHeader';
|
||||
import { QueueCurrentTrack } from '@/features/queue/components/QueueCurrentTrack';
|
||||
@@ -183,10 +182,10 @@ function QueuePanelHostOrSolo() {
|
||||
const {
|
||||
serverOptions: queueServerOptions,
|
||||
defaultServerId: defaultQueueServerId,
|
||||
shareModalOpen,
|
||||
sharePickerOpen,
|
||||
handleCopy: handleCopyQueueShare,
|
||||
shareForServer,
|
||||
closeShareModal,
|
||||
closeSharePicker,
|
||||
} = useQueueShare({
|
||||
queueItems,
|
||||
servers,
|
||||
@@ -249,7 +248,7 @@ function QueuePanelHostOrSolo() {
|
||||
clearQueue();
|
||||
setActivePlaylist(null);
|
||||
setSaveModalOpen(false);
|
||||
closeShareModal();
|
||||
closeSharePicker();
|
||||
};
|
||||
|
||||
// Queue mode shows upcoming tracks only — the current track lives in the
|
||||
@@ -369,6 +368,11 @@ function QueuePanelHostOrSolo() {
|
||||
handleSave={handleSave}
|
||||
handleLoad={handleLoad}
|
||||
handleCopyQueueShare={handleCopyQueueShare}
|
||||
sharePickerOpen={sharePickerOpen}
|
||||
queueServerOptions={queueServerOptions}
|
||||
defaultQueueServerId={defaultQueueServerId}
|
||||
shareForServer={shareForServer}
|
||||
closeSharePicker={closeSharePicker}
|
||||
handleClear={handleClear}
|
||||
publicShareQueueActive={publicShareQueueActive}
|
||||
gaplessEnabled={gaplessEnabled}
|
||||
@@ -454,15 +458,6 @@ function QueuePanelHostOrSolo() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{shareModalOpen && (
|
||||
<ShareQueueModal
|
||||
onClose={closeShareModal}
|
||||
serverOptions={queueServerOptions}
|
||||
initialServerId={defaultQueueServerId}
|
||||
onShare={shareForServer}
|
||||
/>
|
||||
)}
|
||||
|
||||
{loadModalOpen && (
|
||||
<LoadPlaylistModal
|
||||
onClose={() => {
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Share2 } from 'lucide-react';
|
||||
import type { ServerChoiceOption } from '@/ui/ServerChoiceList';
|
||||
import { ServerChoiceWarning } from '@/ui/ServerChoiceList';
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
open: boolean;
|
||||
options: ServerChoiceOption[];
|
||||
initialServerId: string;
|
||||
onTrigger: () => void;
|
||||
onClose: () => void;
|
||||
onShare: (serverId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function QueueShareButton({
|
||||
label,
|
||||
open,
|
||||
options,
|
||||
initialServerId,
|
||||
onTrigger,
|
||||
onClose,
|
||||
onShare,
|
||||
}: Props) {
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const [position, setPosition] = useState({ top: 0, left: 0, ready: false });
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
const updatePosition = () => {
|
||||
const trigger = triggerRef.current;
|
||||
const panel = panelRef.current;
|
||||
if (!trigger || !panel) return;
|
||||
const triggerRect = trigger.getBoundingClientRect();
|
||||
const panelWidth = panel.offsetWidth;
|
||||
const margin = 8;
|
||||
const idealLeft = triggerRect.left + triggerRect.width / 2 - panelWidth / 2;
|
||||
const left = Math.max(margin, Math.min(idealLeft, window.innerWidth - panelWidth - margin));
|
||||
setPosition({ top: triggerRect.bottom + 8, left, ready: true });
|
||||
};
|
||||
updatePosition();
|
||||
window.addEventListener('resize', updatePosition);
|
||||
window.addEventListener('scroll', updatePosition, true);
|
||||
return () => {
|
||||
window.removeEventListener('resize', updatePosition);
|
||||
window.removeEventListener('scroll', updatePosition, true);
|
||||
};
|
||||
}, [open, options]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (triggerRef.current?.contains(target) || panelRef.current?.contains(target)) return;
|
||||
onClose();
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape') return;
|
||||
onClose();
|
||||
triggerRef.current?.focus();
|
||||
};
|
||||
document.addEventListener('mousedown', handlePointerDown);
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handlePointerDown);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [onClose, open]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className={`queue-round-btn${open ? ' active' : ''}`}
|
||||
onClick={onTrigger}
|
||||
data-tooltip={open ? undefined : label}
|
||||
aria-label={label}
|
||||
aria-haspopup={options.length > 1 ? 'menu' : undefined}
|
||||
aria-expanded={options.length > 1 ? open : undefined}
|
||||
>
|
||||
<Share2 size={13} />
|
||||
</button>
|
||||
{open && createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="nav-library-dropdown-panel queue-share-popover"
|
||||
role="menu"
|
||||
aria-label={label}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: position.top,
|
||||
left: position.left,
|
||||
visibility: position.ready ? 'visible' : 'hidden',
|
||||
}}
|
||||
>
|
||||
{options.map(server => (
|
||||
<button
|
||||
key={server.id}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="queue-share-server-item"
|
||||
aria-label={server.warning ? `${server.label}. ${server.warning}` : undefined}
|
||||
autoFocus={server.id === initialServerId}
|
||||
onClick={() => { void onShare(server.id); }}
|
||||
>
|
||||
<span className="queue-share-server-label">{server.label}</span>
|
||||
<ServerChoiceWarning warning={server.warning} />
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Blend, Check, FolderOpen, Infinity as InfinityIcon, ListMusic, MoveRight, Save, Share2, Shuffle, Trash2, Waves,
|
||||
Blend, Check, FolderOpen, Infinity as InfinityIcon, ListMusic, MoveRight, Save, Shuffle, Trash2, Waves,
|
||||
} from 'lucide-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { QueueItemRef } from '@/lib/media/trackTypes';
|
||||
@@ -10,6 +10,8 @@ import type {
|
||||
} from '@/store/queueToolbarStore';
|
||||
import { getTransitionMode, setTransitionMode } from '@/features/playback/utils/playback/playbackTransition';
|
||||
import { useOrbitStore } from '@/features/orbit';
|
||||
import { QueueShareButton } from '@/features/queue/components/QueueShareButton';
|
||||
import type { ServerChoiceOption } from '@/ui/ServerChoiceList';
|
||||
|
||||
interface Props {
|
||||
queue: QueueItemRef[];
|
||||
@@ -20,6 +22,11 @@ interface Props {
|
||||
handleSave: () => void;
|
||||
handleLoad: () => void;
|
||||
handleCopyQueueShare: () => void;
|
||||
sharePickerOpen: boolean;
|
||||
queueServerOptions: ServerChoiceOption[];
|
||||
defaultQueueServerId: string;
|
||||
shareForServer: (serverId: string) => Promise<void>;
|
||||
closeSharePicker: () => void;
|
||||
handleClear: () => void;
|
||||
publicShareQueueActive: boolean;
|
||||
gaplessEnabled: boolean;
|
||||
@@ -34,7 +41,9 @@ interface Props {
|
||||
|
||||
export function QueueToolbar({
|
||||
queue, activePlaylist, saveState, toolbarButtons, shuffleQueue,
|
||||
handleSave, handleLoad, handleCopyQueueShare, handleClear,
|
||||
handleSave, handleLoad, handleCopyQueueShare,
|
||||
sharePickerOpen, queueServerOptions, defaultQueueServerId, shareForServer, closeSharePicker,
|
||||
handleClear,
|
||||
publicShareQueueActive,
|
||||
gaplessEnabled, crossfadeEnabled, crossfadeTrimSilence,
|
||||
crossfadeSecs, setCrossfadeSecs,
|
||||
@@ -126,15 +135,20 @@ export function QueueToolbar({
|
||||
);
|
||||
case 'share':
|
||||
return (
|
||||
<button
|
||||
<QueueShareButton
|
||||
key={btn.id}
|
||||
className="queue-round-btn"
|
||||
onClick={() => void handleCopyQueueShare()}
|
||||
data-tooltip={publicShareQueueActive ? t('queue.shareNavidromePublic') : t('queue.shareQueue')}
|
||||
aria-label={publicShareQueueActive ? t('queue.shareNavidromePublic') : t('queue.shareQueue')}
|
||||
>
|
||||
<Share2 size={13} />
|
||||
</button>
|
||||
label={publicShareQueueActive ? t('queue.shareNavidromePublic') : t('queue.shareQueue')}
|
||||
open={sharePickerOpen}
|
||||
options={queueServerOptions}
|
||||
initialServerId={defaultQueueServerId}
|
||||
onTrigger={() => {
|
||||
setShowCrossfadePopover(false);
|
||||
setShowPlaylistMenu(false);
|
||||
handleCopyQueueShare();
|
||||
}}
|
||||
onClose={closeSharePicker}
|
||||
onShare={shareForServer}
|
||||
/>
|
||||
);
|
||||
case 'clear':
|
||||
return (
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Modal from '@/ui/Modal';
|
||||
import ServerChoiceList from '@/ui/ServerChoiceList';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onShare: (serverId: string) => Promise<void>;
|
||||
serverOptions: Array<{ id: string; label: string }>;
|
||||
initialServerId: string;
|
||||
}
|
||||
|
||||
export function ShareQueueModal({ onClose, onShare, serverOptions, initialServerId }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [serverId, setServerId] = useState(initialServerId);
|
||||
const [sharing, setSharing] = useState(false);
|
||||
|
||||
const submit = async () => {
|
||||
if (sharing || !serverId) return;
|
||||
setSharing(true);
|
||||
try {
|
||||
await onShare(serverId);
|
||||
} finally {
|
||||
setSharing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
onClose={() => { if (!sharing) onClose(); }}
|
||||
title={t('queue.shareQueue')}
|
||||
closeLabel={t('queue.close')}
|
||||
size="sm"
|
||||
footer={(
|
||||
<>
|
||||
<button type="button" className="btn btn-ghost" onClick={onClose} disabled={sharing}>
|
||||
{t('queue.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={() => { void submit(); }}
|
||||
disabled={sharing || !serverId}
|
||||
>
|
||||
{t('queue.shareQueue')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<ServerChoiceList
|
||||
value={serverId}
|
||||
options={serverOptions}
|
||||
onChange={setServerId}
|
||||
ariaLabel={t('settings.servers')}
|
||||
disabled={sharing}
|
||||
autoFocusSelected
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,10 @@ import { resetAllStores } from '@/test/helpers/storeReset';
|
||||
import { makeTrack, seedQueue } from '@/test/helpers/factories';
|
||||
import { onInvoke, registerDefaultCoverInvokeHandlers } from '@/test/mocks/tauri';
|
||||
import { decodeSharePayloadFromText } from '@/lib/share/shareLink';
|
||||
import {
|
||||
resetServerReachabilitySnapshot,
|
||||
setServerReachability,
|
||||
} from '@/lib/network/serverReachability';
|
||||
|
||||
const copyTextToClipboardMock = vi.fn(async (_text: string) => true);
|
||||
|
||||
@@ -48,6 +52,7 @@ function seedPublicShareQueue(pageUrl: string) {
|
||||
describe('QueuePanel share toolbar', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
resetServerReachabilitySnapshot();
|
||||
copyTextToClipboardMock.mockClear();
|
||||
const id = useAuthStore.getState().addServer({
|
||||
name: 'T', url: 'https://x.test', username: 'u', password: 'p',
|
||||
@@ -100,7 +105,7 @@ describe('QueuePanel share toolbar', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('prompts for a server when the queue is mixed and shares only that server\'s tracks', async () => {
|
||||
it('opens a compact server menu for a mixed queue and copies as soon as a server is clicked', async () => {
|
||||
const activeServerId = useAuthStore.getState().activeServerId!;
|
||||
const secondServerId = useAuthStore.getState().addServer({
|
||||
name: 'Second', url: 'https://second.test', username: 'u2', password: 'p2',
|
||||
@@ -112,17 +117,37 @@ describe('QueuePanel share toolbar', () => {
|
||||
const { getByLabelText, getByRole, queryByRole } = renderWithProviders(<QueuePanel />);
|
||||
fireEvent.click(getByLabelText('Copy queue share link'));
|
||||
|
||||
const dialog = getByRole('dialog');
|
||||
const menu = getByRole('menu', { name: 'Copy queue share link' });
|
||||
expect(copyTextToClipboardMock).not.toHaveBeenCalled();
|
||||
fireEvent.click(within(dialog).getByRole('radio', { name: 'Second' }));
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Copy queue share link' }));
|
||||
expect(queryByRole('dialog')).not.toBeInTheDocument();
|
||||
fireEvent.click(within(menu).getByRole('menuitem', { name: 'Second' }));
|
||||
|
||||
await waitFor(() => expect(copyTextToClipboardMock).toHaveBeenCalledOnce());
|
||||
expect(queryByRole('dialog')).not.toBeInTheDocument();
|
||||
expect(queryByRole('menu', { name: 'Copy queue share link' })).not.toBeInTheDocument();
|
||||
expect(decodeSharePayloadFromText(copyTextToClipboardMock.mock.calls[0]![0])).toEqual({
|
||||
srv: 'https://second.test',
|
||||
k: 'queue',
|
||||
ids: ['second-track'],
|
||||
});
|
||||
});
|
||||
|
||||
it('marks an unavailable server with an explanatory warning tooltip', () => {
|
||||
const activeServerId = useAuthStore.getState().activeServerId!;
|
||||
const secondServerId = useAuthStore.getState().addServer({
|
||||
name: 'Second', url: 'https://second.test', username: 'u2', password: 'p2',
|
||||
});
|
||||
const activeTrack = makeTrack({ id: 'active-track', serverId: activeServerId });
|
||||
const secondTrack = makeTrack({ id: 'second-track', serverId: secondServerId });
|
||||
seedQueue([activeTrack, secondTrack], { currentTrack: activeTrack, serverId: activeServerId });
|
||||
setServerReachability(secondServerId, 'unavailable');
|
||||
|
||||
const { getByLabelText, getByRole } = renderWithProviders(<QueuePanel />);
|
||||
fireEvent.click(getByLabelText('Copy queue share link'));
|
||||
|
||||
const item = getByRole('menuitem', { name: /Second.*Cannot reach Second/ });
|
||||
expect(item.querySelector('.server-choice-warning')).toHaveAttribute(
|
||||
'data-tooltip',
|
||||
'Cannot reach Second. Check your network or server.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,8 @@ import { serverShareBaseUrl } from '@/lib/server/serverEndpoint';
|
||||
import { copyTextToClipboard } from '@/lib/server/serverMagicString';
|
||||
import { serverListDisplayLabel } from '@/lib/server/serverDisplayName';
|
||||
import { showToast } from '@/lib/dom/toast';
|
||||
import { useUnavailableServerIds } from '@/lib/network/serverReachability';
|
||||
import type { ServerChoiceOption } from '@/ui/ServerChoiceList';
|
||||
|
||||
interface Options {
|
||||
queueItems: QueueItemRef[];
|
||||
@@ -25,10 +27,20 @@ export function useQueueShare({
|
||||
navidromePublicSharePageUrl,
|
||||
}: Options) {
|
||||
const { t } = useTranslation();
|
||||
const [shareModalOpen, setShareModalOpen] = useState(false);
|
||||
const serverOptions = useMemo(() => servers
|
||||
const unavailableServerIds = useUnavailableServerIds();
|
||||
const [sharePickerOpen, setSharePickerOpen] = useState(false);
|
||||
const serverOptions = useMemo<ServerChoiceOption[]>(() => servers
|
||||
.filter(server => queueTrackIdsForServerProfile(queueItems, server.id).length > 0)
|
||||
.map(server => ({ id: server.id, label: serverListDisplayLabel(server, servers) })), [queueItems, servers]);
|
||||
.map(server => {
|
||||
const label = serverListDisplayLabel(server, servers);
|
||||
return {
|
||||
id: server.id,
|
||||
label,
|
||||
warning: unavailableServerIds.has(server.id)
|
||||
? t('connection.offlineSubtitle', { server: label })
|
||||
: undefined,
|
||||
};
|
||||
}), [queueItems, servers, t, unavailableServerIds]);
|
||||
const defaultServerId = activeServerId && serverOptions.some(server => server.id === activeServerId)
|
||||
? activeServerId
|
||||
: serverOptions[0]?.id ?? '';
|
||||
@@ -67,7 +79,7 @@ export function useQueueShare({
|
||||
return;
|
||||
}
|
||||
if (serverOptions.length > 1) {
|
||||
setShareModalOpen(true);
|
||||
setSharePickerOpen(open => !open);
|
||||
return;
|
||||
}
|
||||
await copyForServer(serverOptions[0]!.id);
|
||||
@@ -77,16 +89,16 @@ export function useQueueShare({
|
||||
try {
|
||||
if (serverOptions.some(server => server.id === serverId)) await copyForServer(serverId);
|
||||
} finally {
|
||||
setShareModalOpen(false);
|
||||
setSharePickerOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
serverOptions,
|
||||
defaultServerId,
|
||||
shareModalOpen,
|
||||
sharePickerOpen,
|
||||
handleCopy,
|
||||
shareForServer,
|
||||
closeShareModal: () => setShareModalOpen(false),
|
||||
closeSharePicker: () => setSharePickerOpen(false),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -202,6 +202,43 @@
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.queue-share-popover {
|
||||
width: max-content;
|
||||
min-width: 150px;
|
||||
max-width: calc(100vw - 16px);
|
||||
padding: var(--space-1);
|
||||
z-index: 10050;
|
||||
}
|
||||
|
||||
.queue-share-server-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.queue-share-server-item:hover,
|
||||
.queue-share-server-item:focus-visible {
|
||||
background: var(--menu-item-hover);
|
||||
}
|
||||
|
||||
.queue-share-server-label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.queue-toolbar-sep {
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
@@ -451,4 +488,3 @@
|
||||
padding: var(--space-3) var(--space-4) 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -288,6 +288,33 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.server-choice-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.server-choice-label__text {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.server-choice-warning {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--warning, #f59e0b);
|
||||
}
|
||||
|
||||
.server-choice-check {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.server-choice-check:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.nav-section-label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Check } from 'lucide-react';
|
||||
import { Check, TriangleAlert } from 'lucide-react';
|
||||
|
||||
export interface ServerChoiceOption {
|
||||
id: string;
|
||||
label: string;
|
||||
warning?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -14,6 +15,20 @@ interface Props {
|
||||
autoFocusSelected?: boolean;
|
||||
}
|
||||
|
||||
export function ServerChoiceWarning({ warning }: { warning?: string }) {
|
||||
if (!warning) return null;
|
||||
return (
|
||||
<span
|
||||
className="server-choice-warning"
|
||||
data-tooltip={warning}
|
||||
data-tooltip-wrap
|
||||
aria-hidden
|
||||
>
|
||||
<TriangleAlert size={15} strokeWidth={2.25} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ServerChoiceList({
|
||||
value,
|
||||
options,
|
||||
@@ -32,17 +47,21 @@ export default function ServerChoiceList({
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
aria-label={server.warning ? `${server.label}. ${server.warning}` : undefined}
|
||||
className={`nav-library-dropdown-item ${selected ? 'nav-library-dropdown-item--selected' : ''}`}
|
||||
onClick={() => onChange(server.id)}
|
||||
disabled={disabled}
|
||||
autoFocus={autoFocusSelected && selected}
|
||||
>
|
||||
<span className="nav-library-dropdown-item-label">{server.label}</span>
|
||||
<span className="nav-library-dropdown-item-label server-choice-label">
|
||||
<span className="server-choice-label__text">{server.label}</span>
|
||||
<ServerChoiceWarning warning={server.warning} />
|
||||
</span>
|
||||
<span
|
||||
className={`nav-library-dropdown-item-toggle ${selected ? 'nav-library-dropdown-item-toggle--on' : ''}`}
|
||||
className={`nav-library-dropdown-item-toggle server-choice-check ${selected ? 'nav-library-dropdown-item-toggle--on' : ''}`}
|
||||
aria-hidden
|
||||
>
|
||||
{selected ? <Check size={16} strokeWidth={2.5} /> : <span className="nav-library-dropdown-item-toggle-box" />}
|
||||
{selected ? <Check size={16} strokeWidth={2.5} /> : null}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user