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