feat(web): restyle Settings + read-only library path panel (Phase 3)

Settings onto the Pressing Plant system: credentials with clear set/unset
affordances, a Library section that shows the configured /music path with a
"change MUSIC_DIR in docker-compose + rebuild" note (honest about the mount
being non-remountable from the UI), restyled scan trigger/result, and the
discovery section. All logic and aria-labels preserved. Completes the
frontend redesign (all pages + shell on one system, both themes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan
2026-07-12 14:34:46 +02:00
parent ead5f33bcc
commit 50949f747b
3 changed files with 152 additions and 64 deletions
+131 -57
View File
@@ -1,6 +1,8 @@
"use client";
import { useEffect, useState } from "react";
import { PageHead } from "../_ui/page-head";
import { SectionHeader } from "../_ui/section-header";
type Loaded = {
qobuzEmail: string;
@@ -11,6 +13,14 @@ type Loaded = {
slskdApiKeySet: boolean;
};
const DISCOVERY_FIELDS: [string, string][] = [
["discover.intervalHours", "Interval (hours)"],
["discover.maxSeeds", "Max seeds per sweep"],
["discover.similarPerSeed", "Similar per seed"],
["discover.albumsPerArtist", "Albums per artist"],
["discover.minScore", "Min score"],
];
export function SettingsForm() {
const [qobuzEmail, setQobuzEmail] = useState("");
const [qobuzPassword, setQobuzPassword] = useState("");
@@ -101,67 +111,131 @@ export function SettingsForm() {
setTimeout(() => setDiscoverSaved(false), 1500);
}
const secretHint = (set: boolean) => (set ? <span className="set"> · set</span> : null);
return (
<>
<form onSubmit={submit}>
<fieldset>
<legend>Qobuz email/password</legend>
<label>Qobuz email <input aria-label="qobuz email" value={qobuzEmail} onChange={(e) => setQobuzEmail(e.target.value)} /></label>
<label>Qobuz password <input aria-label="qobuz password" type="password" placeholder={pwSet ? "•••• (set)" : ""} value={qobuzPassword} onChange={(e) => setQobuzPassword(e.target.value)} /></label>
</fieldset>
<fieldset>
<legend>Qobuz auth token (more reliable; overrides email/password)</legend>
<label>Qobuz user ID <input aria-label="qobuz user id" value={qobuzUserId} onChange={(e) => setQobuzUserId(e.target.value)} /></label>
<label>Qobuz auth token <input aria-label="qobuz auth token" type="password" placeholder={tokenSet ? "•••• (set)" : ""} value={qobuzToken} onChange={(e) => setQobuzToken(e.target.value)} /></label>
</fieldset>
<fieldset>
<legend>Soulseek (slskd)</legend>
<label>slskd URL <input aria-label="slskd url" value={slskdUrl} onChange={(e) => setSlskdUrl(e.target.value)} /></label>
<label>slskd API key <input aria-label="slskd api key" type="password" placeholder={keySet ? "•••• (set)" : ""} value={slskdApiKey} onChange={(e) => setSlskdApiKey(e.target.value)} /></label>
</fieldset>
<button type="submit">Save</button>
{saved ? <span> Saved.</span> : null}
<div>
<PageHead title="Settings" eyebrow="Credentials · library · discovery" />
{/* ── Credentials ─────────────────────────── */}
<form className="settings-section" onSubmit={submit}>
<SectionHeader title="Credentials" />
<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>
{saved ? <span className="saved-note">Saved</span> : null}
</div>
</form>
<section>
<h2>Library</h2>
<p>Scan <code>/music</code> and record what's already on disk as owned + monitored.</p>
<button type="button" onClick={requestScan} disabled={scanRequested}>
{scanRequested ? "Scan requested…" : "Scan library"}
</button>
{scanResult ? <p aria-label="scan result">Last scan: {scanResult}</p> : null}
{/* ── Library ─────────────────────────────── */}
<section className="settings-section">
<SectionHeader title="Library" />
<div className="info-panel">
<div>
<span className="k">Music path</span> &nbsp; <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 cant be remounted from here.
</div>
</div>
<p className="muted-note">Scan the library to record whats 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>
<form onSubmit={saveDiscovery}>
<h2>Discovery</h2>
<label>
<input
type="checkbox"
checked={discoverCfg["discover.enabled"] === "true"}
onChange={(e) => setCfg("discover.enabled", e.target.checked ? "true" : "false")}
/>{" "}
Enable scheduled discovery sweep
</label>
{[
["discover.intervalHours", "Interval (hours)"],
["discover.maxSeeds", "Max seeds per sweep"],
["discover.similarPerSeed", "Similar per seed"],
["discover.albumsPerArtist", "Albums per artist"],
["discover.minScore", "Min score"],
].map(([key, label]) => (
<p key={key}>
<label>
{label}:{" "}
<input
type="number"
step="any"
value={discoverCfg[key] ?? ""}
onChange={(e) => setCfg(key, e.target.value)}
/>
{/* ── 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>
</p>
))}
<button type="submit">Save discovery settings</button>
{discoverSaved && <span> Saved.</span>}
))}
</div>
<div className="save-row">
<button type="submit" className="btn">
Save discovery settings
</button>
{discoverSaved ? <span className="saved-note">Saved</span> : null}
</div>
</form>
</>
</div>
);
}