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:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
|||||||
@@ -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">
|
||||||
Couldn’t load this artist. <button onClick={load}>Retry</button>
|
Couldn’t 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>Couldn’t 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%" }}>Couldn’t 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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,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,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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ?? "Couldn’t 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 artist’s 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user