fix(navidrome-admin): resilient admin API calls + UI polish (#260)

* fix(navidrome-admin): force HTTP/1.1 + User-Agent + no idle pool for /auth and /api calls

Replaces plain reqwest::Client::new() in all nd_* Tauri commands
(navidrome_login, nd_list_users, nd_create_user, nd_update_user,
nd_delete_user, nd_list_libraries, nd_set_user_libraries) with a shared
nd_http_client() helper that:

- sets a real User-Agent (Psysonic/<version> (Tauri))
- pins HTTP/1.1 (avoids HTTP/2 ALPN that some reverse proxies abort on)
- disables the idle-connection pool so a second call doesn't reuse a
  TCP connection that the server or proxy has already half-closed
  (was producing intermittent "tls handshake eof" on external servers).

Adds nd_err() to flatten the reqwest error source chain into the
returned String, so the frontend surfaces the real cause (connection
refused, tls handshake eof, etc.) instead of reqwest's opaque
"error sending request for url (…)" wrapper.

* fix(navidrome-admin): retry + graceful error + server row polish

Rust (src-tauri/src/lib.rs):
- nd_http_client: HTTP/1.1, TLS 1.2 only, no idle pool — browser-parity
  for /auth and /api so strict reverse proxies that abort reqwest's
  default HTTP/2+TLS-1.3 handshake mid-flight get through.
- nd_retry: one retry after 500ms on connect/timeout errors only
  (ECONNRESET, TLS handshake EOF). Aggressive retries could push the
  nginx upstream probe into offline state; this is the minimal useful
  amount.
- nd_err: flatten the reqwest error source chain so the UI surfaces the
  real cause instead of reqwest's opaque wrapper.

User management UI:
- Sequential load (users, then libraries) instead of parallel, to avoid
  racing two TLS connections on a single nginx upstream slot.
- Friendlier failure state with a one-click Retry button that re-runs
  load(). Concrete Rust error is kept as muted sub-line.
- User row layout: Magic-String button sits consistently next to
  last-seen + delete for every user, regardless of admin status or
  library-name length. No more mid-row jitter.

Servers tab:
- "Use" button no longer redirects to Home; stays on the Servers tab so
  the active-badge migration is visible.
- Drag-and-drop reorder via grip handle (psyDnD), backed by a new
  setServers() action on authStore.
- Active server row now has an accent-tinted background on top of the
  border — harder to miss.

New i18n keys (userMgmtLoadFriendly, userMgmtRetry) added to all 8 locales.

---------

Co-authored-by: Psychotoxical <dev@psysonic.app>
This commit is contained in:
Frank Stellmacher
2026-04-22 03:13:09 +02:00
committed by GitHub
parent 2a496c600b
commit 21d00889aa
11 changed files with 289 additions and 69 deletions
+99 -26
View File
@@ -310,6 +310,79 @@ struct NdLoginResult {
is_admin: bool, is_admin: bool,
} }
/// Flatten an error and its `source` chain into a single readable string so
/// frontend toasts can show the actual transport cause (connection refused,
/// tls handshake fail, cert expired, etc.) instead of reqwest's opaque
/// "error sending request for url (…)" wrapper.
fn nd_err(e: reqwest::Error) -> String {
let mut msg = e.to_string();
let mut src: Option<&(dyn std::error::Error + 'static)> = std::error::Error::source(&e);
while let Some(s) = src {
msg.push_str(" | ");
msg.push_str(&s.to_string());
src = s.source();
}
msg
}
/// Retry a request-building closure on transient transport errors
/// (connect/timeout — includes ECONNRESET, TLS handshake EOF, DNS flakes).
/// Three attempts with calm backoff: 0 → 300ms → 700ms (total worst case
/// ~1s). Retrying too aggressively (5+ attempts, short backoff) can drive
/// an already-stressed nginx upstream-probe into "offline" mode, which
/// turns a transient glitch into a visible outage. Status-level failures
/// (401/403/400 with body) return immediately — we don't retry logic
/// errors.
async fn nd_retry<F, Fut>(mut build_and_send: F) -> Result<reqwest::Response, String>
where
F: FnMut() -> Fut,
Fut: std::future::Future<Output = Result<reqwest::Response, reqwest::Error>>,
{
const BACKOFFS_MS: [u64; 1] = [500];
let mut last: Option<reqwest::Error> = None;
for attempt in 0..=BACKOFFS_MS.len() {
if attempt > 0 {
tokio::time::sleep(std::time::Duration::from_millis(BACKOFFS_MS[attempt - 1])).await;
}
match build_and_send().await {
Ok(resp) => return Ok(resp),
Err(e) => {
if !e.is_connect() && !e.is_timeout() {
return Err(nd_err(e));
}
last = Some(e);
}
}
}
Err(nd_err(last.expect("loop ran at least once")))
}
/// Build a reqwest client for Navidrome's native REST endpoints. Plain
/// `reqwest::Client::new()` defaults to HTTP/2 over ALPN with no User-Agent,
/// which some reverse-proxies (strict nginx rules, Cloudflare Tunnel, CDN
/// WAFs) abort mid-TLS-handshake. Pinning HTTP/1.1 and advertising a real
/// User-Agent makes the handshake match what browsers do for the Subsonic
/// endpoints, so `/auth/*` + `/api/*` go through the same path as `/rest/*`.
///
/// `pool_max_idle_per_host(0)` disables connection pooling. Keeping stale
/// keep-alive connections in the pool caused intermittent "tls handshake
/// eof" errors on the second call to an admin endpoint when a server or
/// proxy had already closed the TCP connection between calls.
fn nd_http_client() -> reqwest::Client {
// TLS 1.2 only: rustls + nginx with TLS-1.3 session resumption caches
// produces intermittent ECONNRESET mid-handshake when the upstream
// starts churning keep-alive connections. Pinning TLS 1.2 matches what
// the WebKit-side Subsonic calls end up negotiating most of the time
// on these setups.
reqwest::Client::builder()
.user_agent(format!("Psysonic/{} (Tauri)", env!("CARGO_PKG_VERSION")))
.http1_only()
.pool_max_idle_per_host(0)
.max_tls_version(reqwest::tls::Version::TLS_1_2)
.build()
.unwrap_or_else(|_| reqwest::Client::new())
}
/// Log in to Navidrome's native REST API. Returns a Bearer token and whether the user is admin. /// Log in to Navidrome's native REST API. Returns a Bearer token and whether the user is admin.
#[tauri::command] #[tauri::command]
async fn navidrome_login( async fn navidrome_login(
@@ -317,17 +390,17 @@ async fn navidrome_login(
username: String, username: String,
password: String, password: String,
) -> Result<NdLoginResult, String> { ) -> Result<NdLoginResult, String> {
let client = reqwest::Client::new(); let body = serde_json::json!({ "username": username, "password": password });
let resp = client let resp = nd_retry(|| {
nd_http_client()
.post(format!("{}/auth/login", server_url)) .post(format!("{}/auth/login", server_url))
.json(&serde_json::json!({ "username": username, "password": password })) .json(&body)
.send() .send()
.await }).await?;
.map_err(|e| e.to_string())?;
if !resp.status().is_success() { if !resp.status().is_success() {
return Err(format!("Navidrome login failed: HTTP {}", resp.status())); return Err(format!("Navidrome login failed: HTTP {}", resp.status()));
} }
let data: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?; let data: serde_json::Value = resp.json().await.map_err(nd_err)?;
let token = data["token"].as_str().ok_or("no token in response")?.to_string(); let token = data["token"].as_str().ok_or("no token in response")?.to_string();
let user_id = data["id"].as_str().unwrap_or("").to_string(); let user_id = data["id"].as_str().unwrap_or("").to_string();
let is_admin = data["isAdmin"].as_bool().unwrap_or(false); let is_admin = data["isAdmin"].as_bool().unwrap_or(false);
@@ -340,16 +413,16 @@ async fn nd_list_users(
server_url: String, server_url: String,
token: String, token: String,
) -> Result<serde_json::Value, String> { ) -> Result<serde_json::Value, String> {
let resp = reqwest::Client::new() let resp = nd_retry(|| {
nd_http_client()
.get(format!("{}/api/user", server_url)) .get(format!("{}/api/user", server_url))
.header("X-ND-Authorization", format!("Bearer {}", token)) .header("X-ND-Authorization", format!("Bearer {}", token))
.send() .send()
.await }).await?;
.map_err(|e| e.to_string())?;
if !resp.status().is_success() { if !resp.status().is_success() {
return Err(format!("HTTP {}", resp.status())); return Err(format!("HTTP {}", resp.status()));
} }
resp.json::<serde_json::Value>().await.map_err(|e| e.to_string()) resp.json::<serde_json::Value>().await.map_err(nd_err)
} }
/// POST `/api/user` — create a user. /// POST `/api/user` — create a user.
@@ -370,13 +443,13 @@ async fn nd_create_user(
"password": password, "password": password,
"isAdmin": is_admin, "isAdmin": is_admin,
}); });
let resp = reqwest::Client::new() let resp = nd_retry(|| {
nd_http_client()
.post(format!("{}/api/user", server_url)) .post(format!("{}/api/user", server_url))
.header("X-ND-Authorization", format!("Bearer {}", token)) .header("X-ND-Authorization", format!("Bearer {}", token))
.json(&body) .json(&body)
.send() .send()
.await }).await?;
.map_err(|e| e.to_string())?;
let status = resp.status(); let status = resp.status();
let text = resp.text().await.unwrap_or_default(); let text = resp.text().await.unwrap_or_default();
if !status.is_success() { if !status.is_success() {
@@ -407,13 +480,13 @@ async fn nd_update_user(
if !password.is_empty() { if !password.is_empty() {
body["password"] = serde_json::Value::String(password); body["password"] = serde_json::Value::String(password);
} }
let resp = reqwest::Client::new() let resp = nd_retry(|| {
nd_http_client()
.put(format!("{}/api/user/{}", server_url, id)) .put(format!("{}/api/user/{}", server_url, id))
.header("X-ND-Authorization", format!("Bearer {}", token)) .header("X-ND-Authorization", format!("Bearer {}", token))
.json(&body) .json(&body)
.send() .send()
.await }).await?;
.map_err(|e| e.to_string())?;
let status = resp.status(); let status = resp.status();
let text = resp.text().await.unwrap_or_default(); let text = resp.text().await.unwrap_or_default();
if !status.is_success() { if !status.is_success() {
@@ -429,12 +502,12 @@ async fn nd_delete_user(
token: String, token: String,
id: String, id: String,
) -> Result<(), String> { ) -> Result<(), String> {
let resp = reqwest::Client::new() let resp = nd_retry(|| {
nd_http_client()
.delete(format!("{}/api/user/{}", server_url, id)) .delete(format!("{}/api/user/{}", server_url, id))
.header("X-ND-Authorization", format!("Bearer {}", token)) .header("X-ND-Authorization", format!("Bearer {}", token))
.send() .send()
.await }).await?;
.map_err(|e| e.to_string())?;
let status = resp.status(); let status = resp.status();
if !status.is_success() { if !status.is_success() {
let text = resp.text().await.unwrap_or_default(); let text = resp.text().await.unwrap_or_default();
@@ -449,16 +522,16 @@ async fn nd_list_libraries(
server_url: String, server_url: String,
token: String, token: String,
) -> Result<serde_json::Value, String> { ) -> Result<serde_json::Value, String> {
let resp = reqwest::Client::new() let resp = nd_retry(|| {
nd_http_client()
.get(format!("{}/api/library", server_url)) .get(format!("{}/api/library", server_url))
.header("X-ND-Authorization", format!("Bearer {}", token)) .header("X-ND-Authorization", format!("Bearer {}", token))
.send() .send()
.await }).await?;
.map_err(|e| e.to_string())?;
if !resp.status().is_success() { if !resp.status().is_success() {
return Err(format!("HTTP {}", resp.status())); return Err(format!("HTTP {}", resp.status()));
} }
resp.json::<serde_json::Value>().await.map_err(|e| e.to_string()) resp.json::<serde_json::Value>().await.map_err(nd_err)
} }
/// PUT `/api/user/{id}/library` — assign libraries to a non-admin user. /// PUT `/api/user/{id}/library` — assign libraries to a non-admin user.
@@ -471,13 +544,13 @@ async fn nd_set_user_libraries(
library_ids: Vec<i64>, library_ids: Vec<i64>,
) -> Result<(), String> { ) -> Result<(), String> {
let body = serde_json::json!({ "libraryIds": library_ids }); let body = serde_json::json!({ "libraryIds": library_ids });
let resp = reqwest::Client::new() let resp = nd_retry(|| {
nd_http_client()
.put(format!("{}/api/user/{}/library", server_url, id)) .put(format!("{}/api/user/{}/library", server_url, id))
.header("X-ND-Authorization", format!("Bearer {}", token)) .header("X-ND-Authorization", format!("Bearer {}", token))
.json(&body) .json(&body)
.send() .send()
.await }).await?;
.map_err(|e| e.to_string())?;
let status = resp.status(); let status = resp.status();
if !status.is_success() { if !status.is_success() {
let text = resp.text().await.unwrap_or_default(); let text = resp.text().await.unwrap_or_default();
+2
View File
@@ -481,6 +481,8 @@ export const deTranslation = {
userMgmtDesc: 'Benutzer auf diesem Server verwalten. Erfordert Admin-Rechte.', userMgmtDesc: 'Benutzer auf diesem Server verwalten. Erfordert Admin-Rechte.',
userMgmtNoAdmin: 'Du brauchst Admin-Rechte, um Benutzer auf diesem Server zu verwalten.', userMgmtNoAdmin: 'Du brauchst Admin-Rechte, um Benutzer auf diesem Server zu verwalten.',
userMgmtLoadError: 'Benutzer konnten nicht geladen werden.', userMgmtLoadError: 'Benutzer konnten nicht geladen werden.',
userMgmtLoadFriendly: 'Server hat nicht geantwortet — meistens ein Einzelfall.',
userMgmtRetry: 'Erneut versuchen',
userMgmtEmpty: 'Keine Benutzer gefunden.', userMgmtEmpty: 'Keine Benutzer gefunden.',
userMgmtYouBadge: 'Du', userMgmtYouBadge: 'Du',
userMgmtAdminBadge: 'Admin', userMgmtAdminBadge: 'Admin',
+2
View File
@@ -483,6 +483,8 @@ export const enTranslation = {
userMgmtDesc: 'Manage users on this server. Requires admin privileges.', userMgmtDesc: 'Manage users on this server. Requires admin privileges.',
userMgmtNoAdmin: 'You need admin privileges to manage users on this server.', userMgmtNoAdmin: 'You need admin privileges to manage users on this server.',
userMgmtLoadError: 'Failed to load users.', userMgmtLoadError: 'Failed to load users.',
userMgmtLoadFriendly: 'Server didn\'t answer — this is usually a one-off.',
userMgmtRetry: 'Retry',
userMgmtEmpty: 'No users found.', userMgmtEmpty: 'No users found.',
userMgmtYouBadge: 'You', userMgmtYouBadge: 'You',
userMgmtAdminBadge: 'Admin', userMgmtAdminBadge: 'Admin',
+2
View File
@@ -474,6 +474,8 @@ export const esTranslation = {
userMgmtDesc: 'Administra los usuarios de este servidor. Requiere privilegios de administrador.', userMgmtDesc: 'Administra los usuarios de este servidor. Requiere privilegios de administrador.',
userMgmtNoAdmin: 'Necesitas privilegios de administrador para gestionar usuarios en este servidor.', userMgmtNoAdmin: 'Necesitas privilegios de administrador para gestionar usuarios en este servidor.',
userMgmtLoadError: 'No se pudieron cargar los usuarios.', userMgmtLoadError: 'No se pudieron cargar los usuarios.',
userMgmtLoadFriendly: 'El servidor no respondió — suele ser algo puntual.',
userMgmtRetry: 'Reintentar',
userMgmtEmpty: 'No se encontraron usuarios.', userMgmtEmpty: 'No se encontraron usuarios.',
userMgmtYouBadge: 'Tú', userMgmtYouBadge: 'Tú',
userMgmtAdminBadge: 'Admin', userMgmtAdminBadge: 'Admin',
+2
View File
@@ -471,6 +471,8 @@ export const frTranslation = {
userMgmtDesc: 'Gérer les utilisateurs sur ce serveur. Nécessite des privilèges administrateur.', userMgmtDesc: 'Gérer les utilisateurs sur ce serveur. Nécessite des privilèges administrateur.',
userMgmtNoAdmin: 'Vous devez être administrateur pour gérer les utilisateurs sur ce serveur.', userMgmtNoAdmin: 'Vous devez être administrateur pour gérer les utilisateurs sur ce serveur.',
userMgmtLoadError: 'Impossible de charger les utilisateurs.', userMgmtLoadError: 'Impossible de charger les utilisateurs.',
userMgmtLoadFriendly: 'Le serveur n\'a pas répondu — généralement ponctuel.',
userMgmtRetry: 'Réessayer',
userMgmtEmpty: 'Aucun utilisateur trouvé.', userMgmtEmpty: 'Aucun utilisateur trouvé.',
userMgmtYouBadge: 'Vous', userMgmtYouBadge: 'Vous',
userMgmtAdminBadge: 'Admin', userMgmtAdminBadge: 'Admin',
+2
View File
@@ -471,6 +471,8 @@ export const nbTranslation = {
userMgmtDesc: 'Administrer brukere på denne serveren. Krever admin-rettigheter.', userMgmtDesc: 'Administrer brukere på denne serveren. Krever admin-rettigheter.',
userMgmtNoAdmin: 'Du trenger admin-rettigheter for å administrere brukere på denne serveren.', userMgmtNoAdmin: 'Du trenger admin-rettigheter for å administrere brukere på denne serveren.',
userMgmtLoadError: 'Kunne ikke laste brukere.', userMgmtLoadError: 'Kunne ikke laste brukere.',
userMgmtLoadFriendly: 'Serveren svarte ikke — som regel en engangsfeil.',
userMgmtRetry: 'Prøv igjen',
userMgmtEmpty: 'Ingen brukere funnet.', userMgmtEmpty: 'Ingen brukere funnet.',
userMgmtYouBadge: 'Deg', userMgmtYouBadge: 'Deg',
userMgmtAdminBadge: 'Admin', userMgmtAdminBadge: 'Admin',
+2
View File
@@ -470,6 +470,8 @@ export const nlTranslation = {
userMgmtDesc: 'Beheer gebruikers op deze server. Vereist admin-rechten.', userMgmtDesc: 'Beheer gebruikers op deze server. Vereist admin-rechten.',
userMgmtNoAdmin: 'Je hebt admin-rechten nodig om gebruikers op deze server te beheren.', userMgmtNoAdmin: 'Je hebt admin-rechten nodig om gebruikers op deze server te beheren.',
userMgmtLoadError: 'Kon gebruikers niet laden.', userMgmtLoadError: 'Kon gebruikers niet laden.',
userMgmtLoadFriendly: 'Server antwoordde niet — meestal eenmalig.',
userMgmtRetry: 'Opnieuw proberen',
userMgmtEmpty: 'Geen gebruikers gevonden.', userMgmtEmpty: 'Geen gebruikers gevonden.',
userMgmtYouBadge: 'Jij', userMgmtYouBadge: 'Jij',
userMgmtAdminBadge: 'Admin', userMgmtAdminBadge: 'Admin',
+2
View File
@@ -494,6 +494,8 @@ export const ruTranslation = {
userMgmtDesc: 'Управляйте пользователями этого сервера. Требуются права администратора.', userMgmtDesc: 'Управляйте пользователями этого сервера. Требуются права администратора.',
userMgmtNoAdmin: 'Для управления пользователями на этом сервере нужны права администратора.', userMgmtNoAdmin: 'Для управления пользователями на этом сервере нужны права администратора.',
userMgmtLoadError: 'Не удалось загрузить пользователей.', userMgmtLoadError: 'Не удалось загрузить пользователей.',
userMgmtLoadFriendly: 'Сервер не ответил — обычно разовая ошибка.',
userMgmtRetry: 'Повторить',
userMgmtEmpty: 'Пользователи не найдены.', userMgmtEmpty: 'Пользователи не найдены.',
userMgmtYouBadge: 'Вы', userMgmtYouBadge: 'Вы',
userMgmtAdminBadge: 'Админ', userMgmtAdminBadge: 'Админ',
+2
View File
@@ -466,6 +466,8 @@ export const zhTranslation = {
userMgmtDesc: '管理此服务器上的用户。需要管理员权限。', userMgmtDesc: '管理此服务器上的用户。需要管理员权限。',
userMgmtNoAdmin: '需要管理员权限才能管理此服务器上的用户。', userMgmtNoAdmin: '需要管理员权限才能管理此服务器上的用户。',
userMgmtLoadError: '加载用户失败。', userMgmtLoadError: '加载用户失败。',
userMgmtLoadFriendly: '服务器未响应 — 通常是偶发问题。',
userMgmtRetry: '重试',
userMgmtEmpty: '未找到用户。', userMgmtEmpty: '未找到用户。',
userMgmtYouBadge: '您', userMgmtYouBadge: '您',
userMgmtAdminBadge: '管理员', userMgmtAdminBadge: '管理员',
+146 -18
View File
@@ -762,15 +762,25 @@ function UserManagementSection({
setLoading(true); setLoading(true);
setLoadError(null); setLoadError(null);
try { try {
const [list, libs] = await Promise.all([ // Sequential, not parallel: nginx setups with churning upstream
ndListUsers(serverUrl, token), // keep-alive drop one of the two parallel TLS connections. Doing
ndListLibraries(serverUrl, token).catch(() => [] as NdLibrary[]), // users first then libraries keeps us on one connection at a time
]); // and pairs cleanly with the nd_retry backoff on the Rust side.
const list = await ndListUsers(serverUrl, token);
const libs = await ndListLibraries(serverUrl, token).catch(() => [] as NdLibrary[]);
setUsers([...list].sort((a, b) => a.userName.localeCompare(b.userName))); setUsers([...list].sort((a, b) => a.userName.localeCompare(b.userName)));
setLibraries([...libs].sort((a, b) => a.name.localeCompare(b.name))); setLibraries([...libs].sort((a, b) => a.name.localeCompare(b.name)));
} catch (e) { } catch (e) {
const msg = (e instanceof Error && e.message) ? e.message : t('settings.userMgmtLoadError'); // Tauri invoke rejects with a plain string (our Rust returns Err(String)),
setLoadError(msg); // not an Error instance. Normalise so the surfaced message is the real
// cause (e.g. "tls handshake eof") rather than the generic i18n fallback.
const raw = typeof e === 'string'
? e
: (e instanceof Error && e.message)
? e.message
: '';
const prefix = t('settings.userMgmtLoadError');
setLoadError(raw ? `${prefix} ${raw}` : prefix);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -922,8 +932,30 @@ function UserManagementSection({
)} )}
{!loading && loadError && ( {!loading && loadError && (
<div className="settings-card" style={{ color: 'var(--danger)', fontSize: 13 }}> <div
{loadError} className="settings-card"
style={{
color: 'var(--danger)',
fontSize: 13,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
flexWrap: 'wrap',
}}
>
<div style={{ flex: 1, minWidth: 200 }}>
<div style={{ fontWeight: 600, marginBottom: 4 }}>{t('settings.userMgmtLoadFriendly')}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', wordBreak: 'break-word' }}>{loadError}</div>
</div>
<button
type="button"
className="btn btn-primary"
onClick={() => void load()}
style={{ flexShrink: 0 }}
>
<RotateCcw size={14} /> {t('settings.userMgmtRetry')}
</button>
</div> </div>
)} )}
@@ -1009,16 +1041,14 @@ function UserManagementSection({
{t('settings.userMgmtAdminBadge')} {t('settings.userMgmtAdminBadge')}
</span> </span>
)} )}
{libNames && (
<span style={{ fontSize: 11, color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0, flex: 1 }}> <span style={{ fontSize: 11, color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0, flex: 1 }}>
{libNames} {libNames || ''}
</span> </span>
)}
{!u.isAdmin && ( {!u.isAdmin && (
<button <button
type="button" type="button"
className="btn btn-ghost" className="btn btn-ghost"
style={{ padding: '2px 6px', flexShrink: 0, marginLeft: libNames ? 0 : 'auto' }} style={{ padding: '2px 6px', flexShrink: 0 }}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setMagicRowUser(u); setMagicRowUser(u);
@@ -1031,7 +1061,7 @@ function UserManagementSection({
</button> </button>
)} )}
<span <span
style={{ fontSize: 11, color: 'var(--text-muted)', flexShrink: 0, marginLeft: libNames ? 0 : 'auto' }} style={{ fontSize: 11, color: 'var(--text-muted)', flexShrink: 0 }}
data-tooltip={lastSeenAbsolute || undefined} data-tooltip={lastSeenAbsolute || undefined}
> >
{lastSeen} {lastSeen}
@@ -1347,6 +1377,14 @@ export default function Settings() {
const [searchOpen, setSearchOpen] = useState(false); const [searchOpen, setSearchOpen] = useState(false);
// -1 bedeutet: keine aktive Suche; >= 0 ist die Trefferzahl im aktuellen Tab. // -1 bedeutet: keine aktive Suche; >= 0 ist die Trefferzahl im aktuellen Tab.
const [searchHits, setSearchHits] = useState<number>(-1); const [searchHits, setSearchHits] = useState<number>(-1);
// Server-Liste DnD
const psyDragState = useDragDrop();
const [serverContainerEl, setServerContainerEl] = useState<HTMLDivElement | null>(null);
const [serverDropTarget, setServerDropTarget] = useState<ServerDropTarget>(null);
const serverDropTargetRef = useRef<ServerDropTarget>(null);
const serversRef = useRef(auth.servers);
serversRef.current = auth.servers;
const [connStatus, setConnStatus] = useState<Record<string, 'idle' | 'testing' | 'ok' | 'error'>>({}); const [connStatus, setConnStatus] = useState<Record<string, 'idle' | 'testing' | 'ok' | 'error'>>({});
const [showAddForm, setShowAddForm] = useState(false); const [showAddForm, setShowAddForm] = useState(false);
const [newGenre, setNewGenre] = useState(''); const [newGenre, setNewGenre] = useState('');
@@ -1585,12 +1623,62 @@ export default function Settings() {
} }
}; };
// Clear drop target when drag ends
useEffect(() => {
if (!psyDragState.isDragging) {
serverDropTargetRef.current = null;
setServerDropTarget(null);
}
}, [psyDragState.isDragging]);
// psy-drop listener for server reorder
useEffect(() => {
if (!serverContainerEl) return;
const onPsyDrop = (e: Event) => {
const detail = (e as CustomEvent).detail;
if (!detail?.data) return;
let parsed: { type?: string; index?: number };
try { parsed = JSON.parse(detail.data as string); } catch { return; }
if (parsed.type !== 'server_reorder' || parsed.index == null) return;
const fromIdx = parsed.index;
const target = serverDropTargetRef.current;
serverDropTargetRef.current = null; setServerDropTarget(null);
if (!target) return;
const insertBefore = target.before ? target.idx : target.idx + 1;
if (insertBefore === fromIdx || insertBefore === fromIdx + 1) return;
const next = [...serversRef.current];
const [moved] = next.splice(fromIdx, 1);
next.splice(insertBefore > fromIdx ? insertBefore - 1 : insertBefore, 0, moved);
auth.setServers(next);
};
serverContainerEl.addEventListener('psy-drop', onPsyDrop);
return () => serverContainerEl.removeEventListener('psy-drop', onPsyDrop);
}, [serverContainerEl, auth]);
const handleServerDragMove = (e: React.MouseEvent) => {
if (!psyDragState.isDragging || !serverContainerEl) return;
const rows = serverContainerEl.querySelectorAll<HTMLElement>('[data-server-idx]');
let target: ServerDropTarget = null;
for (const row of rows) {
const rect = row.getBoundingClientRect();
const idx = Number(row.dataset.serverIdx);
if (e.clientY < rect.top + rect.height / 2) { target = { idx, before: true }; break; }
target = { idx, before: false };
}
serverDropTargetRef.current = target;
setServerDropTarget(target);
};
const switchToServer = async (server: ServerProfile) => { const switchToServer = async (server: ServerProfile) => {
setConnStatus(s => ({ ...s, [server.id]: 'testing' })); setConnStatus(s => ({ ...s, [server.id]: 'testing' }));
const ok = await switchActiveServer(server); const ok = await switchActiveServer(server);
if (ok) { if (ok) {
setConnStatus(s => ({ ...s, [server.id]: 'ok' })); setConnStatus(s => ({ ...s, [server.id]: 'ok' }));
navigate('/'); // Auf der Servers-Seite bleiben, damit der User seinen Switch hier
// sofort visuell bestaetigt sieht (gruener Check, aktiv-Badge).
} else { } else {
setConnStatus(s => ({ ...s, [server.id]: 'error' })); setConnStatus(s => ({ ...s, [server.id]: 'error' }));
} }
@@ -3367,13 +3455,31 @@ export default function Settings() {
{t('settings.noServers')} {t('settings.noServers')}
</div> </div>
) : ( ) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}> <div
{auth.servers.map(srv => { ref={setServerContainerEl}
onMouseMove={handleServerDragMove}
style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}
>
{auth.servers.map((srv, srvIdx) => {
const isActive = srv.id === auth.activeServerId; const isActive = srv.id === auth.activeServerId;
const status = connStatus[srv.id]; const status = connStatus[srv.id];
const isBefore = psyDragState.isDragging && serverDropTarget?.idx === srvIdx && serverDropTarget.before;
const isAfter = psyDragState.isDragging && serverDropTarget?.idx === srvIdx && !serverDropTarget.before;
return ( return (
<div key={srv.id} className="settings-card" style={{ border: isActive ? '1px solid var(--accent)' : undefined }}> <div
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '1rem' }}> key={srv.id}
data-server-idx={srvIdx}
className="settings-card"
style={{
border: isActive ? '1px solid var(--accent)' : undefined,
background: isActive ? 'color-mix(in srgb, var(--accent) 10%, var(--bg-card))' : undefined,
borderTop: isBefore ? '2px solid var(--accent)' : undefined,
borderBottom: isAfter ? '2px solid var(--accent)' : undefined,
}}
>
<div style={{ display: 'flex', alignItems: 'stretch', gap: '0.75rem' }}>
<ServerGripHandle idx={srvIdx} label={serverListDisplayLabel(srv, auth.servers)} />
<div style={{ flex: 1, display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '1rem' }}>
<div style={{ minWidth: 0 }}> <div style={{ minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '2px' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '2px' }}>
<span style={{ fontWeight: 600 }}>{serverListDisplayLabel(srv, auth.servers)}</span> <span style={{ fontWeight: 600 }}>{serverListDisplayLabel(srv, auth.servers)}</span>
@@ -3431,6 +3537,7 @@ export default function Settings() {
</button> </button>
</div> </div>
</div> </div>
</div>
{showAudiomuseNavidromeServerSetting( {showAudiomuseNavidromeServerSetting(
auth.subsonicServerIdentityByServer[srv.id], auth.subsonicServerIdentityByServer[srv.id],
auth.instantMixProbeByServer[srv.id], auth.instantMixProbeByServer[srv.id],
@@ -3832,6 +3939,27 @@ const LYRICS_SOURCE_LABEL_KEYS: Record<LyricsSourceId, string> = {
type LyricsDropTarget = { idx: number; before: boolean } | null; type LyricsDropTarget = { idx: number; before: boolean } | null;
type ServerDropTarget = { idx: number; before: boolean } | null;
function ServerGripHandle({ idx, label }: { idx: number; label: string }) {
const { t } = useTranslation();
const { onMouseDown } = useDragSource(() => ({
data: JSON.stringify({ type: 'server_reorder', index: idx }),
label,
}));
return (
<span
className="sidebar-customizer-grip"
data-tooltip={t('settings.sidebarDrag')}
data-tooltip-pos="right"
onMouseDown={onMouseDown}
onClick={e => e.stopPropagation()}
>
<GripVertical size={16} />
</span>
);
}
function LyricsSourceGripHandle({ idx, label }: { idx: number; label: string }) { function LyricsSourceGripHandle({ idx, label }: { idx: number; label: string }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { onMouseDown } = useDragSource(() => ({ const { onMouseDown } = useDragSource(() => ({
+3
View File
@@ -187,6 +187,7 @@ interface AuthState {
addServer: (profile: Omit<ServerProfile, 'id'>) => string; addServer: (profile: Omit<ServerProfile, 'id'>) => string;
updateServer: (id: string, data: Partial<Omit<ServerProfile, 'id'>>) => void; updateServer: (id: string, data: Partial<Omit<ServerProfile, 'id'>>) => void;
removeServer: (id: string) => void; removeServer: (id: string) => void;
setServers: (servers: ServerProfile[]) => void;
setActiveServer: (id: string) => void; setActiveServer: (id: string) => void;
setLoggedIn: (v: boolean) => void; setLoggedIn: (v: boolean) => void;
setConnecting: (v: boolean) => void; setConnecting: (v: boolean) => void;
@@ -408,6 +409,8 @@ export const useAuthStore = create<AuthState>()(
}); });
}, },
setServers: (servers) => set({ servers }),
setActiveServer: (id) => set({ activeServerId: id, musicFolders: [] }), setActiveServer: (id) => set({ activeServerId: id, musicFolders: [] }),
setLoggedIn: (v) => set({ isLoggedIn: v }), setLoggedIn: (v) => set({ isLoggedIn: v }),