Files
Lyra/web/src/app/wanted/wanted-client.tsx
T
Jonathan 24d4ba6098 feat(web): AlbumModal follow everywhere + Settings polish (#10)
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>
2026-07-14 12:09:52 +02:00

147 lines
4.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 ?? "Couldnt 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 artists 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>
);
}