feat(scope): move server selection into create dialogs

This commit is contained in:
cucadmuh
2026-07-19 18:37:11 +03:00
parent b6e65cb1e1
commit 5c81086d11
16 changed files with 726 additions and 165 deletions
@@ -0,0 +1,60 @@
import React, { useId } from 'react';
import { useTranslation } from 'react-i18next';
import ServerSelect, { type ServerSelectOption } from '@/ui/ServerSelect';
interface Props {
name: string;
nameLabel: string;
namePlaceholder: string;
onNameChange: (name: string) => void;
onNameKeyDown?: React.KeyboardEventHandler<HTMLInputElement>;
nameInputRef?: React.RefObject<HTMLInputElement | null>;
serverId: string;
onServerChange: (serverId: string) => void;
serverOptions: ServerSelectOption[];
showServer?: boolean;
}
export default function PlaylistCreateFields({
name,
nameLabel,
namePlaceholder,
onNameChange,
onNameKeyDown,
nameInputRef,
serverId,
onServerChange,
serverOptions,
showServer = true,
}: Props) {
const { t } = useTranslation();
const nameId = useId();
const serverVisible = showServer && serverOptions.length > 1;
return (
<div className={`playlist-create-fields${serverVisible ? '' : ' playlist-create-fields--single'}`}>
<label className="playlist-create-field" htmlFor={nameId}>
<span className="playlist-create-field__label">{nameLabel}</span>
<input
ref={nameInputRef}
id={nameId}
className="input"
placeholder={namePlaceholder}
value={name}
onChange={event => onNameChange(event.target.value)}
onKeyDown={onNameKeyDown}
/>
</label>
{serverVisible && (
<div className="playlist-create-field">
<span className="playlist-create-field__label">{t('settings.servers')}</span>
<ServerSelect
value={serverId}
options={serverOptions}
onChange={onServerChange}
/>
</div>
)}
</div>
);
}
@@ -0,0 +1,99 @@
import { useRef, useState } from 'react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import PlaylistsHeader from '@/features/playlist/components/PlaylistsHeader';
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
function HeaderHarness({
createServerOptions = [
{ id: 'server-a', label: 'Server A' },
{ id: 'server-b', label: 'Server B' },
],
}: {
createServerOptions?: Array<{ id: string; label: string }>;
}) {
const [creating, setCreating] = useState(false);
const [creatingSmart, setCreatingSmart] = useState(false);
const [newName, setNewName] = useState('');
const [serverId, setServerId] = useState('server-a');
const nameInputRef = useRef<HTMLInputElement>(null);
return (
<>
<PlaylistsHeader
selectionMode={false}
selectedIds={new Set()}
selectedPlaylists={[]}
isPlaylistDeletable={() => true}
toggleSelectionMode={vi.fn()}
handleDeleteSelected={vi.fn()}
creating={creating}
setCreating={setCreating}
setCreatingSmart={setCreatingSmart}
newName={newName}
setNewName={setNewName}
nameInputRef={nameInputRef}
handleCreate={vi.fn(async () => {})}
createServerId={serverId}
setCreateServerId={setServerId}
createServerOptions={createServerOptions}
smartCreateServerOptions={[{ id: 'server-b', label: 'Server B' }]}
setEditingSmartId={vi.fn()}
setSmartFilters={vi.fn()}
setGenreQuery={vi.fn()}
onEditorIntent={vi.fn()}
foldersEnabled={false}
/>
{creatingSmart && <div data-testid="smart-editor-open" />}
<div data-testid="selected-server">{serverId}</div>
</>
);
}
describe('PlaylistsHeader', () => {
it('reveals playlist name and server only after New Playlist is pressed', async () => {
const user = userEvent.setup();
const view = renderWithProviders(<HeaderHarness />);
expect(view.queryByRole('textbox', { name: 'Playlist Name' })).not.toBeInTheDocument();
expect(view.queryByRole('button', { name: 'Servers' })).not.toBeInTheDocument();
await user.click(view.getByRole('button', { name: 'New Playlist' }));
expect(view.getByRole('textbox', { name: 'Playlist Name' })).toBeInTheDocument();
const serverSelect = view.getByRole('button', { name: 'Servers' });
expect(serverSelect).toHaveTextContent('Server A');
await user.click(serverSelect);
await user.click(view.getByRole('option', { name: 'Server B' }));
expect(serverSelect).toHaveTextContent('Server B');
await user.click(view.getByRole('button', { name: 'Cancel' }));
expect(view.queryByRole('textbox', { name: 'Playlist Name' })).not.toBeInTheDocument();
expect(view.queryByRole('button', { name: 'Servers' })).not.toBeInTheDocument();
});
it('selects a Navidrome owner when opening the smart playlist editor', async () => {
const user = userEvent.setup();
const view = renderWithProviders(<HeaderHarness />);
expect(view.getByTestId('selected-server')).toHaveTextContent('server-a');
await user.click(view.getByRole('button', { name: 'New Smart Playlist' }));
expect(view.getByTestId('smart-editor-open')).toBeInTheDocument();
expect(view.getByTestId('selected-server')).toHaveTextContent('server-b');
expect(view.queryByRole('button', { name: 'Servers' })).not.toBeInTheDocument();
});
it('hides the server selector when creating in single-server mode', async () => {
const user = userEvent.setup();
const view = renderWithProviders(
<HeaderHarness createServerOptions={[{ id: 'server-a', label: 'Server A' }]} />,
);
await user.click(view.getByRole('button', { name: 'New Playlist' }));
expect(view.getByRole('textbox', { name: 'Playlist Name' })).toBeInTheDocument();
expect(view.queryByRole('button', { name: 'Servers' })).not.toBeInTheDocument();
});
});
@@ -8,6 +8,7 @@ import {
import { offlineActionPolicy, type OfflineActionPolicy } from '@/features/offline';
import PlaylistsNewFolderButton from '@/features/playlist/components/PlaylistsNewFolderButton';
import PlaylistsFolderViewToggle from '@/features/playlist/components/PlaylistsFolderViewToggle';
import PlaylistCreateFields from '@/features/playlist/components/PlaylistCreateFields';
interface Props {
selectionMode: boolean;
@@ -26,7 +27,7 @@ interface Props {
createServerId: string;
setCreateServerId: (serverId: string) => void;
createServerOptions: Array<{ id: string; label: string }>;
isNavidromeServer: boolean;
smartCreateServerOptions: Array<{ id: string; label: string }>;
setEditingSmartId: React.Dispatch<React.SetStateAction<string | null>>;
setSmartFilters: React.Dispatch<React.SetStateAction<SmartFilters>>;
setGenreQuery: React.Dispatch<React.SetStateAction<string>>;
@@ -41,64 +42,39 @@ export default function PlaylistsHeader({
creating, setCreating, setCreatingSmart,
newName, setNewName, nameInputRef, handleCreate,
createServerId, setCreateServerId, createServerOptions,
isNavidromeServer, setEditingSmartId, setSmartFilters, setGenreQuery, onEditorIntent,
smartCreateServerOptions, setEditingSmartId, setSmartFilters, setGenreQuery, onEditorIntent,
actionPolicy,
foldersEnabled = true,
}: Props) {
const { t } = useTranslation();
const policy = actionPolicy ?? offlineActionPolicy('playlistsHeader', false);
const cancelCreate = () => {
setCreating(false);
setNewName('');
};
return (
<div className="playlists-header">
<h1 className="page-title" style={{ marginBottom: 0 }}>
{selectionMode && selectedIds.size > 0
? t('playlists.selectionCount', { count: selectedIds.size })
: t('playlists.title')}
</h1>
<div className="compact-action-bar" style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap', justifyContent: 'flex-start' }}>
{policy.canEditPlaylist && !(selectionMode && selectedIds.size > 0) && (<>
{createServerOptions.length > 1 && (
<select
className="input"
value={createServerId}
onChange={event => setCreateServerId(event.target.value)}
aria-label={t('settings.servers')}
>
{createServerOptions.map(server => (
<option key={server.id} value={server.id}>{server.label}</option>
))}
</select>
)}
{creating ? (
<>
<input
ref={nameInputRef}
className="input"
style={{ width: 220 }}
placeholder={t('playlists.createName')}
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleCreate();
if (e.key === 'Escape') { setCreating(false); setNewName(''); }
}}
/>
<button className="btn btn-primary" onClick={handleCreate}>
{t('playlists.create')}
</button>
<button className="btn btn-surface" onClick={() => { setCreating(false); setNewName(''); }}>
{t('playlists.cancel')}
</button>
</>
) : (
<div className="playlists-header-stack">
<div className="playlists-header">
<h1 className="page-title" style={{ marginBottom: 0 }}>
{selectionMode && selectedIds.size > 0
? t('playlists.selectionCount', { count: selectedIds.size })
: t('playlists.title')}
</h1>
<div className="compact-action-bar" style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap', justifyContent: 'flex-start' }}>
{policy.canEditPlaylist && !(selectionMode && selectedIds.size > 0) && (<>
{!creating && (
<button className="btn btn-primary" onClick={() => { onEditorIntent(); setCreatingSmart(false); setCreating(true); }} aria-label={t('playlists.newPlaylist')} data-tooltip={t('playlists.newPlaylist')}>
<Plus size={15} /> <span className="compact-btn-label">{t('playlists.newPlaylist')}</span>
</button>
)}
{!creating && isNavidromeServer && (
{!creating && smartCreateServerOptions.length > 0 && (
<button className="btn btn-surface" onClick={() => {
onEditorIntent();
setCreating(false);
if (!smartCreateServerOptions.some(server => server.id === createServerId)) {
setCreateServerId(smartCreateServerOptions[0].id);
}
setEditingSmartId(null);
setSmartFilters(defaultSmartFilters);
setGenreQuery('');
@@ -108,39 +84,75 @@ export default function PlaylistsHeader({
</button>
)}
</>
)}
{foldersEnabled && !(selectionMode && selectedIds.size > 0) && <PlaylistsFolderViewToggle />}
{foldersEnabled && !(selectionMode && selectedIds.size > 0) && <PlaylistsNewFolderButton />}
{selectionMode && selectedIds.size > 0 && (() => {
const deletableCount = selectedPlaylists.filter(isPlaylistDeletable).length;
return (
<button
className="btn btn-danger"
onClick={handleDeleteSelected}
disabled={deletableCount === 0}
aria-label={t('playlists.deleteSelected')}
data-tooltip={deletableCount === selectedIds.size
? undefined
: t('playlists.deleteSelectedPartial', { n: deletableCount, total: selectedIds.size })}
data-tooltip-pos="bottom"
>
<Trash2 size={15} />
<span className="compact-btn-label">{t('playlists.deleteSelected')}</span>
</button>
);
})()}
<button
className={`btn btn-surface${selectionMode ? ' btn-sort-active' : ''}`}
onClick={toggleSelectionMode}
aria-label={selectionMode ? t('playlists.cancelSelect') : t('playlists.select')}
data-tooltip={selectionMode ? t('playlists.cancelSelect') : t('playlists.startSelect')}
data-tooltip-pos="bottom"
style={selectionMode ? { background: 'var(--accent)', color: 'var(--text-on-accent)' } : {}}
>
<CheckSquare2 size={15} />
<span className="compact-btn-label">{selectionMode ? t('playlists.cancelSelect') : t('playlists.select')}</span>
</button>
)}
{foldersEnabled && !(selectionMode && selectedIds.size > 0) && <PlaylistsFolderViewToggle />}
{foldersEnabled && !(selectionMode && selectedIds.size > 0) && <PlaylistsNewFolderButton />}
{selectionMode && selectedIds.size > 0 && (() => {
const deletableCount = selectedPlaylists.filter(isPlaylistDeletable).length;
return (
<button
className="btn btn-danger"
onClick={handleDeleteSelected}
disabled={deletableCount === 0}
aria-label={t('playlists.deleteSelected')}
data-tooltip={deletableCount === selectedIds.size
? undefined
: t('playlists.deleteSelectedPartial', { n: deletableCount, total: selectedIds.size })}
data-tooltip-pos="bottom"
>
<Trash2 size={15} />
<span className="compact-btn-label">{t('playlists.deleteSelected')}</span>
</button>
);
})()}
<button
className={`btn btn-surface${selectionMode ? ' btn-sort-active' : ''}`}
onClick={toggleSelectionMode}
aria-label={selectionMode ? t('playlists.cancelSelect') : t('playlists.select')}
data-tooltip={selectionMode ? t('playlists.cancelSelect') : t('playlists.startSelect')}
data-tooltip-pos="bottom"
style={selectionMode ? { background: 'var(--accent)', color: 'var(--text-on-accent)' } : {}}
>
<CheckSquare2 size={15} />
<span className="compact-btn-label">{selectionMode ? t('playlists.cancelSelect') : t('playlists.select')}</span>
</button>
</div>
</div>
{creating && (
<form
className="playlist-create-panel"
onSubmit={event => {
event.preventDefault();
void handleCreate();
}}
>
<div className="playlist-create-panel__heading">
<Plus size={16} />
<span>{t('playlists.newPlaylist')}</span>
</div>
<PlaylistCreateFields
name={newName}
nameLabel={t('queue.playlistName')}
namePlaceholder={t('playlists.createName')}
onNameChange={setNewName}
onNameKeyDown={event => {
if (event.key === 'Escape') cancelCreate();
}}
nameInputRef={nameInputRef}
serverId={createServerId}
onServerChange={setCreateServerId}
serverOptions={createServerOptions}
/>
<div className="playlist-create-panel__actions">
<button type="button" className="btn btn-surface" onClick={cancelCreate}>
{t('playlists.cancel')}
</button>
<button type="submit" className="btn btn-primary" disabled={!createServerId}>
{t('playlists.create')}
</button>
</div>
</form>
)}
</div>
);
}
@@ -0,0 +1,70 @@
import { useState } from 'react';
import { describe, expect, it, vi } from 'vitest';
import PlaylistsSmartEditor from '@/features/playlist/components/PlaylistsSmartEditor';
import { defaultSmartFilters } from '@/features/playlist/utils/playlistsSmart';
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
function SmartEditorHarness({
editingSmartId,
serverOptions = [
{ id: 'server-a', label: 'Server A' },
{ id: 'server-b', label: 'Server B' },
],
}: {
editingSmartId: string | null;
serverOptions?: Array<{ id: string; label: string }>;
}) {
const [filters, setFilters] = useState({
...defaultSmartFilters,
selectedGenres: [...defaultSmartFilters.selectedGenres],
});
const [serverId, setServerId] = useState('server-a');
return (
<PlaylistsSmartEditor
smartFilters={filters}
setSmartFilters={setFilters}
availableGenres={[]}
genreQuery=""
setGenreQuery={vi.fn()}
editingSmartId={editingSmartId}
creatingSmartBusy={false}
genresReady
createServerId={serverId}
setCreateServerId={setServerId}
createServerOptions={serverOptions}
setCreatingSmart={vi.fn()}
setEditingSmartId={vi.fn()}
onSave={vi.fn()}
onCancel={vi.fn()}
/>
);
}
describe('PlaylistsSmartEditor', () => {
it('shows the target server while creating a smart playlist', () => {
const view = renderWithProviders(<SmartEditorHarness editingSmartId={null} />);
expect(view.getByRole('textbox', { name: 'Playlist Name' })).toBeInTheDocument();
expect(view.getByRole('button', { name: 'Servers' })).toHaveTextContent('Server A');
});
it('keeps the owner server fixed while editing a smart playlist', () => {
const view = renderWithProviders(<SmartEditorHarness editingSmartId="smart-1" />);
expect(view.getByRole('textbox', { name: 'Playlist Name' })).toBeInTheDocument();
expect(view.queryByRole('button', { name: 'Servers' })).not.toBeInTheDocument();
});
it('hides the owner selector when creating in single-server mode', () => {
const view = renderWithProviders(
<SmartEditorHarness
editingSmartId={null}
serverOptions={[{ id: 'server-a', label: 'Server A' }]}
/>,
);
expect(view.getByRole('textbox', { name: 'Playlist Name' })).toBeInTheDocument();
expect(view.queryByRole('button', { name: 'Servers' })).not.toBeInTheDocument();
});
});
@@ -7,6 +7,7 @@ import {
LIMIT_MAX, YEAR_MAX, YEAR_MIN, clampYear, defaultSmartFilters,
type SmartFilters,
} from '@/features/playlist/utils/playlistsSmart';
import PlaylistCreateFields from '@/features/playlist/components/PlaylistCreateFields';
interface Props {
smartFilters: SmartFilters;
@@ -17,6 +18,9 @@ interface Props {
editingSmartId: string | null;
creatingSmartBusy: boolean;
genresReady: boolean;
createServerId: string;
setCreateServerId: (serverId: string) => void;
createServerOptions: Array<{ id: string; label: string }>;
setCreatingSmart: React.Dispatch<React.SetStateAction<boolean>>;
setEditingSmartId: React.Dispatch<React.SetStateAction<string | null>>;
onSave: () => void;
@@ -26,6 +30,7 @@ interface Props {
export default function PlaylistsSmartEditor({
smartFilters, setSmartFilters, availableGenres,
genreQuery, setGenreQuery, editingSmartId, creatingSmartBusy, genresReady,
createServerId, setCreateServerId, createServerOptions,
setCreatingSmart, setEditingSmartId, onSave, onCancel,
}: Props) {
const { t } = useTranslation();
@@ -64,7 +69,16 @@ export default function PlaylistsSmartEditor({
<section style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)', padding: '0.75rem' }}>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: '0.65rem' }}>{t('smartPlaylists.sectionBasic')}</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.65rem' }}>
<input className="input" placeholder={t('smartPlaylists.name')} value={smartFilters.name} onChange={e => setSmartFilters(v => ({ ...v, name: e.target.value }))} />
<PlaylistCreateFields
name={smartFilters.name}
nameLabel={t('queue.playlistName')}
namePlaceholder={t('smartPlaylists.name')}
onNameChange={name => setSmartFilters(value => ({ ...value, name }))}
serverId={createServerId}
onServerChange={setCreateServerId}
serverOptions={createServerOptions}
showServer={!editingSmartId}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
<input className="input" type="number" min={1} max={LIMIT_MAX} placeholder={t('smartPlaylists.limit')} value={smartFilters.limit} onChange={e => setSmartFilters(v => ({ ...v, limit: e.target.value }))} />
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('smartPlaylists.limitHint', { max: LIMIT_MAX })}</span>
+18 -6
View File
@@ -53,6 +53,7 @@ export default function Playlists() {
const fetchPlaylists = usePlaylistStore((s) => s.fetchPlaylists);
const servers = useAuthStore(s => s.servers);
const activeServerId = useAuthStore(s => s.activeServerId);
const subsonicIdentityByServer = useAuthStore(s => s.subsonicServerIdentityByServer);
const libraryBrowseServerIds = useAuthStore(s => s.libraryBrowseServerIds);
const libraryBrowseScopeVersion = useAuthStore(s => s.libraryBrowseScopeVersion);
const unavailableServerIds = useUnavailableServerIds();
@@ -64,6 +65,13 @@ export default function Playlists() {
const serverLabelById = useMemo(() => new Map(
servers.map(server => [server.id, serverListDisplayLabel(server, servers)]),
), [servers]);
const createServerOptions = useMemo(() => effectiveServerIds.map(serverId => ({
id: serverId,
label: serverLabelById.get(serverId) ?? serverId,
})), [effectiveServerIds, serverLabelById]);
const smartCreateServerOptions = useMemo(() => createServerOptions.filter(server => (
(subsonicIdentityByServer[server.id]?.type ?? '').toLowerCase() === 'navidrome'
)), [createServerOptions, subsonicIdentityByServer]);
const folderCount = usePlaylistFolderStore(
s => (activeServerId ? s.byServer[activeServerId]?.folders.length ?? 0 : 0),
);
@@ -72,7 +80,6 @@ export default function Playlists() {
&& effectiveServerIds[0] === activeServerId
&& folderCount > 0
&& folderGroupView;
const subsonicIdentityByServer = useAuthStore(s => s.subsonicServerIdentityByServer);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const playlistScopeVersion = musicLibraryFilterVersion + libraryBrowseScopeVersion;
const offlineCtx = useOfflineBrowseContext();
@@ -367,11 +374,8 @@ export default function Playlists() {
setCreatingSmartBusy(false);
setRequestedCreateServerId(serverId);
}}
createServerOptions={effectiveServerIds.map(serverId => ({
id: serverId,
label: serverLabelById.get(serverId) ?? serverId,
}))}
isNavidromeServer={isNavidromeServer}
createServerOptions={createServerOptions}
smartCreateServerOptions={smartCreateServerOptions}
setEditingSmartId={setEditingSmartId}
setSmartFilters={setSmartFilters}
setGenreQuery={setGenreQuery}
@@ -394,6 +398,14 @@ export default function Playlists() {
editingSmartId={editingSmartId}
creatingSmartBusy={creatingSmartBusy}
genresReady={smartGenresReady}
createServerId={smartEditorServerId}
setCreateServerId={serverId => {
smartOperationGenerationRef.current += 1;
smartEditorGenerationRef.current += 1;
setCreatingSmartBusy(false);
setRequestedCreateServerId(serverId);
}}
createServerOptions={smartCreateServerOptions}
setCreatingSmart={setCreatingSmart}
setEditingSmartId={setEditingSmartId}
onSave={handleCreateSmart}
@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
const mocks = vi.hoisted(() => ({
@@ -55,13 +56,15 @@ describe('RadioDirectoryModal owner-scoped creation', () => {
const onAdded = vi.fn();
const view = renderWithProviders(
<RadioDirectoryModal
targetServerId="srv-owner"
initialServerId="srv-owner"
serverOptions={[{ id: 'srv-owner', label: 'Owner' }]}
onMutationStart={vi.fn()}
onClose={vi.fn()}
onAdded={onAdded}
/>,
);
expect(view.queryByRole('button', { name: 'Servers' })).not.toBeInTheDocument();
fireEvent.click(await view.findByText('Directory Station'));
await waitFor(() => expect(mocks.uploadBytesForServer).toHaveBeenCalledWith(
@@ -78,4 +81,36 @@ describe('RadioDirectoryModal owner-scoped creation', () => {
);
expect(onAdded).toHaveBeenCalledOnce();
});
it('adds to the server selected inside the directory modal', async () => {
const user = userEvent.setup();
const onMutationStart = vi.fn();
const onAdded = vi.fn();
const view = renderWithProviders(
<RadioDirectoryModal
initialServerId="srv-a"
serverOptions={[
{ id: 'srv-a', label: 'Server A' },
{ id: 'srv-b', label: 'Server B' },
]}
onMutationStart={onMutationStart}
onClose={vi.fn()}
onAdded={onAdded}
/>,
);
const serverSelect = view.getByRole('button', { name: 'Servers' });
expect(serverSelect).toHaveTextContent('Server A');
await user.click(serverSelect);
await user.click(view.getByRole('option', { name: 'Server B' }));
await user.click(await view.findByText('Directory Station'));
await waitFor(() => expect(mocks.createForServer).toHaveBeenCalledWith(
'srv-b',
'Directory Station',
'https://shared.test/live',
));
expect(onMutationStart).toHaveBeenCalledWith('srv-b');
expect(onAdded).toHaveBeenCalledWith('srv-b');
});
});
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import React, { useCallback, useEffect, useId, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { Cast, Check, Loader2, Plus, X } from 'lucide-react';
@@ -10,16 +10,19 @@ import {
type InternetRadioStation, type RadioBrowserStation, RADIO_PAGE_SIZE,
} from '@/lib/api/subsonicTypes';
import { showToast } from '@/lib/dom/toast';
import ServerSelect, { type ServerSelectOption } from '@/ui/ServerSelect';
interface RadioDirectoryModalProps {
targetServerId: string;
onMutationStart: () => void;
initialServerId: string;
serverOptions: ServerSelectOption[];
onMutationStart: (serverId: string) => void;
onClose: () => void;
onAdded: () => void;
onAdded: (serverId: string) => void | Promise<void>;
}
export default function RadioDirectoryModal({
targetServerId,
initialServerId,
serverOptions,
onMutationStart,
onClose,
onAdded,
@@ -33,10 +36,15 @@ export default function RadioDirectoryModal({
const [hasMore, setHasMore] = useState(true);
const [addingId, setAddingId] = useState<string | null>(null);
const [addedIds, setAddedIds] = useState<Set<string>>(new Set());
const [requestedServerId, setRequestedServerId] = useState(initialServerId);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const observerRef = useRef<IntersectionObserver | null>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const queryRef = useRef(query);
const titleId = useId();
const targetServerId = serverOptions.some(server => server.id === requestedServerId)
? requestedServerId
: serverOptions[0]?.id ?? '';
useEffect(() => { queryRef.current = query; }, [query]);
const fetchPage = useCallback(async (q: string, off: number, append: boolean) => {
@@ -92,20 +100,22 @@ export default function RadioDirectoryModal({
}, [hasMore, loadingMore, offset, fetchPage]);
const handleAdd = async (s: RadioBrowserStation) => {
if (addedIds.has(s.stationuuid) || addingId !== null) return;
const ownerServerId = targetServerId;
const addedKey = `${ownerServerId}:${s.stationuuid}`;
if (!ownerServerId || addedIds.has(addedKey) || addingId !== null) return;
setAddingId(s.stationuuid);
try {
onMutationStart();
await createInternetRadioStationForServer(targetServerId, s.name, s.url);
onMutationStart(ownerServerId);
await createInternetRadioStationForServer(ownerServerId, s.name, s.url);
if (s.favicon) {
const list = await getInternetRadioStationsForServer(targetServerId)
const list = await getInternetRadioStationsForServer(ownerServerId)
.catch(() => [] as InternetRadioStation[]);
const created = list.find(r => r.name === s.name && r.streamUrl === s.url);
if (created) {
try {
const [fileBytes, mimeType] = await fetchUrlBytes(s.favicon);
await uploadRadioCoverArtBytesForServer(
targetServerId,
ownerServerId,
created.id,
fileBytes,
mimeType,
@@ -113,8 +123,8 @@ export default function RadioDirectoryModal({
} catch { /* favicon optional */ }
}
}
onAdded();
setAddedIds(prev => new Set(prev).add(s.stationuuid));
await onAdded(ownerServerId);
setAddedIds(prev => new Set(prev).add(addedKey));
showToast(`${t('radio.stationAdded')}: ${s.name}`, 3000);
} catch (err) {
const msg = typeof err === 'string' ? err : (err instanceof Error ? err.message : '');
@@ -142,6 +152,9 @@ export default function RadioDirectoryModal({
backdropFilter: 'blur(8px)',
}}
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
>
{/* ── 2. Content Box ─────────────────────────────────────── */}
<div
@@ -174,20 +187,30 @@ export default function RadioDirectoryModal({
className="btn btn-ghost"
style={{ position: 'absolute', top: 16, right: 16, color: 'var(--text-muted)' }}
onClick={onClose}
aria-label={t('common.close')}
>
<X size={18} />
</button>
<h2 style={{ fontSize: 20, fontWeight: 700, marginBottom: 14, color: 'var(--text-primary)', fontFamily: 'var(--font-display)' }}>
<h2 id={titleId} style={{ fontSize: 20, fontWeight: 700, marginBottom: 14, color: 'var(--text-primary)', fontFamily: 'var(--font-display)' }}>
{t('radio.browseDirectory')}
</h2>
<input
className="input"
value={query}
onChange={e => setQuery(e.target.value)}
placeholder={t('radio.directoryPlaceholder')}
autoFocus
style={{ width: '100%' }}
/>
<div className={`radio-directory-controls${serverOptions.length > 1 ? '' : ' radio-directory-controls--single'}`}>
<input
className="input"
value={query}
onChange={e => setQuery(e.target.value)}
placeholder={t('radio.directoryPlaceholder')}
autoFocus
style={{ width: '100%' }}
/>
<ServerSelect
value={targetServerId}
options={serverOptions}
onChange={setRequestedServerId}
disabled={addingId !== null}
style={{ width: '100%' }}
/>
</div>
</div>
{/* ── 4. Body / Results ──────────────────────────────────── */}
@@ -201,7 +224,7 @@ export default function RadioDirectoryModal({
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, paddingTop: 8 }}>
{results.map(s => {
const isAdded = addedIds.has(s.stationuuid);
const isAdded = addedIds.has(`${targetServerId}:${s.stationuuid}`);
const isLoading = addingId === s.stationuuid;
const isDisabled = isAdded || addingId !== null;
return (
@@ -0,0 +1,72 @@
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import RadioEditModal from '@/features/radio/components/RadioEditModal';
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
const SERVER_OPTIONS = [
{ id: 'srv-a', label: 'Server A' },
{ id: 'srv-b', label: 'Server B' },
];
describe('RadioEditModal', () => {
it('selects the owner server inside the add-station modal', async () => {
const user = userEvent.setup();
const onSave = vi.fn(async () => {});
const view = renderWithProviders(
<RadioEditModal
station={null}
initialServerId="srv-a"
serverOptions={SERVER_OPTIONS}
onClose={vi.fn()}
onSave={onSave}
/>,
);
const serverSelect = view.getByRole('button', { name: 'Servers' });
expect(serverSelect).toHaveTextContent('Server A');
await user.click(serverSelect);
await user.click(view.getByRole('option', { name: 'Server B' }));
await user.type(view.getByRole('textbox', { name: /Station name/ }), 'New station');
await user.type(view.getByRole('textbox', { name: /Stream URL/ }), 'https://radio.test/live');
await user.click(view.getByRole('button', { name: 'Save' }));
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
serverId: 'srv-b',
name: 'New station',
streamUrl: 'https://radio.test/live',
}));
});
it('hides the owner selector when only one server is available', () => {
const view = renderWithProviders(
<RadioEditModal
station={null}
initialServerId="srv-a"
serverOptions={[SERVER_OPTIONS[0]]}
onClose={vi.fn()}
onSave={vi.fn(async () => {})}
/>,
);
expect(view.queryByRole('button', { name: 'Servers' })).not.toBeInTheDocument();
});
it('keeps the owner fixed while editing an existing station', () => {
const view = renderWithProviders(
<RadioEditModal
station={{
id: 'station-1',
serverId: 'srv-a',
name: 'Existing station',
streamUrl: 'https://radio.test/existing',
}}
initialServerId="srv-a"
serverOptions={SERVER_OPTIONS}
onClose={vi.fn()}
onSave={vi.fn(async () => {})}
/>,
);
expect(view.queryByRole('button', { name: 'Servers' })).not.toBeInTheDocument();
});
});
@@ -1,15 +1,19 @@
import React, { useRef, useState } from 'react';
import React, { useId, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { Camera, Cast, Loader2, X } from 'lucide-react';
import type { InternetRadioStation } from '@/lib/api/subsonicTypes';
import { CoverArtImage } from '@/cover/CoverArtImage';
import { radioCoverRef } from '@/cover/ref';
import ServerSelect, { type ServerSelectOption } from '@/ui/ServerSelect';
interface RadioEditModalProps {
station: InternetRadioStation | null; // null = create new
initialServerId: string;
serverOptions: ServerSelectOption[];
onClose: () => void;
onSave: (opts: {
serverId: string;
name: string;
streamUrl: string;
homepageUrl: string;
@@ -18,7 +22,13 @@ interface RadioEditModalProps {
}) => Promise<void>;
}
export default function RadioEditModal({ station, onClose, onSave }: RadioEditModalProps) {
export default function RadioEditModal({
station,
initialServerId,
serverOptions,
onClose,
onSave,
}: RadioEditModalProps) {
const { t } = useTranslation();
const [name, setName] = useState(station?.name ?? '');
const [streamUrl, setStreamUrl] = useState(station?.streamUrl ?? '');
@@ -27,7 +37,12 @@ export default function RadioEditModal({ station, onClose, onSave }: RadioEditMo
const [coverPreview, setCoverPreview] = useState<string | null>(null);
const [coverRemoved, setCoverRemoved] = useState(false);
const [saving, setSaving] = useState(false);
const [requestedServerId, setRequestedServerId] = useState(initialServerId);
const coverInputRef = useRef<HTMLInputElement>(null);
const titleId = useId();
const serverId = serverOptions.some(server => server.id === requestedServerId)
? requestedServerId
: serverOptions[0]?.id ?? '';
const hasExistingCover = !coverRemoved && (coverPreview || station?.coverArt);
@@ -50,10 +65,10 @@ export default function RadioEditModal({ station, onClose, onSave }: RadioEditMo
};
const handleSave = async () => {
if (!name.trim() || !streamUrl.trim()) return;
if (!serverId || !name.trim() || !streamUrl.trim()) return;
setSaving(true);
try {
await onSave({ name, streamUrl, homepageUrl, coverFile, coverRemoved });
await onSave({ serverId, name, streamUrl, homepageUrl, coverFile, coverRemoved });
} finally {
setSaving(false);
}
@@ -66,7 +81,14 @@ export default function RadioEditModal({ station, onClose, onSave }: RadioEditMo
/* Portal to document.body: nested .content-body uses contain:paint — in-tree
* modals are clipped when the station list is empty (short layout). Same pattern as RadioDirectoryModal. */
return createPortal(
<div className="modal-overlay" style={{ alignItems: 'center', paddingTop: 0, overflowY: 'auto' }} onClick={handleOverlayClick}>
<div
className="modal-overlay"
style={{ alignItems: 'center', paddingTop: 0, overflowY: 'auto' }}
onClick={handleOverlayClick}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
>
<div
className="modal-content"
style={{ maxWidth: 440, width: '90%', maxHeight: 'none', overflow: 'visible' }}
@@ -76,7 +98,7 @@ export default function RadioEditModal({ station, onClose, onSave }: RadioEditMo
<X size={18} />
</button>
<h2 className="modal-title" style={{ fontSize: 20 }}>
<h2 id={titleId} className="modal-title" style={{ fontSize: 20 }}>
{station ? t('radio.editStation') : t('radio.addStation')}
</h2>
@@ -127,6 +149,15 @@ export default function RadioEditModal({ station, onClose, onSave }: RadioEditMo
{/* Fields */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 8 }}>
{!station && (
<ServerSelect
value={serverId}
options={serverOptions}
onChange={setRequestedServerId}
disabled={saving}
style={{ width: '100%' }}
/>
)}
<input
className="input"
style={{ fontSize: 15, fontWeight: 600 }}
@@ -158,7 +189,7 @@ export default function RadioEditModal({ station, onClose, onSave }: RadioEditMo
<button
className="btn btn-primary"
onClick={handleSave}
disabled={saving || !name.trim() || !streamUrl.trim()}
disabled={saving || !serverId || !name.trim() || !streamUrl.trim()}
>
{saving ? <Loader2 size={14} className="spin-slow" /> : null}
{t('radio.save')}
+21 -15
View File
@@ -76,8 +76,11 @@ vi.mock('@/features/radio/components/RadioCard', () => ({
),
}));
vi.mock('@/features/radio/components/RadioEditModal', () => ({
default: ({ onSave }: {
default: ({ initialServerId, serverOptions, onSave }: {
initialServerId: string;
serverOptions: Array<{ id: string; label: string }>;
onSave: (options: {
serverId: string;
name: string;
streamUrl: string;
homepageUrl: string;
@@ -85,13 +88,19 @@ vi.mock('@/features/radio/components/RadioEditModal', () => ({
coverRemoved: boolean;
}) => Promise<void>;
}) => (
<button onClick={() => void onSave({
name: 'Created',
streamUrl: 'https://created.test/live',
homepageUrl: '',
coverFile: null,
coverRemoved: false,
})}>save station</button>
<div>
<span data-testid="radio-modal-initial-server">{initialServerId}</span>
{serverOptions.map(server => (
<button key={server.id} onClick={() => void onSave({
serverId: server.id,
name: 'Created',
streamUrl: 'https://created.test/live',
homepageUrl: '',
coverFile: null,
coverRemoved: false,
})}>save station {server.id}</button>
))}
</div>
),
}));
vi.mock('@/features/radio/components/RadioDirectoryModal', () => ({ default: () => null }));
@@ -160,14 +169,11 @@ describe('InternetRadio multi-server ownership', () => {
const view = renderWithProviders(<InternetRadio />);
await view.findByTestId('station-srv-a-shared');
fireEvent.change(view.getByRole('combobox', { name: 'Servers' }), {
target: { value: 'srv-b' },
});
expect(view.queryByRole('combobox', { name: 'Servers' })).not.toBeInTheDocument();
expect(view.queryByRole('button', { name: 'Servers' })).not.toBeInTheDocument();
fireEvent.click(view.getByRole('button', { name: /add station/i }));
fireEvent.change(view.getByRole('combobox', { name: 'Servers' }), {
target: { value: 'srv-a' },
});
fireEvent.click(view.getByRole('button', { name: 'save station' }));
expect(view.getByTestId('radio-modal-initial-server')).toHaveTextContent('srv-a');
fireEvent.click(view.getByRole('button', { name: 'save station srv-b' }));
await waitFor(() => expect(hoisted.createForServer).toHaveBeenCalledWith(
'srv-b',
+25 -34
View File
@@ -54,15 +54,20 @@ export default function InternetRadio() {
const serverLabelById = useMemo(() => new Map(
servers.map(server => [server.id, serverListDisplayLabel(server, servers)]),
), [servers]);
const manageableServerOptions = useMemo(() => effectiveServerIds
.filter(serverId => canManageNavidromeRadio(adminRoles[serverId] ?? 'checking'))
.map(serverId => ({
id: serverId,
label: serverLabelById.get(serverId) ?? serverId,
})), [adminRoles, effectiveServerIds, serverLabelById]);
const [stations, setStations] = useState<InternetRadioStation[]>([]);
const [loading, setLoading] = useState(true);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const [modalStation, setModalStation] = useState<
InternetRadioStation | { kind: 'new'; serverId: string } | null
InternetRadioStation | { kind: 'new' } | null
>(null);
const [browseServerId, setBrowseServerId] = useState<string | null>(null);
const [requestedTargetServerId, setRequestedTargetServerId] = useState<string | null>(null);
const [directoryOpen, setDirectoryOpen] = useState(false);
const loadGenerationRef = useRef(0);
const mutationGenerationRef = useRef(0);
const reloadGenerationByServerRef = useRef(new Map<string, number>());
@@ -77,14 +82,9 @@ export default function InternetRadio() {
const [manualOrder, setManualOrder] = useState<string[]>([]);
const [dragOver, setDragOver] = useState<{ id: string; side: 'before' | 'after' } | null>(null);
const targetServerId = requestedTargetServerId && effectiveServerIds.includes(requestedTargetServerId)
? requestedTargetServerId
: activeServerId && effectiveServerIds.includes(activeServerId)
const targetServerId = activeServerId && manageableServerOptions.some(server => server.id === activeServerId)
? activeServerId
: effectiveServerIds[0] ?? '';
const canManageTarget = Boolean(
targetServerId && canManageNavidromeRadio(adminRoles[targetServerId] ?? 'checking'),
);
: manageableServerOptions[0]?.id ?? '';
useEffect(() => {
const generation = ++loadGenerationRef.current;
@@ -232,6 +232,7 @@ export default function InternetRadio() {
}, [sortedFilteredStations, activeLetter]);
const handleSave = async (opts: {
serverId: string;
name: string;
streamUrl: string;
homepageUrl: string;
@@ -239,7 +240,8 @@ export default function InternetRadio() {
coverRemoved: boolean;
}) => {
if (modalStation && 'kind' in modalStation) {
const ownerServerId = modalStation.serverId;
const ownerServerId = opts.serverId;
if (!ownerServerId) return;
beginMutation(ownerServerId);
await createInternetRadioStationForServer(
ownerServerId,
@@ -347,25 +349,11 @@ export default function InternetRadio() {
<div className="playlists-header">
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('radio.title')}</h1>
<div className="compact-action-bar" style={{ display: 'flex', gap: 8 }}>
{effectiveServerIds.length > 1 && (
<select
className="input"
value={targetServerId}
onChange={event => setRequestedTargetServerId(event.target.value)}
aria-label={t('settings.servers')}
>
{effectiveServerIds.map(serverId => (
<option key={serverId} value={serverId}>
{serverLabelById.get(serverId) ?? serverId}
</option>
))}
</select>
)}
{canManageTarget && (<>
<button className="btn btn-primary" onClick={() => setBrowseServerId(targetServerId)} aria-label={t('radio.browseDirectory')} data-tooltip={t('radio.browseDirectory')}>
{targetServerId && (<>
<button className="btn btn-primary" onClick={() => setDirectoryOpen(true)} aria-label={t('radio.browseDirectory')} data-tooltip={t('radio.browseDirectory')}>
<Search size={14} /> <span className="compact-btn-label">{t('radio.browseDirectory')}</span>
</button>
<button className="btn btn-primary" onClick={() => setModalStation({ kind: 'new', serverId: targetServerId })} aria-label={t('radio.addStation')} data-tooltip={t('radio.addStation')}>
<button className="btn btn-primary" onClick={() => setModalStation({ kind: 'new' })} aria-label={t('radio.addStation')} data-tooltip={t('radio.addStation')}>
<Plus size={15} /> <span className="compact-btn-label">{t('radio.addStation')}</span>
</button>
</>)}
@@ -433,20 +421,23 @@ export default function InternetRadio() {
{modalStation !== null && (
<RadioEditModal
station={'kind' in modalStation ? null : modalStation}
initialServerId={'kind' in modalStation ? targetServerId : modalStation.serverId ?? ''}
serverOptions={manageableServerOptions}
onClose={() => setModalStation(null)}
onSave={handleSave}
/>
)}
{/* ── Directory Modal ── */}
{browseServerId && (
{directoryOpen && (
<RadioDirectoryModal
targetServerId={browseServerId}
onMutationStart={() => beginMutation(browseServerId)}
onClose={() => setBrowseServerId(null)}
onAdded={() => {
initialServerId={targetServerId}
serverOptions={manageableServerOptions}
onMutationStart={beginMutation}
onClose={() => setDirectoryOpen(false)}
onAdded={serverId => {
completeMutation();
return reloadServer(browseServerId);
return reloadServer(serverId);
}}
/>
)}
@@ -1,10 +1,80 @@
/* ─ Playlists overview header ─ */
.playlists-header-stack {
margin-bottom: var(--space-6);
}
.playlists-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
flex-wrap: wrap;
margin-bottom: var(--space-6);
}
.playlist-create-panel {
display: flex;
flex-direction: column;
gap: var(--space-4);
margin-top: var(--space-4);
padding: var(--space-4);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--bg-card);
box-shadow: 0 10px 28px color-mix(in srgb, var(--bg-app) 55%, transparent);
}
.playlist-create-panel__heading {
display: flex;
align-items: center;
gap: var(--space-2);
color: var(--text-primary);
font-family: var(--font-display);
font-size: 0.95rem;
font-weight: 650;
}
.playlist-create-fields {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(180px, 0.65fr);
gap: var(--space-3);
}
.playlist-create-fields--single {
grid-template-columns: minmax(0, 1fr);
}
.playlist-create-field {
display: flex;
min-width: 0;
flex-direction: column;
gap: var(--space-2);
}
.playlist-create-field__label {
color: var(--text-muted);
font-size: 0.72rem;
font-weight: 650;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.playlist-create-field .input,
.playlist-create-field .custom-select-trigger {
min-height: 38px;
}
.playlist-create-panel__actions {
display: flex;
justify-content: flex-end;
gap: var(--space-2);
}
@media (max-width: 680px) {
.playlist-create-fields {
grid-template-columns: minmax(0, 1fr);
}
.playlist-create-panel__actions .btn {
flex: 1;
}
}
@@ -1,4 +1,14 @@
/* ─ Radio Browser Directory ─ */
.radio-directory-controls {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(180px, 0.45fr);
gap: var(--space-3);
}
.radio-directory-controls--single {
grid-template-columns: minmax(0, 1fr);
}
.radio-browser-result {
display: flex;
align-items: center;
@@ -53,3 +63,8 @@
margin-top: 2px;
}
@media (max-width: 680px) {
.radio-directory-controls {
grid-template-columns: minmax(0, 1fr);
}
}
+11 -1
View File
@@ -16,9 +16,18 @@ interface Props {
className?: string;
style?: React.CSSProperties;
disabled?: boolean;
ariaLabel?: string;
}
export default function CustomSelect({ value, options, onChange, className = '', style, disabled }: Props) {
export default function CustomSelect({
value,
options,
onChange,
className = '',
style,
disabled,
ariaLabel,
}: Props) {
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const listRef = useRef<HTMLDivElement>(null);
@@ -92,6 +101,7 @@ export default function CustomSelect({ value, options, onChange, className = '',
onClick={() => { if (!disabled) setOpen(v => !v); }}
aria-haspopup="listbox"
aria-expanded={open}
aria-label={ariaLabel}
>
<span className="custom-select-label">{selected?.label ?? value}</span>
<ChevronDown size={14} className={`custom-select-chevron ${open ? 'open' : ''}`} />
+41
View File
@@ -0,0 +1,41 @@
import { useTranslation } from 'react-i18next';
import CustomSelect from '@/ui/CustomSelect';
export interface ServerSelectOption {
id: string;
label: string;
}
interface Props {
value: string;
options: ServerSelectOption[];
onChange: (serverId: string) => void;
disabled?: boolean;
className?: string;
style?: React.CSSProperties;
}
export default function ServerSelect({
value,
options,
onChange,
disabled,
className,
style,
}: Props) {
const { t } = useTranslation();
if (options.length <= 1) return null;
return (
<CustomSelect
value={value}
options={options.map(server => ({ value: server.id, label: server.label }))}
onChange={onChange}
disabled={disabled}
className={className}
style={style}
ariaLabel={t('settings.servers')}
/>
);
}