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:
@@ -222,3 +222,23 @@ nav.contents .sep { flex: 1; }
|
|||||||
}
|
}
|
||||||
.tracks li .tnum { color: var(--rule-2); min-width: 2em; }
|
.tracks li .tnum { color: var(--rule-2); min-width: 2em; }
|
||||||
.tracks li .tname { color: var(--ink); }
|
.tracks li .tname { color: var(--ink); }
|
||||||
|
|
||||||
|
/* ── Phase 3: settings ────────────────────────────────── */
|
||||||
|
.field-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 18px 24px; margin: 16px 0 8px; }
|
||||||
|
.field.wide { grid-column: 1 / -1; }
|
||||||
|
.subhead {
|
||||||
|
font-family: var(--mono); font-size: 0.66rem; letter-spacing: 0.16em; text-transform: uppercase;
|
||||||
|
color: var(--graphite); margin: 26px 0 2px;
|
||||||
|
}
|
||||||
|
.field .set { color: var(--accent); }
|
||||||
|
.info-panel {
|
||||||
|
background: var(--paper-2); border: 1px solid var(--rule); padding: 15px 18px; margin: 14px 0 16px;
|
||||||
|
font-family: var(--mono); font-size: 0.78rem; line-height: 1.8; color: var(--ink);
|
||||||
|
}
|
||||||
|
.info-panel .k { color: var(--graphite); text-transform: uppercase; letter-spacing: 0.12em; font-size: 0.6rem; }
|
||||||
|
.info-panel code { color: var(--accent); }
|
||||||
|
.saved-note {
|
||||||
|
font-family: var(--mono); font-size: 0.64rem; letter-spacing: 0.12em; text-transform: uppercase; color: var(--accent);
|
||||||
|
}
|
||||||
|
.save-row { display: flex; align-items: center; gap: 14px; margin-top: 8px; }
|
||||||
|
.settings-section { margin-bottom: 30px; }
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
import { SettingsForm } from "./settings-form";
|
import { SettingsForm } from "./settings-form";
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
return (
|
return <SettingsForm />;
|
||||||
<main>
|
|
||||||
<h1>Settings</h1>
|
|
||||||
<SettingsForm />
|
|
||||||
<p><a href="/">← Back to queue</a></p>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { PageHead } from "../_ui/page-head";
|
||||||
|
import { SectionHeader } from "../_ui/section-header";
|
||||||
|
|
||||||
type Loaded = {
|
type Loaded = {
|
||||||
qobuzEmail: string;
|
qobuzEmail: string;
|
||||||
@@ -11,6 +13,14 @@ type Loaded = {
|
|||||||
slskdApiKeySet: boolean;
|
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() {
|
export function SettingsForm() {
|
||||||
const [qobuzEmail, setQobuzEmail] = useState("");
|
const [qobuzEmail, setQobuzEmail] = useState("");
|
||||||
const [qobuzPassword, setQobuzPassword] = useState("");
|
const [qobuzPassword, setQobuzPassword] = useState("");
|
||||||
@@ -101,67 +111,131 @@ export function SettingsForm() {
|
|||||||
setTimeout(() => setDiscoverSaved(false), 1500);
|
setTimeout(() => setDiscoverSaved(false), 1500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const secretHint = (set: boolean) => (set ? <span className="set"> · set</span> : null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div>
|
||||||
<form onSubmit={submit}>
|
<PageHead title="Settings" eyebrow="Credentials · library · discovery" />
|
||||||
<fieldset>
|
|
||||||
<legend>Qobuz — email/password</legend>
|
{/* ── Credentials ─────────────────────────── */}
|
||||||
<label>Qobuz email <input aria-label="qobuz email" value={qobuzEmail} onChange={(e) => setQobuzEmail(e.target.value)} /></label>
|
<form className="settings-section" onSubmit={submit}>
|
||||||
<label>Qobuz password <input aria-label="qobuz password" type="password" placeholder={pwSet ? "•••• (set)" : ""} value={qobuzPassword} onChange={(e) => setQobuzPassword(e.target.value)} /></label>
|
<SectionHeader title="Credentials" />
|
||||||
</fieldset>
|
|
||||||
<fieldset>
|
<div className="subhead">Qobuz — email / password</div>
|
||||||
<legend>Qobuz — auth token (more reliable; overrides email/password)</legend>
|
<div className="field-grid">
|
||||||
<label>Qobuz user ID <input aria-label="qobuz user id" value={qobuzUserId} onChange={(e) => setQobuzUserId(e.target.value)} /></label>
|
<label className="field">
|
||||||
<label>Qobuz auth token <input aria-label="qobuz auth token" type="password" placeholder={tokenSet ? "•••• (set)" : ""} value={qobuzToken} onChange={(e) => setQobuzToken(e.target.value)} /></label>
|
<span>Qobuz email</span>
|
||||||
</fieldset>
|
<input aria-label="qobuz email" value={qobuzEmail} onChange={(e) => setQobuzEmail(e.target.value)} />
|
||||||
<fieldset>
|
</label>
|
||||||
<legend>Soulseek (slskd)</legend>
|
<label className="field">
|
||||||
<label>slskd URL <input aria-label="slskd url" value={slskdUrl} onChange={(e) => setSlskdUrl(e.target.value)} /></label>
|
<span>Qobuz password{secretHint(pwSet)}</span>
|
||||||
<label>slskd API key <input aria-label="slskd api key" type="password" placeholder={keySet ? "•••• (set)" : ""} value={slskdApiKey} onChange={(e) => setSlskdApiKey(e.target.value)} /></label>
|
<input
|
||||||
</fieldset>
|
aria-label="qobuz password"
|
||||||
<button type="submit">Save</button>
|
type="password"
|
||||||
{saved ? <span> Saved.</span> : null}
|
placeholder={pwSet ? "•••• stored" : ""}
|
||||||
</form>
|
value={qobuzPassword}
|
||||||
<section>
|
onChange={(e) => setQobuzPassword(e.target.value)}
|
||||||
<h2>Library</h2>
|
/>
|
||||||
<p>Scan <code>/music</code> and record what's already on disk as owned + monitored.</p>
|
</label>
|
||||||
<button type="button" onClick={requestScan} disabled={scanRequested}>
|
</div>
|
||||||
{scanRequested ? "Scan requested…" : "Scan library"}
|
|
||||||
|
<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>
|
||||||
{scanResult ? <p aria-label="scan result">Last scan: {scanResult}</p> : null}
|
{saved ? <span className="saved-note">Saved</span> : null}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* ── 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.
|
||||||
|
</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>
|
</section>
|
||||||
<form onSubmit={saveDiscovery}>
|
|
||||||
<h2>Discovery</h2>
|
{/* ── Discovery ───────────────────────────── */}
|
||||||
<label>
|
<form className="settings-section" onSubmit={saveDiscovery}>
|
||||||
|
<SectionHeader title="Discovery" />
|
||||||
|
<div className="save-row" style={{ margin: "14px 0 4px" }}>
|
||||||
|
<label className="toggle">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={discoverCfg["discover.enabled"] === "true"}
|
checked={discoverCfg["discover.enabled"] === "true"}
|
||||||
onChange={(e) => setCfg("discover.enabled", e.target.checked ? "true" : "false")}
|
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)}
|
|
||||||
/>
|
/>
|
||||||
|
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>
|
||||||
</p>
|
|
||||||
))}
|
))}
|
||||||
<button type="submit">Save discovery settings</button>
|
</div>
|
||||||
{discoverSaved && <span> Saved.</span>}
|
<div className="save-row">
|
||||||
|
<button type="submit" className="btn">
|
||||||
|
Save discovery settings
|
||||||
|
</button>
|
||||||
|
{discoverSaved ? <span className="saved-note">Saved</span> : null}
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user