feat(web): restyle Artists, Discover, Wanted, discography + preview (Phase 2)

Convert the remaining pages onto the Pressing Plant system: PageHead
component, shared list-row / toggle / small+ghost button / tracklist classes,
and page wrappers trimmed now that the shell provides masthead + nav. All
logic and test hooks (aria-labels, button text) preserved. Verified in the
real app across both themes (Playwright): artists/discover/wanted/discography
render cohesively with real data.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan
2026-07-12 14:27:43 +02:00
parent 7f1fe52a7c
commit ead5f33bcc
12 changed files with 404 additions and 204 deletions
+8
View File
@@ -0,0 +1,8 @@
export function PageHead({ title, eyebrow }: { title: string; eyebrow?: string }) {
return (
<div className="page-head">
{eyebrow ? <p className="eyebrow">{eyebrow}</p> : null}
<h1>{title}</h1>
</div>
);
}
+46 -21
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { PageHead } from "../../_ui/page-head";
type Release = { type Release = {
id: string; id: string;
@@ -14,10 +15,10 @@ type Release = {
}; };
type Detail = { id: string; name: string; autoMonitorFuture: boolean; showAllTypes: boolean; releases: Release[] }; type Detail = { id: string; name: string; autoMonitorFuture: boolean; showAllTypes: boolean; releases: Release[] };
function badge(r: Release): string { function badge(r: Release): { label: string; cls: string } {
if (r.currentQualityClass !== null) return `Have (q${r.currentQualityClass})`; if (r.currentQualityClass !== null) return { label: `Have q${r.currentQualityClass}`, cls: "chip done" };
if (r.monitored) return r.state === "wanted" ? "Wanted" : r.state; if (r.monitored) return { label: r.state === "wanted" ? "Wanted" : r.state, cls: "chip working" };
return "Dormant"; return { label: "Dormant", cls: "chip" };
} }
export function DiscographyClient({ id }: { id: string }) { export function DiscographyClient({ id }: { id: string }) {
@@ -53,32 +54,56 @@ export function DiscographyClient({ id }: { id: string }) {
refresh(); refresh();
} }
if (!detail) return <p>Loading</p>; if (!detail) return <p className="empty">Loading</p>;
return ( return (
<div> <div>
<h1>{detail.name}</h1> <PageHead title={detail.name} eyebrow="Discography" />
<label>
<div className="tool-row">
<label className="toggle">
<input <input
type="checkbox" type="checkbox"
aria-label="show all release types" aria-label="show all release types"
checked={detail.showAllTypes} checked={detail.showAllTypes}
onChange={toggleShowAll} onChange={toggleShowAll}
/>{" "} />
Show all release types (live, compilations, singles) Show all types (live, compilations, singles)
</label> </label>
<ul> </div>
{detail.releases.map((r) => (
<li key={r.id}> <ul className="list">
<strong>{r.album}</strong> {detail.releases.map((r) => {
{r.firstReleaseDate ? ` (${r.firstReleaseDate.slice(0, 4)})` : ""} const b = badge(r);
{r.primaryType ? ` · ${r.primaryType}` : ""} return (
{r.secondaryTypes.length ? ` [${r.secondaryTypes.join(", ")}]` : ""} · <em>{badge(r)}</em>{" "} <li key={r.id} className="list-row">
<label> <div className="main">
<input type="checkbox" aria-label={`monitor ${r.album}`} checked={r.monitored} onChange={() => toggleMonitor(r)} /> monitor <div className="rtitle">
</label>{" "} {r.album}
<button onClick={() => searchNow(r)}>Search now</button> {r.firstReleaseDate ? <span className="dim"> ({r.firstReleaseDate.slice(0, 4)})</span> : null}
</div>
<div className="rmeta">
{r.primaryType ? <span>{r.primaryType}</span> : null}
{r.secondaryTypes.length ? (
<>
<span className="dot">·</span>
<span>{r.secondaryTypes.join(", ")}</span>
</>
) : null}
</div>
</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> </li>
))} );
})}
</ul> </ul>
</div> </div>
); );
+1 -6
View File
@@ -2,10 +2,5 @@ import { DiscographyClient } from "./discography-client";
export default async function ArtistDetailPage({ params }: { params: Promise<{ id: string }> }) { export default async function ArtistDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params; const { id } = await params;
return ( return <DiscographyClient id={id} />;
<main>
<p><a href="/artists"> Artists</a></p>
<DiscographyClient id={id} />
</main>
);
} }
+66 -16
View File
@@ -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 Artist = { type Artist = {
id: string; id: string;
@@ -68,38 +70,86 @@ export function ArtistsClient() {
return ( return (
<div> <div>
<form onSubmit={search}> <PageHead title="Artists" eyebrow="The roster · follow & watch" />
<input aria-label="artist search" placeholder="Search an artist…" value={query} onChange={(e) => setQuery(e.target.value)} />
<button type="submit">Search</button> <form className="request-form" onSubmit={search}>
<label className="field">
<span>Find an artist</span>
<input
aria-label="artist search"
placeholder="Search MusicBrainz…"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</label>
<button type="submit" className="btn accent">
Search
</button>
{searching ? <span className="rmeta">searching</span> : null}
</form> </form>
{searching ? <p>Searching</p> : null}
{hits.length > 0 ? ( {hits.length > 0 ? (
<ul> <>
<SectionHeader title="Search results" note={`${hits.length} found`} />
<ul className="list">
{hits.map((h) => ( {hits.map((h) => (
<li key={h.mbid}> <li key={h.mbid} className="list-row">
<div className="main">
<div className="rtitle">
<a href={`/discover/artist/${h.mbid}?name=${encodeURIComponent(h.name)}`}>{h.name}</a> <a href={`/discover/artist/${h.mbid}?name=${encodeURIComponent(h.name)}`}>{h.name}</a>
{h.disambiguation ? ` (${h.disambiguation})` : ""}{" "} {h.disambiguation ? <span className="dim"> {h.disambiguation}</span> : null}
</div>
</div>
<div className="actions">
{followedMbids.has(h.mbid) ? ( {followedMbids.has(h.mbid) ? (
<span> · following</span> <span className="following">Following</span>
) : ( ) : (
<button onClick={() => follow(h)}>Follow</button> <button className="btn sm" onClick={() => follow(h)}>
Follow
</button>
)} )}
</div>
</li> </li>
))} ))}
</ul> </ul>
</>
) : null} ) : null}
<ul> <SectionHeader title="Watching" note={`${artists.length} artists`} />
{artists.length === 0 ? (
<p className="empty">Not following anyone yet. Search above and follow an artist to start watching their releases.</p>
) : (
<ul className="list">
{artists.map((a) => ( {artists.map((a) => (
<li key={a.id}> <li key={a.id} className="list-row">
<a href={`/artists/${a.id}`}>{a.name}</a> · {a.haveCount}/{a.monitoredCount} have/monitored of {a.releaseCount}{" "} <div className="main">
<label> <div className="rtitle">
<input type="checkbox" aria-label={`auto-monitor ${a.name}`} checked={a.autoMonitorFuture} onChange={() => toggleAuto(a)} /> auto-monitor future <a href={`/artists/${a.id}`}>{a.name}</a>
</label>{" "} </div>
<button onClick={() => unfollow(a)}>Unfollow</button> <div className="rmeta">
<span className="score">
{a.haveCount} have <span className="dot">·</span> {a.monitoredCount} monitored <span className="dot">·</span> {a.releaseCount} releases
</span>
</div>
</div>
<div className="actions">
<label className="toggle">
<input
type="checkbox"
aria-label={`auto-monitor ${a.name}`}
checked={a.autoMonitorFuture}
onChange={() => toggleAuto(a)}
/>
auto-monitor
</label>
<button className="btn sm ghost" onClick={() => unfollow(a)}>
Unfollow
</button>
</div>
</li> </li>
))} ))}
</ul> </ul>
)}
</div> </div>
); );
} }
+1 -7
View File
@@ -1,11 +1,5 @@
import { ArtistsClient } from "./artists-client"; import { ArtistsClient } from "./artists-client";
export default function ArtistsPage() { export default function ArtistsPage() {
return ( return <ArtistsClient />;
<main>
<h1>Watched artists</h1>
<p><a href="/">Home</a> · <a href="/wanted">Wanted</a></p>
<ArtistsClient />
</main>
);
} }
+53
View File
@@ -169,3 +169,56 @@ nav.contents .sep { flex: 1; }
.job { grid-template-columns: 4px 1fr; } .job { grid-template-columns: 4px 1fr; }
.job .chip { grid-column: 2 / 3; justify-self: start; margin-top: 12px; } .job .chip { grid-column: 2 / 3; justify-self: start; margin-top: 12px; }
} }
/* ── Phase 2: page headers, list rows, tools ──────────── */
.page-head { margin: 6px 0 26px; }
.page-head .eyebrow {
font-family: var(--mono); font-size: 0.66rem; letter-spacing: 0.2em;
text-transform: uppercase; color: var(--graphite); margin: 0 0 9px;
}
.page-head h1 {
font-size: clamp(1.9rem, 4vw, 2.6rem); font-weight: 600; letter-spacing: -0.02em;
margin: 0; line-height: 1; text-wrap: balance;
}
.list { list-style: none; margin: 0 0 40px; padding: 0; }
.list-row {
display: flex; gap: 14px 20px; align-items: baseline; justify-content: space-between;
flex-wrap: wrap; padding: 16px 0; border-bottom: 1px solid var(--rule);
}
.list-row .main { min-width: 0; }
.list-row .rtitle { font-size: 1.2rem; line-height: 1.2; }
.list-row .rtitle a { color: inherit; text-decoration: none; }
.list-row .rtitle a:hover { text-decoration: underline; text-decoration-color: var(--accent); }
.list-row .rtitle .artist, .list-row .rtitle .dim { color: var(--graphite); }
.list-row .rmeta {
font-family: var(--mono); font-size: 0.68rem; letter-spacing: 0.06em; text-transform: uppercase;
color: var(--graphite); margin-top: 7px; display: flex; gap: 10px; flex-wrap: wrap; align-items: center;
}
.list-row .rmeta .dot { opacity: 0.5; }
.actions { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.btn.sm { padding: 6px 11px; font-size: 0.62rem; letter-spacing: 0.12em; }
.btn.ghost { border-color: var(--rule-2); color: var(--graphite); }
.btn.ghost:hover { background: transparent; border-color: var(--ink); color: var(--ink); }
.toggle {
font-family: var(--mono); font-size: 0.64rem; letter-spacing: 0.1em; text-transform: uppercase;
color: var(--graphite); display: inline-flex; align-items: center; gap: 7px; cursor: pointer;
}
.toggle input { accent-color: var(--accent); }
.following { font-family: var(--mono); font-size: 0.62rem; letter-spacing: 0.14em; text-transform: uppercase; color: var(--accent); }
.tool-row { display: flex; gap: 14px; align-items: center; flex-wrap: wrap; margin: 0 0 28px; }
.notice { font-family: var(--mono); font-size: 0.7rem; letter-spacing: 0.04em; color: var(--alert); }
.muted-note { color: var(--graphite); font-size: 0.95rem; }
.score { font-variant-numeric: tabular-nums; }
/* tracklist (preview page) */
.tracks { list-style: none; margin: 10px 0 4px; padding: 0 0 0 2px; }
.tracks li {
display: flex; gap: 12px; font-family: var(--mono); font-size: 0.74rem; color: var(--graphite);
padding: 3px 0; font-variant-numeric: tabular-nums;
}
.tracks li .tnum { color: var(--rule-2); min-width: 2em; }
.tracks li .tname { color: var(--ink); }
+1 -8
View File
@@ -9,12 +9,5 @@ export default async function PreviewPage({
}) { }) {
const { mbid } = await params; const { mbid } = await params;
const { name } = await searchParams; const { name } = await searchParams;
return ( return <PreviewClient mbid={mbid} initialName={name ?? ""} />;
<main>
<p>
<a href="/discover"> Discover</a> · <a href="/artists">Artists</a>
</p>
<PreviewClient mbid={mbid} initialName={name ?? ""} />
</main>
);
} }
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { PageHead } from "../../../_ui/page-head";
type Release = { type Release = {
rgMbid: string; rgMbid: string;
@@ -105,52 +106,78 @@ export function PreviewClient({ mbid, initialName }: { mbid: string; initialName
if (error) if (error)
return ( return (
<p> <p className="empty">
Couldnt load this artist. <button onClick={load}>Retry</button> Couldnt load this artist.{" "}
<button className="btn sm" onClick={load}>
Retry
</button>
</p> </p>
); );
if (!data) return <p>Loading</p>; if (!data) return <p className="empty">Loading</p>;
return ( return (
<div> <div>
<h1>{data.artistName || "Artist"}</h1> <PageHead title={data.artistName || "Artist"} eyebrow="Preview · discography" />
<p>
{followed ? <span>Following</span> : <button onClick={follow}>Follow</button>} <div className="tool-row">
{" · "} {followed ? (
<span className="following">Following</span>
) : (
<button className="btn accent" onClick={follow}>
Follow
</button>
)}
<a href={`https://musicbrainz.org/artist/${mbid}`} target="_blank" rel="noreferrer"> <a href={`https://musicbrainz.org/artist/${mbid}`} target="_blank" rel="noreferrer">
MusicBrainz MusicBrainz
</a> </a>
</p> <label className="toggle">
<label> <input type="checkbox" checked={showAll} onChange={(e) => setShowAll(e.target.checked)} />
<input type="checkbox" checked={showAll} onChange={(e) => setShowAll(e.target.checked)} /> Show all release Show all types
types
</label> </label>
<ul> </div>
{data.releases.map((r) => (
<li key={r.rgMbid} id={`rg-${r.rgMbid}`}> <ul className="list">
<strong>{r.album}</strong>{" "} {data.releases.map((r) => {
<small> const t = tracks[r.rgMbid];
{r.primaryType} return (
{r.firstReleaseDate ? ` · ${r.firstReleaseDate.slice(0, 4)}` : ""} <li key={r.rgMbid} id={`rg-${r.rgMbid}`} className="list-row">
</small>{" "} <div className="main">
{r.have ? <em>In library</em> : r.monitored ? <em>Monitored</em> : null}{" "} <div className="rtitle">{r.album}</div>
<button onClick={() => want(r)} disabled={r.have || r.monitored}> <div className="rmeta">
{r.primaryType ? <span>{r.primaryType}</span> : null}
{r.firstReleaseDate ? (
<>
<span className="dot">·</span>
<span>{r.firstReleaseDate.slice(0, 4)}</span>
</>
) : null}
</div>
</div>
<div className="actions">
{r.have ? <span className="chip done">In library</span> : r.monitored ? <span className="chip working">Monitored</span> : null}
<button className="btn sm" onClick={() => want(r)} disabled={r.have || r.monitored}>
Want Want
</button>{" "} </button>
<button onClick={() => toggleTracks(r.rgMbid)}>{tracks[r.rgMbid] ? "Hide tracks" : "Tracks"}</button> <button className="btn sm ghost" onClick={() => toggleTracks(r.rgMbid)}>
{tracks[r.rgMbid] === "loading" && <p>Loading tracks</p>} {t ? "Hide tracks" : "Tracks"}
{tracks[r.rgMbid] === "error" && <p>Couldnt load tracks.</p>} </button>
{Array.isArray(tracks[r.rgMbid]) && ( </div>
<ol> {t === "loading" ? <p className="muted-note" style={{ flexBasis: "100%" }}>Loading tracks</p> : null}
{(tracks[r.rgMbid] as Track[]).map((t) => ( {t === "error" ? <p className="notice" style={{ flexBasis: "100%" }}>Couldnt load tracks.</p> : null}
<li key={t.position}> {Array.isArray(t) ? (
{t.title} {t.lengthMs != null && <small>({fmt(t.lengthMs)})</small>} <ol className="tracks" style={{ flexBasis: "100%" }}>
{t.map((tr) => (
<li key={tr.position}>
<span className="tnum">{tr.position}</span>
<span className="tname">{tr.title}</span>
{tr.lengthMs != null ? <span>{fmt(tr.lengthMs)}</span> : null}
</li> </li>
))} ))}
</ol> </ol>
)} ) : null}
</li> </li>
))} );
})}
</ul> </ul>
</div> </div>
); );
+88 -52
View File
@@ -1,6 +1,8 @@
"use client"; "use client";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { PageHead } from "../_ui/page-head";
import { SectionHeader } from "../_ui/section-header";
type Suggestion = { type Suggestion = {
id: string; id: string;
@@ -93,80 +95,114 @@ export function DiscoverClient() {
return ( return (
<div> <div>
<section> <PageHead title="Discover" eyebrow="A&R · similar artists & new releases" />
<button onClick={discoverNow} disabled={running}>
<div className="tool-row">
<button className="btn accent" onClick={discoverNow} disabled={running}>
{running ? "Discovering…" : "Discover now"} {running ? "Discovering…" : "Discover now"}
</button> </button>
{result && <span> Last run: {result}</span>} {result ? <span className="rmeta">last run · {result}</span> : null}
</section> </div>
<section> <SectionHeader title="Find similar" />
<h2>Seed search</h2> <form className="request-form" onSubmit={runSearch}>
<form onSubmit={runSearch}> <label className="field">
<input <span>Seed artist</span>
value={query} <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Artist name…" aria-label="Seed artist" />
onChange={(e) => setQuery(e.target.value)} </label>
placeholder="Artist name…" <button type="submit" className="btn" disabled={busy}>
aria-label="Seed artist" {busy ? "Searching…" : "Find similar"}
/> </button>
<button type="submit" disabled={busy}>Find similar</button>
</form> </form>
{search && !search.seed && <p>No MusicBrainz match.</p>} {search && !search.seed ? <p className="muted-note">No MusicBrainz match.</p> : null}
{search?.seed && ( {search?.seed ? (
<ul> <ul className="list">
{search.similar.map((s) => ( {search.similar.map((s) => (
<li key={s.mbid}> <li key={s.mbid} className="list-row">
<a href={`/discover/artist/${s.mbid}?name=${encodeURIComponent(s.name)}`}>{s.name}</a>{" "} <div className="main">
<small>({s.score.toFixed(1)})</small>{" "} <div className="rtitle">
<a href={`/discover/artist/${s.mbid}?name=${encodeURIComponent(s.name)}`}>{s.name}</a>
</div>
<div className="rmeta">
<span className="score">similarity {s.score.toFixed(1)}</span>
</div>
</div>
<div className="actions">
{followedSimilar.has(s.mbid) ? ( {followedSimilar.has(s.mbid) ? (
<span>Followed</span> <span className="following">Following</span>
) : ( ) : (
<button onClick={() => followSimilar(s)}>Follow</button> <button className="btn sm" onClick={() => followSimilar(s)}>
Follow
</button>
)} )}
</div>
</li> </li>
))} ))}
</ul> </ul>
)} ) : null}
</section>
<section> <SectionHeader title="Suggested artists" note={`${feed.artists.length}`} />
<h2>Suggested artists ({feed.artists.length})</h2> {feed.artists.length === 0 ? (
<ul> <p className="muted-note">No suggestions yet run Discover, or follow a few artists to seed it.</p>
) : (
<ul className="list">
{feed.artists.map((s) => ( {feed.artists.map((s) => (
<li key={s.id}> <li key={s.id} className="list-row">
<strong> <div className="main">
<a href={`/discover/artist/${s.artistMbid}?name=${encodeURIComponent(s.artistName)}`}> <div className="rtitle">
{s.artistName} <a href={`/discover/artist/${s.artistMbid}?name=${encodeURIComponent(s.artistName)}`}>{s.artistName}</a>
</a> </div>
</strong>{" "} <div className="rmeta">
<small>score {s.score.toFixed(2)} · {s.seedCount} seed(s) · {s.sources.join(", ")}</small>{" "} <span className="score">score {s.score.toFixed(2)}</span>
<button onClick={() => act(s.id, "follow")}>Follow</button>{" "} <span className="dot">·</span>
<button onClick={() => act(s.id, "dismiss")}>Dismiss</button> <span>{s.seedCount} seed{s.seedCount === 1 ? "" : "s"}</span>
<span className="dot">·</span>
<span>{s.sources.join(", ")}</span>
</div>
</div>
<div className="actions">
<button className="btn sm" onClick={() => act(s.id, "follow")}>
Follow
</button>
<button className="btn sm ghost" onClick={() => act(s.id, "dismiss")}>
Dismiss
</button>
</div>
</li> </li>
))} ))}
</ul> </ul>
</section> )}
<section> <SectionHeader title="Suggested albums" note={`${feed.albums.length}`} />
<h2>Suggested albums ({feed.albums.length})</h2> {feed.albums.length === 0 ? (
<ul> <p className="muted-note">No album suggestions yet.</p>
) : (
<ul className="list">
{feed.albums.map((s) => ( {feed.albums.map((s) => (
<li key={s.id}> <li key={s.id} className="list-row">
<strong> <div className="main">
<a <div className="rtitle">
href={`/discover/artist/${s.artistMbid}?name=${encodeURIComponent(s.artistName)}#rg-${s.rgMbid}`} <a href={`/discover/artist/${s.artistMbid}?name=${encodeURIComponent(s.artistName)}#rg-${s.rgMbid}`}>
>
{s.album} {s.album}
</a> </a>{" "}
</strong>{" "} <span className="artist">· {s.artistName}</span>
{s.artistName}{" "} </div>
<small>score {s.score.toFixed(2)}</small>{" "} <div className="rmeta">
<button onClick={() => act(s.id, "want")}>Want</button>{" "} <span className="score">score {s.score.toFixed(2)}</span>
<button onClick={() => act(s.id, "dismiss")}>Dismiss</button> </div>
</div>
<div className="actions">
<button className="btn sm" onClick={() => act(s.id, "want")}>
Want
</button>
<button className="btn sm ghost" onClick={() => act(s.id, "dismiss")}>
Dismiss
</button>
</div>
</li> </li>
))} ))}
</ul> </ul>
</section> )}
</div> </div>
); );
} }
+1 -10
View File
@@ -1,14 +1,5 @@
import { DiscoverClient } from "./discover-client"; import { DiscoverClient } from "./discover-client";
export default function DiscoverPage() { export default function DiscoverPage() {
return ( return <DiscoverClient />;
<main>
<h1>Discover</h1>
<p>
<a href="/">Home</a> · <a href="/artists">Artists</a> · <a href="/wanted">Wanted</a> ·{" "}
<a href="/settings">Settings</a>
</p>
<DiscoverClient />
</main>
);
} }
+1 -7
View File
@@ -1,11 +1,5 @@
import { WantedClient } from "./wanted-client"; import { WantedClient } from "./wanted-client";
export default function WantedPage() { export default function WantedPage() {
return ( return <WantedClient />;
<main>
<h1>Wanted</h1>
<p><a href="/">Home</a> · <a href="/artists">Artists</a></p>
<WantedClient />
</main>
);
} }
+43 -9
View File
@@ -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 Wanted = { type Wanted = {
id: string; id: string;
@@ -11,6 +13,11 @@ type Wanted = {
lastSearchedAt: string | null; lastSearchedAt: 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() { export function WantedClient() {
const [rows, setRows] = useState<Wanted[]>([]); const [rows, setRows] = useState<Wanted[]>([]);
const [artist, setArtist] = useState(""); const [artist, setArtist] = useState("");
@@ -35,7 +42,7 @@ export function WantedClient() {
body: JSON.stringify({ artist, album }), body: JSON.stringify({ artist, album }),
}); });
if (!res.ok) { if (!res.ok) {
setError((await res.json()).error ?? "failed to add"); setError((await res.json()).error ?? "Couldnt add that — no MusicBrainz match?");
return; return;
} }
setArtist(""); setArtist("");
@@ -50,21 +57,48 @@ export function WantedClient() {
return ( return (
<div> <div>
<form onSubmit={add}> <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)} /> <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)} /> <input aria-label="wanted album" placeholder="Album" value={album} onChange={(e) => setAlbum(e.target.value)} />
<button type="submit">Add to wanted</button> </label>
{error ? <span> {error}</span> : null} <button type="submit" className="btn accent">
Add to wanted
</button>
{error ? <span className="notice">{error}</span> : null}
</form> </form>
<ul>
<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) => ( {rows.map((w) => (
<li key={w.id}> <li key={w.id} className="list-row">
{w.artistName} {w.album} · <em>{w.currentQualityClass !== null ? `have q${w.currentQualityClass}, upgrading` : w.state}</em> <div className="main">
{w.lastSearchedAt ? ` · last tried ${new Date(w.lastSearchedAt).toLocaleString()}` : " · never tried"}{" "} <div className="rtitle">
<button onClick={() => searchNow(w)}>Search now</button> {w.album} <span className="artist">· {w.artistName}</span>
</div>
<div className="rmeta">
<span>{w.lastSearchedAt ? `last tried ${new Date(w.lastSearchedAt).toLocaleDateString()}` : "never tried"}</span>
</div>
</div>
<div className="actions">
<span className="chip">{stateLabel(w)}</span>
<button className="btn sm ghost" onClick={() => searchNow(w)}>
Search now
</button>
</div>
</li> </li>
))} ))}
</ul> </ul>
)}
</div> </div>
); );
} }