24d4ba6098
Follow affordance in ALL album modals (user request): thread artistMbid through the remaining callers — Library, Wanted, and artist Discography. /api/library and /api/wanted now expose artistMbid (from the joined MonitoredRelease); the artist-detail route already exposed mbid. So every album modal now links the artist name to their page + shows a Follow/Following button. Settings polish (#10): - Qobuz tab: drop the email/password inputs (the engine prefers the more reliable user-id + auth-token path); a "Clear legacy login" affordance appears only when old email/password creds are still stored, and the PUT no longer sends them. - Clear-button alignment fixed: .field label is a flex row and .field-grid bottom-aligns inputs, so a set-secret field (with its · set + Clear) lines up with a plain sibling field. - Library tab gains a read-only Staging path panel (/staging, STAGING_DIR, why fast local disk, rebuild-to-change). - Monitor + Discovery tabs gain grounded helptext (quality-class 1/2/3 cutoff, poll/retry/upgrade-window; sweep cadence + seeds-per-chunk + ListenBrainz URL). web 152 tests, tsc + build clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
149 lines
5.0 KiB
TypeScript
149 lines
5.0 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { PageHead } from "../../_ui/page-head";
|
|
import { CoverArt } from "../../_ui/cover-art";
|
|
import { AlbumModal } from "../../_ui/album-modal";
|
|
import { categoryOf, TAB_ORDER, type Category } from "../../_ui/categories";
|
|
|
|
type Release = {
|
|
id: string;
|
|
rgMbid: string | null;
|
|
album: string;
|
|
primaryType: string | null;
|
|
secondaryTypes: string[];
|
|
firstReleaseDate: string | null;
|
|
monitored: boolean;
|
|
state: string;
|
|
currentQualityClass: number | null;
|
|
};
|
|
type Detail = { id: string; mbid: string; name: string; autoMonitorFuture: boolean; showAllTypes: boolean; releases: Release[] };
|
|
|
|
function badge(r: Release): { label: string; cls: string } {
|
|
if (r.currentQualityClass !== null) return { label: `Have q${r.currentQualityClass}`, cls: "chip done" };
|
|
if (r.monitored) return { label: r.state === "wanted" ? "Wanted" : r.state, cls: "chip working" };
|
|
return { label: "Dormant", cls: "chip" };
|
|
}
|
|
|
|
export function DiscographyClient({ id }: { id: string }) {
|
|
const [detail, setDetail] = useState<Detail | null>(null);
|
|
const [tab, setTab] = useState<"All" | Category>("Albums");
|
|
const [openAlbum, setOpenAlbum] = useState<Release | null>(null);
|
|
|
|
async function refresh() {
|
|
const res = await fetch(`/api/artists/${id}?all=true`);
|
|
setDetail(res.ok ? await res.json() : null);
|
|
}
|
|
useEffect(() => {
|
|
refresh();
|
|
}, [id]);
|
|
|
|
async function toggleMonitor(r: Release) {
|
|
await fetch(`/api/releases/${r.id}`, {
|
|
method: "PATCH",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ monitored: !r.monitored }),
|
|
});
|
|
refresh();
|
|
}
|
|
async function searchNow(r: Release) {
|
|
await fetch(`/api/releases/${r.id}/search`, { method: "POST" });
|
|
refresh();
|
|
}
|
|
|
|
const counts = useMemo(() => {
|
|
const c: Record<string, number> = {};
|
|
detail?.releases.forEach((r) => {
|
|
c[categoryOf(r)] = (c[categoryOf(r)] ?? 0) + 1;
|
|
});
|
|
return c;
|
|
}, [detail]);
|
|
|
|
if (!detail) return <p className="empty">Loading…</p>;
|
|
|
|
const tabs: ("All" | Category)[] = ["All", ...TAB_ORDER.filter((t) => counts[t])];
|
|
const visible = tab === "All" ? detail.releases : detail.releases.filter((r) => categoryOf(r) === tab);
|
|
|
|
return (
|
|
<div>
|
|
<PageHead title={detail.name} eyebrow="Discography" />
|
|
|
|
<div className="tabs">
|
|
{tabs.map((t) => (
|
|
<button key={t} className={`tab${tab === t ? " active" : ""}`} onClick={() => setTab(t)}>
|
|
{t}
|
|
<span className="n">{t === "All" ? detail.releases.length : counts[t]}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<ul className="list">
|
|
{visible.map((r) => {
|
|
const b = badge(r);
|
|
return (
|
|
<li key={r.id} className="list-row">
|
|
<div className="main">
|
|
<button className="disco-open" onClick={() => setOpenAlbum(r)} aria-label={`Open ${r.album}`}>
|
|
<CoverArt rgMbid={r.rgMbid} alt={r.album} className="thumb" />
|
|
<span>
|
|
<span className="rtitle">
|
|
{r.album}
|
|
{r.firstReleaseDate ? <span className="dim"> ({r.firstReleaseDate.slice(0, 4)})</span> : null}
|
|
</span>
|
|
<span className="rmeta">
|
|
{r.primaryType ? <span>{r.primaryType}</span> : null}
|
|
{r.secondaryTypes.length ? (
|
|
<>
|
|
<span className="dot">·</span>
|
|
<span>{r.secondaryTypes.join(", ")}</span>
|
|
</>
|
|
) : null}
|
|
</span>
|
|
</span>
|
|
</button>
|
|
</div>
|
|
<div className="actions">
|
|
<span className={b.cls}>{b.label}</span>
|
|
<label className="toggle">
|
|
<input type="checkbox" aria-label={`monitor ${r.album}`} checked={r.monitored} onChange={() => toggleMonitor(r)} />
|
|
monitor
|
|
</label>
|
|
<button className="btn sm ghost" onClick={() => searchNow(r)}>
|
|
Search now
|
|
</button>
|
|
</div>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
|
|
{openAlbum ? (
|
|
<AlbumModal
|
|
open
|
|
onClose={() => setOpenAlbum(null)}
|
|
album={{
|
|
rgMbid: openAlbum.rgMbid,
|
|
album: openAlbum.album,
|
|
artist: detail.name,
|
|
year: openAlbum.firstReleaseDate?.slice(0, 4) ?? null,
|
|
type: openAlbum.primaryType,
|
|
artistMbid: detail.mbid,
|
|
}}
|
|
status={<span className={badge(openAlbum).cls}>{badge(openAlbum).label}</span>}
|
|
actions={
|
|
<button
|
|
className="btn sm ghost"
|
|
onClick={() => {
|
|
searchNow(openAlbum);
|
|
setOpenAlbum(null);
|
|
}}
|
|
>
|
|
Search now
|
|
</button>
|
|
}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|