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 { 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,125 +121,116 @@ 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}
|
||||||
<div className="subhead">Qobuz — email / password</div>
|
|
||||||
<div className="field-grid">
|
|
||||||
<label className="field">
|
|
||||||
<span>Qobuz email</span>
|
|
||||||
<input aria-label="qobuz email" value={qobuzEmail} onChange={(e) => setQobuzEmail(e.target.value)} />
|
|
||||||
</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)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="subhead">Qobuz — auth token (more reliable; overrides email/password)</div>
|
|
||||||
<div className="field-grid">
|
|
||||||
<label className="field">
|
|
||||||
<span>Qobuz user ID</span>
|
|
||||||
<input aria-label="qobuz user id" value={qobuzUserId} onChange={(e) => setQobuzUserId(e.target.value)} />
|
|
||||||
</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)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="subhead">Soulseek (slskd)</div>
|
|
||||||
<div className="field-grid">
|
|
||||||
<label className="field">
|
|
||||||
<span>slskd URL</span>
|
|
||||||
<input aria-label="slskd url" value={slskdUrl} onChange={(e) => setSlskdUrl(e.target.value)} />
|
|
||||||
</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)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="save-row">
|
|
||||||
<button type="submit" className="btn accent">
|
|
||||||
Save
|
|
||||||
</button>
|
</button>
|
||||||
{saved ? <span className="saved-note">Saved</span> : null}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
|
||||||
|
|
||||||
{/* ── Library ─────────────────────────────── */}
|
{tab === "Qobuz" ? (
|
||||||
<section className="settings-section">
|
<form className="settings-section" onSubmit={saveCredentials}>
|
||||||
<SectionHeader title="Library" />
|
<div className="subhead">Email / password</div>
|
||||||
<div className="info-panel">
|
<div className="field-grid">
|
||||||
<div>
|
<label className="field">
|
||||||
<span className="k">Music path</span> <code>/music</code>
|
<span>Qobuz email</span>
|
||||||
</div>
|
<input aria-label="qobuz email" value={qobuzEmail} onChange={(e) => setQobuzEmail(e.target.value)} />
|
||||||
<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.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="muted-note">Scan the library to record what’s already on disk as owned + monitored.</p>
|
|
||||||
<div className="save-row">
|
|
||||||
<button type="button" className="btn" onClick={requestScan} disabled={scanRequested}>
|
|
||||||
{scanRequested ? "Scanning…" : "Scan library"}
|
|
||||||
</button>
|
|
||||||
{scanResult ? (
|
|
||||||
<span className="saved-note" aria-label="scan result" style={{ color: "var(--graphite)" }}>
|
|
||||||
Last scan: {scanResult}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* ── Discovery ───────────────────────────── */}
|
|
||||||
<form className="settings-section" onSubmit={saveDiscovery}>
|
|
||||||
<SectionHeader title="Discovery" />
|
|
||||||
<div className="save-row" style={{ margin: "14px 0 4px" }}>
|
|
||||||
<label className="toggle">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={discoverCfg["discover.enabled"] === "true"}
|
|
||||||
onChange={(e) => setCfg("discover.enabled", e.target.checked ? "true" : "false")}
|
|
||||||
/>
|
|
||||||
Scheduled discovery sweep
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="field-grid">
|
|
||||||
{DISCOVERY_FIELDS.map(([key, label]) => (
|
|
||||||
<label key={key} className="field">
|
|
||||||
<span>{label}</span>
|
|
||||||
<input type="number" step="any" value={discoverCfg[key] ?? ""} onChange={(e) => setCfg(key, e.target.value)} />
|
|
||||||
</label>
|
</label>
|
||||||
))}
|
<label className="field">
|
||||||
</div>
|
<span>Qobuz password{secretHint(pwSet)}</span>
|
||||||
<div className="save-row">
|
<input aria-label="qobuz password" type="password" placeholder={pwSet ? "•••• stored" : ""} value={qobuzPassword} onChange={(e) => setQobuzPassword(e.target.value)} />
|
||||||
<button type="submit" className="btn">
|
</label>
|
||||||
Save discovery settings
|
</div>
|
||||||
</button>
|
<div className="subhead">Auth token (more reliable; overrides email/password)</div>
|
||||||
{discoverSaved ? <span className="saved-note">Saved</span> : null}
|
<div className="field-grid">
|
||||||
</div>
|
<label className="field">
|
||||||
</form>
|
<span>Qobuz user ID</span>
|
||||||
|
<input aria-label="qobuz user id" value={qobuzUserId} onChange={(e) => setQobuzUserId(e.target.value)} />
|
||||||
|
</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)} />
|
||||||
|
</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">
|
||||||
|
<span>slskd URL</span>
|
||||||
|
<input aria-label="slskd url" value={slskdUrl} onChange={(e) => setSlskdUrl(e.target.value)} />
|
||||||
|
</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)} />
|
||||||
|
</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 === "Library" ? (
|
||||||
|
<section className="settings-section">
|
||||||
|
<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.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="muted-note">Scan the library to record what’s already on disk as owned + monitored.</p>
|
||||||
|
<div className="save-row">
|
||||||
|
<button type="button" className="btn" onClick={requestScan} disabled={scanRequested}>
|
||||||
|
{scanRequested ? "Scanning…" : "Scan library"}
|
||||||
|
</button>
|
||||||
|
{scanResult ? (
|
||||||
|
<span className="saved-note" aria-label="scan result" style={{ color: "var(--graphite)" }}>
|
||||||
|
Last scan: {scanResult}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{tab === "Discovery" ? (
|
||||||
|
<form className="settings-section" onSubmit={saveDiscovery}>
|
||||||
|
<div className="save-row" style={{ margin: "6px 0 4px" }}>
|
||||||
|
<label className="toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={discoverCfg["discover.enabled"] === "true"}
|
||||||
|
onChange={(e) => setCfg("discover.enabled", e.target.checked ? "true" : "false")}
|
||||||
|
/>
|
||||||
|
Scheduled discovery sweep
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="field-grid">
|
||||||
|
{DISCOVERY_FIELDS.map(([key, label]) => (
|
||||||
|
<label key={key} className="field">
|
||||||
|
<span>{label}</span>
|
||||||
|
<input type="number" step="any" value={discoverCfg[key] ?? ""} onChange={(e) => setCfg(key, e.target.value)} />
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="save-row">
|
||||||
|
<button type="submit" className="btn">Save discovery settings</button>
|
||||||
|
{discoverSaved ? <span className="saved-note">Saved</span> : null}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user