feat(web): Settings tabs per service (Qobuz/Soulseek/Library/Discovery)

Split the long Settings page into tabs so each service stands alone, instead
of one long mixed scroll. Same handlers/aria-labels; credential save still
PUTs the full config.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan
2026-07-12 15:33:20 +02:00
parent 1fbe58013f
commit 76eab9e703
+39 -44
View File
@@ -2,7 +2,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { PageHead } from "../_ui/page-head"; import { PageHead } from "../_ui/page-head";
import { SectionHeader } from "../_ui/section-header";
type Loaded = { type Loaded = {
qobuzEmail: string; qobuzEmail: string;
@@ -13,6 +12,9 @@ type Loaded = {
slskdApiKeySet: boolean; slskdApiKeySet: boolean;
}; };
type Tab = "Qobuz" | "Soulseek" | "Library" | "Discovery";
const TABS: Tab[] = ["Qobuz", "Soulseek", "Library", "Discovery"];
const DISCOVERY_FIELDS: [string, string][] = [ const DISCOVERY_FIELDS: [string, string][] = [
["discover.intervalHours", "Interval (hours)"], ["discover.intervalHours", "Interval (hours)"],
["discover.maxSeeds", "Max seeds per sweep"], ["discover.maxSeeds", "Max seeds per sweep"],
@@ -22,6 +24,7 @@ const DISCOVERY_FIELDS: [string, string][] = [
]; ];
export function SettingsForm() { export function SettingsForm() {
const [tab, setTab] = useState<Tab>("Qobuz");
const [qobuzEmail, setQobuzEmail] = useState(""); const [qobuzEmail, setQobuzEmail] = useState("");
const [qobuzPassword, setQobuzPassword] = useState(""); const [qobuzPassword, setQobuzPassword] = useState("");
const [qobuzUserId, setQobuzUserId] = useState(""); const [qobuzUserId, setQobuzUserId] = useState("");
@@ -56,7 +59,7 @@ export function SettingsForm() {
const id = setInterval(async () => { const id = setInterval(async () => {
const s = await (await fetch("/api/scan")).json(); const s = await (await fetch("/api/scan")).json();
setScanResult(s.result); setScanResult(s.result);
if (!s.requested) setScanRequested(false); // worker finished the scan if (!s.requested) setScanRequested(false);
}, 2000); }, 2000);
return () => clearInterval(id); return () => clearInterval(id);
}, [scanRequested]); }, [scanRequested]);
@@ -80,7 +83,7 @@ export function SettingsForm() {
.then((c: Record<string, string>) => setDiscoverCfg(c)); .then((c: Record<string, string>) => setDiscoverCfg(c));
}, []); }, []);
async function submit(e: React.FormEvent) { async function saveCredentials(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
await fetch("/api/config", { await fetch("/api/config", {
method: "PUT", method: "PUT",
@@ -91,6 +94,7 @@ export function SettingsForm() {
setQobuzToken(""); setQobuzToken("");
setSlskdApiKey(""); setSlskdApiKey("");
setSaved(true); setSaved(true);
setTimeout(() => setSaved(false), 1500);
setPwSet(pwSet || qobuzPassword !== ""); setPwSet(pwSet || qobuzPassword !== "");
setTokenSet(tokenSet || qobuzToken !== ""); setTokenSet(tokenSet || qobuzToken !== "");
setKeySet(keySet || slskdApiKey !== ""); setKeySet(keySet || slskdApiKey !== "");
@@ -117,11 +121,17 @@ export function SettingsForm() {
<div> <div>
<PageHead title="Settings" eyebrow="Credentials · library · discovery" /> <PageHead title="Settings" eyebrow="Credentials · library · discovery" />
{/* ── Credentials ─────────────────────────── */} <div className="tabs">
<form className="settings-section" onSubmit={submit}> {TABS.map((t) => (
<SectionHeader title="Credentials" /> <button key={t} className={`tab${tab === t ? " active" : ""}`} onClick={() => setTab(t)}>
{t}
</button>
))}
</div>
<div className="subhead">Qobuz email / password</div> {tab === "Qobuz" ? (
<form className="settings-section" onSubmit={saveCredentials}>
<div className="subhead">Email / password</div>
<div className="field-grid"> <div className="field-grid">
<label className="field"> <label className="field">
<span>Qobuz email</span> <span>Qobuz email</span>
@@ -129,17 +139,10 @@ export function SettingsForm() {
</label> </label>
<label className="field"> <label className="field">
<span>Qobuz password{secretHint(pwSet)}</span> <span>Qobuz password{secretHint(pwSet)}</span>
<input <input aria-label="qobuz password" type="password" placeholder={pwSet ? "•••• stored" : ""} value={qobuzPassword} onChange={(e) => setQobuzPassword(e.target.value)} />
aria-label="qobuz password"
type="password"
placeholder={pwSet ? "•••• stored" : ""}
value={qobuzPassword}
onChange={(e) => setQobuzPassword(e.target.value)}
/>
</label> </label>
</div> </div>
<div className="subhead">Auth token (more reliable; overrides email/password)</div>
<div className="subhead">Qobuz auth token (more reliable; overrides email/password)</div>
<div className="field-grid"> <div className="field-grid">
<label className="field"> <label className="field">
<span>Qobuz user ID</span> <span>Qobuz user ID</span>
@@ -147,16 +150,18 @@ export function SettingsForm() {
</label> </label>
<label className="field"> <label className="field">
<span>Qobuz auth token{secretHint(tokenSet)}</span> <span>Qobuz auth token{secretHint(tokenSet)}</span>
<input <input aria-label="qobuz auth token" type="password" placeholder={tokenSet ? "•••• stored" : ""} value={qobuzToken} onChange={(e) => setQobuzToken(e.target.value)} />
aria-label="qobuz auth token"
type="password"
placeholder={tokenSet ? "•••• stored" : ""}
value={qobuzToken}
onChange={(e) => setQobuzToken(e.target.value)}
/>
</label> </label>
</div> </div>
<div className="save-row">
<button type="submit" className="btn accent">Save</button>
{saved ? <span className="saved-note">Saved</span> : null}
</div>
</form>
) : null}
{tab === "Soulseek" ? (
<form className="settings-section" onSubmit={saveCredentials}>
<div className="subhead">Soulseek (slskd)</div> <div className="subhead">Soulseek (slskd)</div>
<div className="field-grid"> <div className="field-grid">
<label className="field"> <label className="field">
@@ -165,34 +170,25 @@ export function SettingsForm() {
</label> </label>
<label className="field"> <label className="field">
<span>slskd API key{secretHint(keySet)}</span> <span>slskd API key{secretHint(keySet)}</span>
<input <input aria-label="slskd api key" type="password" placeholder={keySet ? "•••• stored" : ""} value={slskdApiKey} onChange={(e) => setSlskdApiKey(e.target.value)} />
aria-label="slskd api key"
type="password"
placeholder={keySet ? "•••• stored" : ""}
value={slskdApiKey}
onChange={(e) => setSlskdApiKey(e.target.value)}
/>
</label> </label>
</div> </div>
<div className="save-row"> <div className="save-row">
<button type="submit" className="btn accent"> <button type="submit" className="btn accent">Save</button>
Save
</button>
{saved ? <span className="saved-note">Saved</span> : null} {saved ? <span className="saved-note">Saved</span> : null}
</div> </div>
</form> </form>
) : null}
{/* ── Library ─────────────────────────────── */} {tab === "Library" ? (
<section className="settings-section"> <section className="settings-section">
<SectionHeader title="Library" />
<div className="info-panel"> <div className="info-panel">
<div> <div>
<span className="k">Music path</span> &nbsp; <code>/music</code> <span className="k">Music path</span> &nbsp; <code>/music</code>
</div> </div>
<div className="muted-note" style={{ fontFamily: "inherit" }}> <div className="muted-note" style={{ fontFamily: "inherit" }}>
Mounted read/write into the worker. To point Lyra at a different folder, set{" "} Mounted read/write into the worker. To point Lyra at a different folder, set <code>MUSIC_DIR</code> in{" "}
<code>MUSIC_DIR</code> in <code>docker-compose.yml</code> and rebuild it cant be remounted from here. <code>docker-compose.yml</code> and rebuild it cant be remounted from here.
</div> </div>
</div> </div>
<p className="muted-note">Scan the library to record whats already on disk as owned + monitored.</p> <p className="muted-note">Scan the library to record whats already on disk as owned + monitored.</p>
@@ -207,11 +203,11 @@ export function SettingsForm() {
) : null} ) : null}
</div> </div>
</section> </section>
) : null}
{/* ── Discovery ───────────────────────────── */} {tab === "Discovery" ? (
<form className="settings-section" onSubmit={saveDiscovery}> <form className="settings-section" onSubmit={saveDiscovery}>
<SectionHeader title="Discovery" /> <div className="save-row" style={{ margin: "6px 0 4px" }}>
<div className="save-row" style={{ margin: "14px 0 4px" }}>
<label className="toggle"> <label className="toggle">
<input <input
type="checkbox" type="checkbox"
@@ -230,12 +226,11 @@ export function SettingsForm() {
))} ))}
</div> </div>
<div className="save-row"> <div className="save-row">
<button type="submit" className="btn"> <button type="submit" className="btn">Save discovery settings</button>
Save discovery settings
</button>
{discoverSaved ? <span className="saved-note">Saved</span> : null} {discoverSaved ? <span className="saved-note">Saved</span> : null}
</div> </div>
</form> </form>
) : null}
</div> </div>
); );
} }