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:
Frank Stellmacher
2026-05-18 16:32:40 +02:00
committed by GitHub
parent 562218f447
commit bca45d5a80
20 changed files with 592 additions and 37 deletions
+33 -21
View File
@@ -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>
);
}