mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
ae9be74719
* 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).
372 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|