Files
psysonic/src/components/settings/AddServerForm.tsx
T
cucadmuh ae9be74719 feat(settings): compact server cards with capability badges (#1054)
* feat(settings): redesign server cards with identity line and capability badges

Compact two-line server headers (entry name + user@host), HTTPS lock, and a
clickable version info tooltip. Navidrome ≥0.62 shows a green AudioMuse inline
badge; older Navidrome keeps the manual toggle row. Adds click-pinned tooltips
via data-tooltip-click on TooltipPortal.

* feat(settings): unify use/active slot and move delete into edit form

Merge Active badge and Use button into one rightmost action; Active uses
green styling. Reorder actions to edit, test, use/active. Remove the card
delete icon — deletion lives in the server edit form footer.

* docs: note compact server cards in CHANGELOG and credits (PR #1054)

* fix(credits): move PR #1054 server cards line to cucadmuh block

Was appended to Psychotoxical's contributions array by mistake; CHANGELOG
already credited cucadmuh (gh pr view author).
2026-06-10 00:25:30 +03:00

372 lines
13 KiB
TypeScript

import React, { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { ServerProfile } from '../../store/authStoreTypes';
import { showToast } from '../../utils/ui/toast';
import {
decodeServerMagicString,
encodeServerMagicString,
DECODED_PASSWORD_VISUAL_MASK,
type ServerMagicPayload,
} from '../../utils/server/serverMagicString';
import { shortHostFromServerUrl } from '../../utils/server/serverDisplayName';
import { isLanUrl } from '../../utils/server/serverEndpoint';
import { resolveHostAddresses } from '../../api/network';
type FormState = {
name: string;
url: string;
alternateUrl: string;
shareUsesLocalUrl: boolean;
username: string;
password: string;
};
/** Hostname for the DNS-based form hint, or null when the input is a literal IP. */
function hostnameForDnsHint(rawUrl: string): string | null {
const trimmed = rawUrl.trim();
if (!trimmed) return null;
try {
const u = new URL(trimmed.startsWith('http') ? trimmed : `http://${trimmed}`);
const host = u.hostname.replace(/^\[|\]$/g, '');
// Literal IPv4 / IPv6 → no DNS lookup; isLanUrl alone classifies it.
if (/^\d+\.\d+\.\d+\.\d+$/.test(host)) return null;
if (host.includes(':')) return null;
if (host === 'localhost' || host.endsWith('.local')) return null;
return host;
} catch {
return null;
}
}
export function AddServerForm({
onSave,
onCancel,
onDelete,
initialInvite = null,
editingServer = null,
}: {
onSave: (data: Omit<ServerProfile, 'id'>) => void | Promise<void>;
onCancel: () => void;
onDelete?: () => void | Promise<void>;
initialInvite?: ServerMagicPayload | null;
editingServer?: ServerProfile | null;
}) {
const { t } = useTranslation();
const isEdit = editingServer != null;
const [form, setForm] = useState<FormState>(
editingServer
? {
name: editingServer.name,
url: editingServer.url,
alternateUrl: editingServer.alternateUrl ?? '',
shareUsesLocalUrl: editingServer.shareUsesLocalUrl ?? false,
username: editingServer.username,
password: editingServer.password,
}
: {
name: '',
url: '',
alternateUrl: '',
shareUsesLocalUrl: false,
username: '',
password: '',
},
);
const [magicString, setMagicString] = useState('');
const [blockPasswordReveal, setBlockPasswordReveal] = useState(false);
// DNS-classified hint: 'lan' / 'public' / null (no hint, no lookup yet, or
// literal IP — isLanUrl already classifies those without DNS).
const [primaryDnsClass, setPrimaryDnsClass] = useState<'lan' | 'public' | null>(null);
useEffect(() => {
if (!initialInvite) return;
setBlockPasswordReveal(true);
setForm(f => ({
...f,
name: (initialInvite.name && initialInvite.name.trim()) || shortHostFromServerUrl(initialInvite.url),
url: initialInvite.url,
// v2 invites carry the host's dual-address fields. Pre-populate so the
// receiver sees both addresses + the share preference rather than
// re-typing them.
alternateUrl: initialInvite.alternateUrl ?? '',
shareUsesLocalUrl: initialInvite.shareUsesLocalUrl ?? false,
username: initialInvite.username,
password: initialInvite.password,
}));
setMagicString(encodeServerMagicString(initialInvite));
}, [initialInvite]);
const update = <K extends keyof FormState>(k: K) =>
(e: React.ChangeEvent<HTMLInputElement>) => {
const value =
e.target.type === 'checkbox'
? (e.target as HTMLInputElement).checked
: e.target.value;
setForm(f => ({ ...f, [k]: value }) as FormState);
};
const handleMagicStringChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const v = e.target.value;
setMagicString(v);
const trimmed = v.trim();
const decoded = decodeServerMagicString(trimmed);
if (decoded) {
setBlockPasswordReveal(true);
setForm(f => ({
...f,
name: (decoded.name && decoded.name.trim()) || shortHostFromServerUrl(decoded.url),
url: decoded.url,
alternateUrl: decoded.alternateUrl ?? '',
shareUsesLocalUrl: decoded.shareUsesLocalUrl ?? false,
username: decoded.username,
password: decoded.password,
}));
}
};
// Literal-IP classification — instant, no DNS needed.
const primaryUrlIsLanLiteral = useMemo(() => {
const trimmed = form.url.trim();
if (!trimmed) return null as boolean | null;
if (hostnameForDnsHint(trimmed) !== null) return null; // hostname — defer to DNS
return isLanUrl(trimmed);
}, [form.url]);
// Effective LAN classification: literal IP shortcut OR DNS result.
const primaryUrlIsLan = useMemo(() => {
if (primaryUrlIsLanLiteral !== null) return primaryUrlIsLanLiteral;
if (primaryDnsClass === null) return null;
return primaryDnsClass === 'lan';
}, [primaryUrlIsLanLiteral, primaryDnsClass]);
const runDnsHint = async () => {
const hostname = hostnameForDnsHint(form.url);
if (!hostname) {
setPrimaryDnsClass(null);
return;
}
const addresses = await resolveHostAddresses(hostname);
if (addresses.length === 0) {
// DNS failed (no network, NXDOMAIN, …) → no hint, don't block.
setPrimaryDnsClass(null);
return;
}
const anyPublic = addresses.some(ip => !isLanUrl(`http://${ip}`));
setPrimaryDnsClass(anyPublic ? 'public' : 'lan');
};
// Two-LAN client-side check before submit. Returns true on validation pass.
const validateAddresses = (): boolean => {
const url = form.url.trim();
const alt = form.alternateUrl.trim();
if (!url) return false;
if (!alt) return true; // single-address — always fine.
// For the LAN-LAN check we accept both the synchronous isLanUrl
// classification (literal IPs + .local + localhost) and the DNS-resolved
// class for the primary. The alternate goes through isLanUrl directly —
// we don't run a second DNS lookup for it here; the verify step on save
// will catch any deeper inconsistency.
const primaryLan = primaryUrlIsLan ?? isLanUrl(url);
const altLan = isLanUrl(alt);
if (primaryLan && altLan) {
showToast(t('settings.serverBothLanError'), 4500, 'error');
return false;
}
return true;
};
const submit = async () => {
const ms = magicString.trim();
if (ms) {
const decoded = decodeServerMagicString(ms);
if (!decoded) {
showToast(t('login.magicStringInvalid'), 4000, 'error');
return;
}
// v2 invites carry alternateUrl + shareUsesLocalUrl — must survive the
// magic-string submit path (handleMagicStringChange already prefills
// them into form state, but the magic-string branch forwards the
// decoded payload directly so we have to pick them off here too).
const altDecoded = decoded.alternateUrl?.trim() ?? '';
await onSave({
name: form.name.trim() || (decoded.name && decoded.name.trim()) || shortHostFromServerUrl(decoded.url),
url: decoded.url,
username: decoded.username,
password: decoded.password,
...(altDecoded
? {
alternateUrl: altDecoded,
shareUsesLocalUrl: decoded.shareUsesLocalUrl ?? false,
}
: {}),
});
return;
}
if (!form.url.trim()) return;
if (!validateAddresses()) return;
const altTrimmed = form.alternateUrl.trim();
// If the user clears the second address, strip the share-flag as well so
// we don't leave a dangling preference (spec §5.3 last row).
const data: Omit<ServerProfile, 'id'> = {
name: form.name.trim() || form.url.trim(),
url: form.url.trim(),
username: form.username.trim(),
password: form.password,
...(altTrimmed
? {
alternateUrl: altTrimmed,
shareUsesLocalUrl: form.shareUsesLocalUrl,
}
: {}),
};
await onSave(data);
};
// Hint to show under the second-address field: only when the primary is
// classified one way or the other and the second is still empty.
const alternateUrlHint =
!form.alternateUrl.trim() && primaryUrlIsLan !== null
? primaryUrlIsLan
? t('settings.serverAlternateUrlHintAddPublic')
: t('settings.serverAlternateUrlHintAddLocal')
: null;
// Share checkbox visibility (spec §5.3):
// - hidden when there is no second address
// - shown the moment a user fills the second field (or both already exist
// on an edit). Default off; persists across save/load.
const showShareCheckbox = form.alternateUrl.trim().length > 0;
return (
<form
className="settings-card"
style={{ marginTop: '1rem' }}
onSubmit={e => { e.preventDefault(); void 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" />
</div>
<div className="form-group" style={{ marginBottom: '0.75rem' }}>
<label style={{ fontSize: 13 }}>{t('settings.serverUrl')}</label>
<input
className="input"
type="text"
value={form.url}
onChange={update('url')}
onBlur={() => { void runDnsHint(); }}
placeholder={t('settings.serverUrlPlaceholder')}
autoComplete="off"
/>
</div>
<div className="form-group" style={{ marginBottom: '0.75rem' }}>
<label style={{ fontSize: 13 }}>{t('settings.serverAlternateUrl')}</label>
<input
className="input"
type="text"
value={form.alternateUrl}
onChange={update('alternateUrl')}
placeholder={
primaryUrlIsLan === true
? t('settings.serverAlternateUrlPlaceholderPublic')
: t('settings.serverAlternateUrlPlaceholderLocal')
}
autoComplete="off"
/>
{alternateUrlHint && (
<div style={{ fontSize: 11, opacity: 0.7, marginTop: 4 }}>
{alternateUrlHint}
</div>
)}
</div>
{showShareCheckbox && (
<div className="form-group" style={{ marginBottom: '0.75rem' }}>
<label style={{ fontSize: 13, display: 'flex', alignItems: 'flex-start', gap: 8, cursor: 'pointer' }}>
<input
type="checkbox"
checked={form.shareUsesLocalUrl}
onChange={update('shareUsesLocalUrl')}
style={{ marginTop: 2 }}
/>
<span style={{ display: 'flex', flexDirection: 'column' }}>
<span>{t('settings.shareUsesLocalUrl')}</span>
<span style={{ fontSize: 11, opacity: 0.7 }}>
{t('settings.shareUsesLocalUrlDesc')}
</span>
</span>
</label>
</div>
)}
<div className="form-row" style={{ marginBottom: '0.75rem' }}>
<div className="form-group">
<label style={{ fontSize: 13 }}>{t('settings.serverUsername')}</label>
<input
className="input"
type="text"
value={form.username}
onChange={update('username')}
placeholder="admin"
autoComplete="off"
readOnly={blockPasswordReveal}
style={blockPasswordReveal ? { cursor: 'default' } : undefined}
/>
</div>
<div className="form-group">
<label style={{ fontSize: 13 }}>{t('settings.serverPassword')}</label>
{blockPasswordReveal ? (
<input
className="input"
type="text"
readOnly
value={DECODED_PASSWORD_VISUAL_MASK}
autoComplete="off"
aria-label={t('settings.serverPassword')}
style={{ letterSpacing: '0.12em', cursor: 'default' }}
/>
) : (
<input
className="input"
type="password"
value={form.password}
onChange={update('password')}
placeholder="••••••••"
/>
)}
</div>
</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: 'space-between', alignItems: 'center' }}>
{isEdit && onDelete ? (
<button type="button" className="btn btn-danger" onClick={() => void onDelete()}>
{t('settings.deleteServer')}
</button>
) : (
<span />
)}
<div style={{ display: 'flex', gap: '8px' }}>
<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>
);
}