mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
feat(servers): scan actions + edit existing server profiles (#780)
* feat(server-scan): plumbing for triggering library scans
Adds `startScan` / `getScanStatus` against the Subsonic API
(`fullScan=true` is Navidrome's extension), a small per-server scan
store, and a global polling hook (2 s cadence) that emits a toast when
each scan finishes. Scans can run on any configured server, including
inactive ones, by reusing `apiForServer`.
UI surfaces follow in the next commit.
* feat(server-scan): expose Quick / Full Scan in switcher + settings cards
Adds a `ServerScanActions` component with two variants (compact for the
server-switcher dropdown, card for the Settings server cards) backed by
the scan store from the previous commit. Full Scan requires a second
click within 3 s to confirm, matching the playlist-delete pattern.
Status slot shows a spinner with running track count while scanning, a
green check when finished, and a red icon on error.
The switcher row is converted from a single button to a flex container
so per-server scan controls don't hijack the server-switch click.
i18n added across all 9 locales.
* fix(server-scan): reorder switcher row to check / name / scan actions
Moves the check / spinner slot from the right edge to the left so the
spinner pop-in on server switch doesn't sit next to the scan icons.
Removes the layout shift that briefly hovered the Quick scan button
when the row re-rendered.
* feat(servers): edit existing server profiles in Settings → Servers
* Pencil-button on each server card opens an inline edit form that
replaces the card (prefilled name / URL / username / password).
* `AddServerForm` reused with an `editingServer` prop — title flips to
"Edit Server", submit label to "Save", magic-string field hidden (the
edit scope is manual fields; magic-string remains an add-time invite
shortcut).
* Edit saves unconditionally — ping runs post-save as a status indicator
(analog to the existing Test button) instead of gating the save. Lets
users update a profile when the server is currently unreachable.
* Translations across all 9 locales (`editServer`, `editServerTitle`).
* fix(servers): submit Add/Edit Server form on Enter
Wrapped the form body in a real <form>, made the submit button
type="submit", marked Cancel as type="button" so Enter no longer
cancels. Add-Mode now also responds to Enter — same flow, consistent
across both modes.
* fix(servers): collapse card action buttons to icon-only on narrow screens
* Quick-Scan / Full-Scan / Test buttons in each server card hide their
text label below 1100px viewport via the .server-card-btn-label class
and a single media query in connection-indicator.css.
* Labels remain accessible via data-tooltip and aria-label so screen
readers + hover both keep working in the collapsed state.
* No content reflow above the breakpoint — pure additive CSS.
* fix(servers): include Use button in icon-only narrow-screen collapse
The Use ("Verwenden") button on inactive server cards lacked the
.server-card-btn-label wrapper, so its text stayed visible at narrow
viewports and pushed Edit/Delete off-screen. Added a Power icon and
wrapped the label so it collapses alongside the other action buttons.
* docs(changelog,credits): #780 server scan + edit
This commit is contained in:
committed by
GitHub
parent
562218f447
commit
bca45d5a80
@@ -15,13 +15,20 @@ export function AddServerForm({
|
||||
onSave,
|
||||
onCancel,
|
||||
initialInvite = null,
|
||||
editingServer = null,
|
||||
}: {
|
||||
onSave: (data: Omit<ServerProfile, 'id'>) => void;
|
||||
onCancel: () => void;
|
||||
initialInvite?: ServerMagicPayload | null;
|
||||
editingServer?: ServerProfile | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [form, setForm] = useState({ name: '', url: '', username: '', password: '' });
|
||||
const isEdit = editingServer != null;
|
||||
const [form, setForm] = useState(
|
||||
editingServer
|
||||
? { name: editingServer.name, url: editingServer.url, username: editingServer.username, password: editingServer.password }
|
||||
: { name: '', url: '', username: '', password: '' },
|
||||
);
|
||||
const [magicString, setMagicString] = useState('');
|
||||
const [showPass, setShowPass] = useState(false);
|
||||
const [blockPasswordReveal, setBlockPasswordReveal] = useState(false);
|
||||
@@ -85,8 +92,14 @@ export function AddServerForm({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="settings-card" style={{ marginTop: '1rem' }}>
|
||||
<h3 style={{ fontWeight: 600, marginBottom: '1rem', fontSize: '14px' }}>{t('settings.addServerTitle')}</h3>
|
||||
<form
|
||||
className="settings-card"
|
||||
style={{ marginTop: '1rem' }}
|
||||
onSubmit={e => { e.preventDefault(); submit(); }}
|
||||
>
|
||||
<h3 style={{ fontWeight: 600, marginBottom: '1rem', fontSize: '14px' }}>
|
||||
{isEdit ? t('settings.editServerTitle') : t('settings.addServerTitle')}
|
||||
</h3>
|
||||
<div className="form-group" style={{ marginBottom: '0.75rem' }}>
|
||||
<label style={{ fontSize: 13 }}>{t('settings.serverName')}</label>
|
||||
<input className="input" type="text" value={form.name} onChange={update('name')} placeholder="My Navidrome" autoComplete="off" />
|
||||
@@ -142,26 +155,25 @@ export function AddServerForm({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group" style={{ marginBottom: '0.75rem' }}>
|
||||
<label style={{ fontSize: 13 }}>{t('login.orMagicString')}</label>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
value={magicString}
|
||||
onChange={handleMagicStringChange}
|
||||
placeholder={t('login.magicStringPlaceholder')}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
{!isEdit && (
|
||||
<div className="form-group" style={{ marginBottom: '0.75rem' }}>
|
||||
<label style={{ fontSize: 13 }}>{t('login.orMagicString')}</label>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
value={magicString}
|
||||
onChange={handleMagicStringChange}
|
||||
placeholder={t('login.magicStringPlaceholder')}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end' }}>
|
||||
<button className="btn btn-ghost" onClick={onCancel}>{t('common.cancel')}</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={submit}
|
||||
>
|
||||
{t('common.add')}
|
||||
<button type="button" className="btn btn-ghost" onClick={onCancel}>{t('common.cancel')}</button>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{isEdit ? t('common.save') : t('common.add')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Check, DatabaseZap, Loader2, RefreshCw, WifiOff } from 'lucide-react';
|
||||
import { startScan } from '../../api/subsonicScan';
|
||||
import { useScanStore } from '../../store/scanStore';
|
||||
import { showToast } from '../../utils/ui/toast';
|
||||
|
||||
const CONFIRM_TIMEOUT_MS = 3000;
|
||||
|
||||
interface Props {
|
||||
serverId: string;
|
||||
/** `compact` for the server switcher dropdown (icon-only), `card` for the settings cards. */
|
||||
variant: 'compact' | 'card';
|
||||
}
|
||||
|
||||
export default function ServerScanActions({ serverId, variant }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const entry = useScanStore(s => s.byServer[serverId]);
|
||||
const setEntry = useScanStore(s => s.set);
|
||||
const confirmTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const phase = entry?.phase ?? 'idle';
|
||||
const count = entry?.count ?? 0;
|
||||
const confirmingFull = entry?.confirmingFull ?? false;
|
||||
const busy = phase === 'starting' || phase === 'running';
|
||||
|
||||
useEffect(() => () => {
|
||||
if (confirmTimerRef.current) clearTimeout(confirmTimerRef.current);
|
||||
}, []);
|
||||
|
||||
const armConfirmTimeout = () => {
|
||||
if (confirmTimerRef.current) clearTimeout(confirmTimerRef.current);
|
||||
confirmTimerRef.current = setTimeout(() => {
|
||||
setEntry(serverId, { confirmingFull: false });
|
||||
}, CONFIRM_TIMEOUT_MS);
|
||||
};
|
||||
|
||||
const launch = async (full: boolean) => {
|
||||
setEntry(serverId, { phase: 'starting', type: full ? 'full' : 'quick', count: 0, confirmingFull: false });
|
||||
try {
|
||||
await startScan(serverId, full);
|
||||
setEntry(serverId, { phase: 'running' });
|
||||
} catch (err) {
|
||||
setEntry(serverId, { phase: 'error' });
|
||||
showToast(typeof err === 'string' ? err : err instanceof Error ? err.message : 'Scan failed', 4000, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const onQuickClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (busy) return;
|
||||
void launch(false);
|
||||
};
|
||||
|
||||
const onFullClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (busy) return;
|
||||
if (!confirmingFull) {
|
||||
setEntry(serverId, { confirmingFull: true });
|
||||
armConfirmTimeout();
|
||||
return;
|
||||
}
|
||||
if (confirmTimerRef.current) clearTimeout(confirmTimerRef.current);
|
||||
void launch(true);
|
||||
};
|
||||
|
||||
// ── Status slot content ──────────────────────────────────────────────────
|
||||
const status = (() => {
|
||||
if (phase === 'running' || phase === 'starting') {
|
||||
const label = count > 0 ? formatCount(count) : '';
|
||||
return (
|
||||
<span className="server-scan-status" data-tooltip={t('settings.scan.scanning')}>
|
||||
<Loader2 size={13} className="spin-slow" />
|
||||
{label && <span className="server-scan-status-count">{label}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (phase === 'just-finished') {
|
||||
return (
|
||||
<span className="server-scan-status server-scan-status--done" data-tooltip={t('settings.scan.done')}>
|
||||
<Check size={13} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (phase === 'error') {
|
||||
return (
|
||||
<span className="server-scan-status server-scan-status--error" data-tooltip={t('settings.scan.error')}>
|
||||
<WifiOff size={13} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
// ── Render ───────────────────────────────────────────────────────────────
|
||||
if (variant === 'compact') {
|
||||
return (
|
||||
<span className="server-scan-actions server-scan-actions--compact" onClick={e => e.stopPropagation()}>
|
||||
<button
|
||||
type="button"
|
||||
className="player-btn player-btn-sm"
|
||||
onClick={onQuickClick}
|
||||
disabled={busy}
|
||||
data-tooltip={t('settings.scan.quickTip')}
|
||||
data-tooltip-pos="bottom"
|
||||
aria-label={t('settings.scan.quick')}
|
||||
>
|
||||
<RefreshCw size={13} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`player-btn player-btn-sm${confirmingFull ? ' server-scan-btn--confirm' : ''}`}
|
||||
onClick={onFullClick}
|
||||
disabled={busy}
|
||||
data-tooltip={confirmingFull ? t('settings.scan.confirmFull') : t('settings.scan.fullTip')}
|
||||
data-tooltip-pos="bottom"
|
||||
aria-label={t('settings.scan.full')}
|
||||
>
|
||||
<DatabaseZap size={13} />
|
||||
</button>
|
||||
<span className="server-scan-slot" aria-live="polite">{status}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Card variant — matches the existing `btn btn-surface` chrome in ServersTab actions.
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-surface"
|
||||
style={{ fontSize: 12, padding: '4px 10px' }}
|
||||
onClick={onQuickClick}
|
||||
disabled={busy}
|
||||
data-tooltip={t('settings.scan.quickTip')}
|
||||
aria-label={t('settings.scan.quick')}
|
||||
>
|
||||
<RefreshCw size={13} />
|
||||
<span className="server-card-btn-label">{t('settings.scan.quick')}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-surface${confirmingFull ? ' btn-confirm-danger' : ''}`}
|
||||
style={{ fontSize: 12, padding: '4px 10px' }}
|
||||
onClick={onFullClick}
|
||||
disabled={busy}
|
||||
data-tooltip={confirmingFull ? t('settings.scan.confirmFull') : t('settings.scan.fullTip')}
|
||||
aria-label={t('settings.scan.full')}
|
||||
>
|
||||
<DatabaseZap size={13} />
|
||||
<span className="server-card-btn-label">
|
||||
{confirmingFull ? t('settings.scan.confirmFullShort') : t('settings.scan.full')}
|
||||
</span>
|
||||
</button>
|
||||
{status && <span className="server-scan-slot">{status}</span>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function formatCount(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
|
||||
return String(n);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { open as openUrl } from '@tauri-apps/plugin-shell';
|
||||
import { AlertTriangle, CheckCircle2, Lock, LogOut, Plus, Server, Sparkles, Trash2, User, Wifi, WifiOff } from 'lucide-react';
|
||||
import { AlertTriangle, CheckCircle2, Lock, LogOut, Pencil, Plus, Power, Server, Sparkles, Trash2, User, Wifi, WifiOff } from 'lucide-react';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import type { ServerProfile } from '../../store/authStoreTypes';
|
||||
import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../../api/subsonic';
|
||||
@@ -13,6 +13,7 @@ import { serverListDisplayLabel } from '../../utils/server/serverDisplayName';
|
||||
import { switchActiveServer } from '../../utils/server/switchActiveServer';
|
||||
import { AddServerForm } from './AddServerForm';
|
||||
import { ServerGripHandle } from './ServerGripHandle';
|
||||
import ServerScanActions from './ServerScanActions';
|
||||
|
||||
const AUDIOMUSE_NV_PLUGIN_URL = 'https://github.com/NeptuneHub/AudioMuse-AI-NV-plugin';
|
||||
|
||||
@@ -30,6 +31,7 @@ export function ServersTab({
|
||||
|
||||
const [connStatus, setConnStatus] = useState<Record<string, 'idle' | 'testing' | 'ok' | 'error'>>({});
|
||||
const [showAddForm, setShowAddForm] = useState<boolean>(initialInvite != null);
|
||||
const [editingServerId, setEditingServerId] = useState<string | null>(null);
|
||||
const [pastedServerInvite, setPastedServerInvite] = useState<ServerMagicPayload | null>(initialInvite);
|
||||
const [serverContainerEl, setServerContainerEl] = useState<HTMLDivElement | null>(null);
|
||||
const [serverDropTarget, setServerDropTarget] = useState<ServerDropTarget>(null);
|
||||
@@ -168,6 +170,31 @@ export function ServersTab({
|
||||
}
|
||||
};
|
||||
|
||||
// Edit saves unconditionally — ping result becomes a post-save status
|
||||
// indicator (analog zum existing Test-Button) rather than blocking the
|
||||
// save. Lets users update a profile even when the server is currently
|
||||
// unreachable.
|
||||
const handleEditServer = async (id: string, data: Omit<ServerProfile, 'id'>) => {
|
||||
setEditingServerId(null);
|
||||
auth.updateServer(id, data);
|
||||
setConnStatus(s => ({ ...s, [id]: 'testing' }));
|
||||
try {
|
||||
const ping = await pingWithCredentials(data.url, data.username, data.password);
|
||||
if (ping.ok) {
|
||||
const identity = {
|
||||
type: ping.type,
|
||||
serverVersion: ping.serverVersion,
|
||||
openSubsonic: ping.openSubsonic,
|
||||
};
|
||||
auth.setSubsonicServerIdentity(id, identity);
|
||||
scheduleInstantMixProbeForServer(id, data.url, data.username, data.password, identity);
|
||||
}
|
||||
setConnStatus(s => ({ ...s, [id]: ping.ok ? 'ok' : 'error' }));
|
||||
} catch {
|
||||
setConnStatus(s => ({ ...s, [id]: 'error' }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
auth.logout();
|
||||
navigate('/login');
|
||||
@@ -195,6 +222,16 @@ export function ServersTab({
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}
|
||||
>
|
||||
{auth.servers.map((srv, srvIdx) => {
|
||||
if (editingServerId === srv.id) {
|
||||
return (
|
||||
<AddServerForm
|
||||
key={srv.id}
|
||||
editingServer={srv}
|
||||
onSave={(data) => handleEditServer(srv.id, data)}
|
||||
onCancel={() => setEditingServerId(null)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const isActive = srv.id === auth.activeServerId;
|
||||
const status = connStatus[srv.id];
|
||||
const isBefore = psyDragState.isDragging && serverDropTarget?.idx === srvIdx && serverDropTarget.before;
|
||||
@@ -240,14 +277,17 @@ export function ServersTab({
|
||||
{status === 'ok' && <CheckCircle2 size={16} style={{ color: 'var(--positive)' }} />}
|
||||
{status === 'error' && <WifiOff size={16} style={{ color: 'var(--danger)' }} />}
|
||||
{status === 'testing' && <div className="spinner" style={{ width: 16, height: 16 }} />}
|
||||
<ServerScanActions serverId={srv.id} variant="card" />
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
style={{ fontSize: 12, padding: '4px 10px' }}
|
||||
onClick={() => testConnection(srv)}
|
||||
disabled={status === 'testing'}
|
||||
data-tooltip={t('settings.testBtn')}
|
||||
aria-label={t('settings.testBtn')}
|
||||
>
|
||||
<Wifi size={13} />
|
||||
{t('settings.testBtn')}
|
||||
<span className="server-card-btn-label">{t('settings.testBtn')}</span>
|
||||
</button>
|
||||
{!isActive && (
|
||||
<button
|
||||
@@ -256,10 +296,26 @@ export function ServersTab({
|
||||
onClick={() => switchToServer(srv)}
|
||||
disabled={status === 'testing'}
|
||||
id={`settings-use-server-${srv.id}`}
|
||||
data-tooltip={t('settings.useServer')}
|
||||
aria-label={t('settings.useServer')}
|
||||
>
|
||||
{t('settings.useServer')}
|
||||
<Power size={13} />
|
||||
<span className="server-card-btn-label">{t('settings.useServer')}</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ padding: '4px 8px' }}
|
||||
onClick={() => {
|
||||
setShowAddForm(false);
|
||||
setPastedServerInvite(null);
|
||||
setEditingServerId(srv.id);
|
||||
}}
|
||||
data-tooltip={t('settings.editServer')}
|
||||
id={`settings-edit-server-${srv.id}`}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ color: 'var(--danger)', padding: '4px 8px' }}
|
||||
|
||||
Reference in New Issue
Block a user