67f374c470
Avoid re-triggering a USER_BLOCKED account by capping Qobuz to a human-looking volume and cadence. A per-album gate (worker/lyra_worker/qobuz_gate.py) allows Qobuz only within active hours, under today's cap (a warm-up ramp of a steady cap), and past a randomized spacing since the last Qobuz download; the gap spreads the day's budget across active hours with jitter. When the gate is closed the pipeline drops Qobuz candidates so the job falls through to another source (and monitored albums upgrade to Qobuz later); a Qobuz-only setup is never gated. Config keys read live each loop; counters reset at the day boundary via the DB clock. Tunable in Settings -> Qobuz -> Pacing (new /api/qobuz/pacing route). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
515 lines
22 KiB
TypeScript
515 lines
22 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState } from "react";
|
||
import { PageHead } from "../_ui/page-head";
|
||
import { HelpButton } from "../_ui/help-button";
|
||
|
||
type Loaded = {
|
||
qobuzEmail: string;
|
||
qobuzUserId: string;
|
||
slskdUrl: string;
|
||
lastfmUsername: string;
|
||
qobuzPasswordSet: boolean;
|
||
qobuzTokenSet: boolean;
|
||
slskdApiKeySet: boolean;
|
||
lastfmApiKeySet: boolean;
|
||
};
|
||
|
||
type Tab = "Qobuz" | "Soulseek" | "Last.fm" | "Library" | "Monitor" | "Discovery";
|
||
const TABS: Tab[] = ["Qobuz", "Soulseek", "Last.fm", "Library", "Monitor", "Discovery"];
|
||
|
||
const DISCOVERY_FIELDS: [string, string][] = [
|
||
["discover.intervalHours", "Interval (hours)"],
|
||
["discover.chunkSize", "Seeds per chunk"],
|
||
["discover.similarPerSeed", "Similar per seed"],
|
||
["discover.albumsPerArtist", "Albums per artist"],
|
||
["discover.minScore", "Min score"],
|
||
];
|
||
|
||
const MONITOR_FIELDS: [string, string][] = [
|
||
["monitor.pollIntervalHours", "Poll interval (hours)"],
|
||
["monitor.retryIntervalHours", "Retry interval (hours)"],
|
||
["monitor.qualityCutoff", "Quality cutoff"],
|
||
["monitor.upgradeWindowDays", "Upgrade window (days)"],
|
||
["floor.jobDelaySeconds", "Delay between downloads (seconds)"],
|
||
];
|
||
|
||
export function SettingsForm() {
|
||
const [tab, setTab] = useState<Tab>("Qobuz");
|
||
const [qobuzEmail, setQobuzEmail] = useState(""); // legacy; no input, kept to detect stored creds
|
||
const [qobuzUserId, setQobuzUserId] = useState("");
|
||
const [qobuzToken, setQobuzToken] = useState("");
|
||
const [slskdUrl, setSlskdUrl] = useState("");
|
||
const [slskdApiKey, setSlskdApiKey] = useState("");
|
||
const [lastfmUsername, setLastfmUsername] = useState("");
|
||
const [lastfmApiKey, setLastfmApiKey] = useState("");
|
||
const [pwSet, setPwSet] = useState(false);
|
||
const [tokenSet, setTokenSet] = useState(false);
|
||
const [keySet, setKeySet] = useState(false);
|
||
const [lastfmKeySet, setLastfmKeySet] = useState(false);
|
||
const [saved, setSaved] = useState(false);
|
||
const [scanResult, setScanResult] = useState<string | null>(null);
|
||
const [scanRequested, setScanRequested] = useState(false);
|
||
const [discoverCfg, setDiscoverCfg] = useState<Record<string, string>>({});
|
||
const [discoverSaved, setDiscoverSaved] = useState(false);
|
||
const [monitorCfg, setMonitorCfg] = useState<Record<string, string>>({});
|
||
const [monitorSaved, setMonitorSaved] = useState(false);
|
||
const [pacingCfg, setPacingCfg] = useState<Record<string, string>>({});
|
||
const [pacingSaved, setPacingSaved] = useState(false);
|
||
|
||
useEffect(() => {
|
||
fetch("/api/scan")
|
||
.then((r) => r.json())
|
||
.then((s: { result: string | null; requested: boolean }) => {
|
||
setScanResult(s.result);
|
||
setScanRequested(s.requested);
|
||
});
|
||
}, []);
|
||
|
||
async function requestScan() {
|
||
await fetch("/api/scan", { method: "POST" });
|
||
setScanRequested(true);
|
||
}
|
||
|
||
useEffect(() => {
|
||
if (!scanRequested) return;
|
||
const id = setInterval(async () => {
|
||
const s = await (await fetch("/api/scan")).json();
|
||
setScanResult(s.result);
|
||
if (!s.requested) setScanRequested(false);
|
||
}, 2000);
|
||
return () => clearInterval(id);
|
||
}, [scanRequested]);
|
||
|
||
useEffect(() => {
|
||
fetch("/api/config")
|
||
.then((r) => r.json())
|
||
.then((c: Loaded) => {
|
||
setQobuzEmail(c.qobuzEmail);
|
||
setQobuzUserId(c.qobuzUserId);
|
||
setSlskdUrl(c.slskdUrl);
|
||
setLastfmUsername(c.lastfmUsername);
|
||
setPwSet(c.qobuzPasswordSet);
|
||
setTokenSet(c.qobuzTokenSet);
|
||
setKeySet(c.slskdApiKeySet);
|
||
setLastfmKeySet(c.lastfmApiKeySet);
|
||
});
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
fetch("/api/discover/config")
|
||
.then((r) => r.json())
|
||
.then((c: Record<string, string>) => setDiscoverCfg(c));
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
fetch("/api/monitor/config")
|
||
.then((r) => r.json())
|
||
.then((c: Record<string, string>) => setMonitorCfg(c));
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
fetch("/api/qobuz/pacing")
|
||
.then((r) => r.json())
|
||
.then((c: Record<string, string>) => setPacingCfg(c));
|
||
}, []);
|
||
|
||
async function saveCredentials(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
await fetch("/api/config", {
|
||
method: "PUT",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify({
|
||
qobuzUserId,
|
||
qobuzToken,
|
||
slskdUrl,
|
||
slskdApiKey,
|
||
lastfmApiKey,
|
||
lastfmUsername,
|
||
}),
|
||
});
|
||
setQobuzToken("");
|
||
setSlskdApiKey("");
|
||
setLastfmApiKey("");
|
||
setSaved(true);
|
||
setTimeout(() => setSaved(false), 1500);
|
||
setTokenSet(tokenSet || qobuzToken !== "");
|
||
setKeySet(keySet || slskdApiKey !== "");
|
||
setLastfmKeySet(lastfmKeySet || lastfmApiKey !== "");
|
||
}
|
||
|
||
function setCfg(key: string, value: string) {
|
||
setDiscoverCfg((c) => ({ ...c, [key]: value }));
|
||
}
|
||
|
||
async function saveDiscovery(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
await fetch("/api/discover/config", {
|
||
method: "PATCH",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify(discoverCfg),
|
||
});
|
||
setDiscoverSaved(true);
|
||
setTimeout(() => setDiscoverSaved(false), 1500);
|
||
}
|
||
|
||
function setMon(key: string, value: string) {
|
||
setMonitorCfg((c) => ({ ...c, [key]: value }));
|
||
}
|
||
|
||
async function saveMonitor(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
await fetch("/api/monitor/config", {
|
||
method: "PATCH",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify(monitorCfg),
|
||
});
|
||
setMonitorSaved(true);
|
||
setTimeout(() => setMonitorSaved(false), 1500);
|
||
}
|
||
|
||
function setPacing(key: string, value: string) {
|
||
setPacingCfg((c) => ({ ...c, [key]: value }));
|
||
}
|
||
|
||
async function savePacing(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
await fetch("/api/qobuz/pacing", {
|
||
method: "PATCH",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify(pacingCfg),
|
||
});
|
||
setPacingSaved(true);
|
||
setTimeout(() => setPacingSaved(false), 1500);
|
||
}
|
||
|
||
const secretHint = (set: boolean) => (set ? <span className="set"> · set</span> : null);
|
||
|
||
async function clearField(field: "qobuzToken" | "slskdApiKey" | "lastfmApiKey") {
|
||
await fetch(`/api/config?field=${field}`, { method: "DELETE" });
|
||
if (field === "qobuzToken") { setTokenSet(false); setQobuzToken(""); }
|
||
if (field === "slskdApiKey") { setKeySet(false); setSlskdApiKey(""); }
|
||
if (field === "lastfmApiKey") { setLastfmKeySet(false); setLastfmApiKey(""); }
|
||
}
|
||
|
||
// Purge the legacy email/password login (kept working for backward-compat, but Qobuz
|
||
// often rejects it — the token path is preferred).
|
||
async function clearLegacyQobuz() {
|
||
await Promise.all([
|
||
fetch("/api/config?field=qobuzPassword", { method: "DELETE" }),
|
||
fetch("/api/config?field=qobuzEmail", { method: "DELETE" }),
|
||
]);
|
||
setPwSet(false);
|
||
setQobuzEmail("");
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
<PageHead title="Settings" eyebrow="Credentials · library · monitor · discovery · last.fm" />
|
||
|
||
<div className="tabs">
|
||
{TABS.map((t) => (
|
||
<button key={t} className={`tab${tab === t ? " active" : ""}`} onClick={() => setTab(t)}>
|
||
{t}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{tab === "Qobuz" ? (
|
||
<>
|
||
<form className="settings-section" onSubmit={saveCredentials}>
|
||
<div className="subhead">
|
||
Auth token
|
||
<HelpButton title="Qobuz authentication">
|
||
Lyra signs in to Qobuz with a <b>user ID + auth token</b> — the download engine
|
||
treats these as more reliable than email/password, which Qobuz often rejects. Find
|
||
them in your logged-in Qobuz web session (bundle/app secret + user auth token).
|
||
</HelpButton>
|
||
</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)}
|
||
{tokenSet ? (
|
||
<button type="button" className="btn ghost sm"
|
||
aria-label="clear qobuz auth token" onClick={() => clearField("qobuzToken")}>
|
||
Clear
|
||
</button>
|
||
) : null}
|
||
</span>
|
||
<input aria-label="qobuz auth token" type="password" placeholder={tokenSet ? "•••• stored" : ""} value={qobuzToken} onChange={(e) => setQobuzToken(e.target.value)} />
|
||
</label>
|
||
</div>
|
||
{pwSet || qobuzEmail ? (
|
||
<p className="help">
|
||
A legacy Qobuz email/password login is still stored (Qobuz often rejects it —
|
||
the token above takes precedence).{" "}
|
||
<button type="button" className="btn ghost sm" aria-label="clear legacy qobuz login"
|
||
onClick={clearLegacyQobuz}>
|
||
Clear legacy login
|
||
</button>
|
||
</p>
|
||
) : null}
|
||
<div className="save-row">
|
||
<button type="submit" className="btn accent">Save</button>
|
||
{saved ? <span className="saved-note">Saved</span> : null}
|
||
</div>
|
||
</form>
|
||
<form className="settings-section" onSubmit={savePacing}>
|
||
<div className="subhead">
|
||
Pacing
|
||
<HelpButton title="Qobuz pacing">
|
||
Throttles Qobuz to a human-looking volume and cadence so the account isn't flagged
|
||
for bulk downloading. Within active hours it downloads up to the daily cap, spaced out
|
||
with randomized gaps; when throttled, albums fall through to Soulseek/YouTube and
|
||
upgrade to Qobuz hi-res later. The daily cap ramps up over the first two weeks from the
|
||
new-account start date.
|
||
</HelpButton>
|
||
</div>
|
||
<label className="field-inline">
|
||
<input type="checkbox" aria-label="pace qobuz downloads"
|
||
checked={pacingCfg["qobuz.pacing.enabled"] !== "false"}
|
||
onChange={(e) => setPacing("qobuz.pacing.enabled", e.target.checked ? "true" : "false")} />
|
||
<span>Pace Qobuz downloads</span>
|
||
</label>
|
||
<div className="field-grid">
|
||
<label className="field">
|
||
<span>Active hours — start (0–24)</span>
|
||
<input type="number" min={0} max={24} aria-label="qobuz active start hour"
|
||
value={pacingCfg["qobuz.pacing.activeStartHour"] ?? ""}
|
||
onChange={(e) => setPacing("qobuz.pacing.activeStartHour", e.target.value)} />
|
||
</label>
|
||
<label className="field">
|
||
<span>Active hours — end (0–24)</span>
|
||
<input type="number" min={0} max={24} aria-label="qobuz active end hour"
|
||
value={pacingCfg["qobuz.pacing.activeEndHour"] ?? ""}
|
||
onChange={(e) => setPacing("qobuz.pacing.activeEndHour", e.target.value)} />
|
||
</label>
|
||
<label className="field">
|
||
<span>Daily cap (steady)</span>
|
||
<input type="number" min={0} aria-label="qobuz daily cap"
|
||
value={pacingCfg["qobuz.pacing.dailyCap"] ?? ""}
|
||
onChange={(e) => setPacing("qobuz.pacing.dailyCap", e.target.value)} />
|
||
</label>
|
||
<label className="field">
|
||
<span>New-account start (warm-up)</span>
|
||
<input type="date" aria-label="qobuz warmup start date"
|
||
value={pacingCfg["qobuz.pacing.warmupStartDate"] ?? ""}
|
||
onChange={(e) => setPacing("qobuz.pacing.warmupStartDate", e.target.value)} />
|
||
</label>
|
||
</div>
|
||
<div className="save-row">
|
||
<button type="submit" className="btn accent">Save</button>
|
||
{pacingSaved ? <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)}
|
||
{keySet ? (
|
||
<button type="button" className="btn ghost sm"
|
||
aria-label="clear slskd api key" onClick={() => clearField("slskdApiKey")}>
|
||
Clear
|
||
</button>
|
||
) : null}
|
||
</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 === "Last.fm" ? (
|
||
<form className="settings-section" onSubmit={saveCredentials}>
|
||
<div className="subhead">Last.fm</div>
|
||
<div className="field-grid">
|
||
<label className="field">
|
||
<span>Last.fm username</span>
|
||
<input aria-label="lastfm username" value={lastfmUsername} onChange={(e) => setLastfmUsername(e.target.value)} />
|
||
</label>
|
||
<label className="field">
|
||
<span>
|
||
Last.fm API key{secretHint(lastfmKeySet)}
|
||
{lastfmKeySet ? (
|
||
<button type="button" className="btn ghost sm"
|
||
aria-label="clear lastfm api key" onClick={() => clearField("lastfmApiKey")}>
|
||
Clear
|
||
</button>
|
||
) : null}
|
||
</span>
|
||
<input aria-label="lastfm api key" type="password" placeholder={lastfmKeySet ? "•••• stored" : ""} value={lastfmApiKey} onChange={(e) => setLastfmApiKey(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>
|
||
<div className="info-panel">
|
||
<div>
|
||
<span className="k">Staging path</span> <code>/staging</code>
|
||
</div>
|
||
<div className="muted-note" style={{ fontFamily: "inherit" }}>
|
||
Where each download is assembled + verified before being promoted into the library.
|
||
Set by <code>STAGING_DIR</code> in <code>docker-compose.yml</code> (defaults to{" "}
|
||
<code>MUSIC_DIR/.staging</code>). When your library is on a network share (SMB/NFS),
|
||
point <code>STAGING_DIR</code> at a <b>fast local disk</b> so temporary download I/O
|
||
doesn’t hammer the share — the finished album is copied onto the library volume and
|
||
swapped in atomically. Rebuild to change.
|
||
</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 === "Monitor" ? (
|
||
<form className="settings-section" onSubmit={saveMonitor}>
|
||
<div className="save-row" style={{ margin: "6px 0 4px" }}>
|
||
<label className="toggle">
|
||
<input
|
||
type="checkbox"
|
||
aria-label="automatic monitoring"
|
||
checked={monitorCfg["monitor.enabled"] === "true"}
|
||
onChange={(e) => setMon("monitor.enabled", e.target.checked ? "true" : "false")}
|
||
/>
|
||
Automatic monitoring (grab & upgrade)
|
||
</label>
|
||
<HelpButton title="Monitoring">
|
||
When on, the worker periodically checks your followed artists: it <b>grabs new
|
||
releases</b> as they appear and <b>upgrades</b> owned albums that fall below the
|
||
quality cutoff.
|
||
<br />
|
||
<br />
|
||
<b>Quality cutoff</b> — the minimum acceptable quality class: <code>1</code> = lossy,{" "}
|
||
<code>2</code> = CD-lossless (FLAC 16/44.1), <code>3</code> = hi-res. An owned album is
|
||
kept as-is when it’s ≥ the cutoff; otherwise a better copy is chased. Range 1–3.
|
||
<br />
|
||
<br />
|
||
<b>Poll interval</b> — hours between new-release checks. <b>Retry interval</b> — hours
|
||
before re-attempting a release that couldn’t be found yet. <b>Upgrade window</b> — how
|
||
many days after first acquiring an album to keep looking for a better copy.
|
||
</HelpButton>
|
||
</div>
|
||
<div className="field-grid">
|
||
{MONITOR_FIELDS.map(([key, label]) => (
|
||
<label key={key} className="field">
|
||
<span>{label}</span>
|
||
<input type="number" step="any" value={monitorCfg[key] ?? ""} onChange={(e) => setMon(key, e.target.value)} />
|
||
</label>
|
||
))}
|
||
</div>
|
||
<div className="save-row">
|
||
<button type="submit" className="btn">Save monitor settings</button>
|
||
{monitorSaved ? <span className="saved-note">Saved</span> : null}
|
||
</div>
|
||
</form>
|
||
) : 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>
|
||
<HelpButton title="Discovery">
|
||
When on, the worker periodically sweeps your followed artists for similar artists +
|
||
their new releases (via ListenBrainz and, if configured, Last.fm) and lists them on{" "}
|
||
<b>Discover</b>. Nothing is downloaded automatically — you Follow/Want from there.
|
||
<br />
|
||
<br />
|
||
<b>Interval</b> — hours between sweeps. <b>Seeds per chunk</b> — how many followed
|
||
artists are processed per worker cycle (keeps job-claiming responsive on big rosters).
|
||
<b> Similar per seed</b> — candidates pulled per artist. <b>Albums per artist</b> —
|
||
releases surfaced per suggested artist. <b>Min score</b> — drops weak suggestions.{" "}
|
||
<b>Albums only</b> — suggest full-length albums only, hiding singles and EPs.{" "}
|
||
<b>Seed from library</b> — also find artists similar to ones you own albums by, not
|
||
just the artists you follow.{" "}
|
||
<b>ListenBrainz base URL</b> — leave blank for the default endpoint.
|
||
</HelpButton>
|
||
</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>
|
||
<label className="toggle" style={{ margin: "10px 0 0" }}>
|
||
<input
|
||
type="checkbox"
|
||
checked={discoverCfg["discover.albumsOnly"] !== "false"}
|
||
onChange={(e) => setCfg("discover.albumsOnly", e.target.checked ? "true" : "false")}
|
||
/>
|
||
Albums only (hide singles & EPs)
|
||
</label>
|
||
<label className="toggle" style={{ margin: "6px 0 0" }}>
|
||
<input
|
||
type="checkbox"
|
||
checked={discoverCfg["discover.seedFromLibrary"] !== "false"}
|
||
onChange={(e) => setCfg("discover.seedFromLibrary", e.target.checked ? "true" : "false")}
|
||
/>
|
||
Seed from library (similar to albums you own)
|
||
</label>
|
||
<label className="field">
|
||
<span>ListenBrainz base URL (blank = default)</span>
|
||
<input value={discoverCfg["discover.listenBrainzUrl"] ?? ""}
|
||
onChange={(e) => setCfg("discover.listenBrainzUrl", e.target.value)} />
|
||
</label>
|
||
<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>
|
||
);
|
||
}
|