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>
147 lines
4.6 KiB
TypeScript
147 lines
4.6 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState } from "react";
|
||
import { PageHead } from "../_ui/page-head";
|
||
import { SectionHeader } from "../_ui/section-header";
|
||
import { CoverArt } from "../_ui/cover-art";
|
||
import { AlbumModal } from "../_ui/album-modal";
|
||
import { toast } from "../_ui/toast";
|
||
|
||
type Wanted = {
|
||
id: string;
|
||
artistName: string;
|
||
artistMbid: string | null;
|
||
album: string;
|
||
state: string;
|
||
currentQualityClass: number | null;
|
||
lastSearchedAt: string | null;
|
||
rgMbid: string | null;
|
||
primaryType: string | null;
|
||
firstReleaseDate: string | null;
|
||
};
|
||
|
||
function stateLabel(w: Wanted): string {
|
||
if (w.currentQualityClass !== null) return `Have q${w.currentQualityClass} · upgrading`;
|
||
return w.state === "wanted" ? "Wanted" : w.state;
|
||
}
|
||
|
||
export function WantedClient() {
|
||
const [rows, setRows] = useState<Wanted[]>([]);
|
||
const [artist, setArtist] = useState("");
|
||
const [album, setAlbum] = useState("");
|
||
const [error, setError] = useState("");
|
||
const [open, setOpen] = useState<Wanted | null>(null);
|
||
|
||
async function refresh() {
|
||
const res = await fetch("/api/wanted");
|
||
setRows((await res.json()).wanted);
|
||
}
|
||
useEffect(() => {
|
||
refresh();
|
||
}, []);
|
||
|
||
async function add(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
setError("");
|
||
if (!artist.trim() || !album.trim()) return;
|
||
const res = await fetch("/api/wanted", {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify({ artist, album }),
|
||
});
|
||
if (!res.ok) {
|
||
setError((await res.json()).error ?? "Couldn’t add that — no MusicBrainz match?");
|
||
return;
|
||
}
|
||
toast("Added to wanted");
|
||
setArtist("");
|
||
setAlbum("");
|
||
refresh();
|
||
}
|
||
|
||
async function searchNow(w: Wanted) {
|
||
await fetch(`/api/releases/${w.id}/search`, { method: "POST" });
|
||
toast(`Searching for ${w.album}`);
|
||
refresh();
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
<PageHead title="Wanted" eyebrow="Cutting list · acquire & upgrade" />
|
||
|
||
<form className="request-form" onSubmit={add}>
|
||
<label className="field">
|
||
<span>Artist</span>
|
||
<input aria-label="wanted artist" placeholder="Artist" value={artist} onChange={(e) => setArtist(e.target.value)} />
|
||
</label>
|
||
<label className="field">
|
||
<span>Album</span>
|
||
<input aria-label="wanted album" placeholder="Album" value={album} onChange={(e) => setAlbum(e.target.value)} />
|
||
</label>
|
||
<button type="submit" className="btn accent">
|
||
Add to wanted
|
||
</button>
|
||
{error ? <span className="notice">{error}</span> : null}
|
||
</form>
|
||
|
||
<SectionHeader title="Wanted" note={`${rows.length} on the list`} />
|
||
{rows.length === 0 ? (
|
||
<p className="empty">Nothing wanted yet. Add a release above, or send one over from an artist’s discography.</p>
|
||
) : (
|
||
<ul className="list">
|
||
{rows.map((w) => (
|
||
<li key={w.id} className="list-row">
|
||
<div className="main">
|
||
<button className="disco-open" onClick={() => setOpen(w)} aria-label={`Open ${w.album}`}>
|
||
<CoverArt rgMbid={w.rgMbid} alt={w.album} className="thumb" />
|
||
<span>
|
||
<span className="rtitle">
|
||
{w.album} <span className="artist">· {w.artistName}</span>
|
||
</span>
|
||
<span className="rmeta">
|
||
{w.lastSearchedAt ? `last tried ${new Date(w.lastSearchedAt).toLocaleDateString()}` : "never tried"}
|
||
</span>
|
||
</span>
|
||
</button>
|
||
</div>
|
||
<div className="actions">
|
||
<span className="chip">{stateLabel(w)}</span>
|
||
<button className="btn sm ghost" onClick={() => searchNow(w)}>
|
||
Search now
|
||
</button>
|
||
</div>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
|
||
{open ? (
|
||
<AlbumModal
|
||
open
|
||
onClose={() => setOpen(null)}
|
||
album={{
|
||
rgMbid: open.rgMbid,
|
||
album: open.album,
|
||
artist: open.artistName,
|
||
year: open.firstReleaseDate?.slice(0, 4) ?? null,
|
||
type: open.primaryType,
|
||
artistMbid: open.artistMbid,
|
||
}}
|
||
status={<span className="chip working">{stateLabel(open)}</span>}
|
||
actions={
|
||
<button
|
||
className="btn sm ghost"
|
||
onClick={() => {
|
||
searchNow(open);
|
||
setOpen(null);
|
||
}}
|
||
>
|
||
Search now
|
||
</button>
|
||
}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|