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";
import { useEffect, useState } from "react";
import { PageHead } from "../../_ui/page-head";
type Release = {
id: string;
@@ -14,10 +15,10 @@ type Release = {
};
type Detail = { id: string; name: string; autoMonitorFuture: boolean; showAllTypes: boolean; releases: Release[] };
function badge(r: Release): string {
if (r.currentQualityClass !== null) return `Have (q${r.currentQualityClass})`;
if (r.monitored) return r.state === "wanted" ? "Wanted" : r.state;
return "Dormant";
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 }) {
@@ -53,32 +54,56 @@ export function DiscographyClient({ id }: { id: string }) {
refresh();
}
if (!detail) return <p>Loading</p>;
if (!detail) return <p className="empty">Loading</p>;
return (
<div>
<h1>{detail.name}</h1>
<label>
<PageHead title={detail.name} eyebrow="Discography" />
<div className="tool-row">
<label className="toggle">
<input
type="checkbox"
aria-label="show all release types"
checked={detail.showAllTypes}
onChange={toggleShowAll}
/>{" "}
Show all release types (live, compilations, singles)
/>
Show all types (live, compilations, singles)
</label>
<ul>
{detail.releases.map((r) => (
<li key={r.id}>
<strong>{r.album}</strong>
{r.firstReleaseDate ? ` (${r.firstReleaseDate.slice(0, 4)})` : ""}
{r.primaryType ? ` · ${r.primaryType}` : ""}
{r.secondaryTypes.length ? ` [${r.secondaryTypes.join(", ")}]` : ""} · <em>{badge(r)}</em>{" "}
<label>
<input type="checkbox" aria-label={`monitor ${r.album}`} checked={r.monitored} onChange={() => toggleMonitor(r)} /> monitor
</label>{" "}
<button onClick={() => searchNow(r)}>Search now</button>
</div>
<ul className="list">
{detail.releases.map((r) => {
const b = badge(r);
return (
<li key={r.id} className="list-row">
<div className="main">
<div className="rtitle">
{r.album}
{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>
))}
);
})}
</ul>
</div>
);
+1 -6
View File
@@ -2,10 +2,5 @@ import { DiscographyClient } from "./discography-client";
export default async function ArtistDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return (
<main>
<p><a href="/artists"> Artists</a></p>
<DiscographyClient id={id} />
</main>
);
return <DiscographyClient id={id} />;
}
+66 -16
View File
@@ -1,6 +1,8 @@
"use client";
import { useEffect, useState } from "react";
import { PageHead } from "../_ui/page-head";
import { SectionHeader } from "../_ui/section-header";
type Artist = {
id: string;
@@ -68,38 +70,86 @@ export function ArtistsClient() {
return (
<div>
<form onSubmit={search}>
<input aria-label="artist search" placeholder="Search an artist…" value={query} onChange={(e) => setQuery(e.target.value)} />
<button type="submit">Search</button>
<PageHead title="Artists" eyebrow="The roster · follow & watch" />
<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>
{searching ? <p>Searching</p> : null}
{hits.length > 0 ? (
<ul>
<>
<SectionHeader title="Search results" note={`${hits.length} found`} />
<ul className="list">
{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>
{h.disambiguation ? ` (${h.disambiguation})` : ""}{" "}
{h.disambiguation ? <span className="dim"> {h.disambiguation}</span> : null}
</div>
</div>
<div className="actions">
{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>
))}
</ul>
</>
) : 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) => (
<li key={a.id}>
<a href={`/artists/${a.id}`}>{a.name}</a> · {a.haveCount}/{a.monitoredCount} have/monitored of {a.releaseCount}{" "}
<label>
<input type="checkbox" aria-label={`auto-monitor ${a.name}`} checked={a.autoMonitorFuture} onChange={() => toggleAuto(a)} /> auto-monitor future
</label>{" "}
<button onClick={() => unfollow(a)}>Unfollow</button>
<li key={a.id} className="list-row">
<div className="main">
<div className="rtitle">
<a href={`/artists/${a.id}`}>{a.name}</a>
</div>
<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>
))}
</ul>
)}
</div>
);
}
+1 -7
View File
@@ -1,11 +1,5 @@
import { ArtistsClient } from "./artists-client";
export default function ArtistsPage() {
return (
<main>
<h1>Watched artists</h1>
<p><a href="/">Home</a> · <a href="/wanted">Wanted</a></p>
<ArtistsClient />
</main>
);
return <ArtistsClient />;
}
+53
View File
@@ -169,3 +169,56 @@ nav.contents .sep { flex: 1; }
.job { grid-template-columns: 4px 1fr; }
.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 { name } = await searchParams;
return (
<main>
<p>
<a href="/discover"> Discover</a> · <a href="/artists">Artists</a>
</p>
<PreviewClient mbid={mbid} initialName={name ?? ""} />
</main>
);
return <PreviewClient mbid={mbid} initialName={name ?? ""} />;
}
@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { PageHead } from "../../../_ui/page-head";
type Release = {
rgMbid: string;
@@ -105,52 +106,78 @@ export function PreviewClient({ mbid, initialName }: { mbid: string; initialName
if (error)
return (
<p>
Couldnt load this artist. <button onClick={load}>Retry</button>
<p className="empty">
Couldnt load this artist.{" "}
<button className="btn sm" onClick={load}>
Retry
</button>
</p>
);
if (!data) return <p>Loading</p>;
if (!data) return <p className="empty">Loading</p>;
return (
<div>
<h1>{data.artistName || "Artist"}</h1>
<p>
{followed ? <span>Following</span> : <button onClick={follow}>Follow</button>}
{" · "}
<PageHead title={data.artistName || "Artist"} eyebrow="Preview · discography" />
<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">
MusicBrainz
</a>
</p>
<label>
<input type="checkbox" checked={showAll} onChange={(e) => setShowAll(e.target.checked)} /> Show all release
types
<label className="toggle">
<input type="checkbox" checked={showAll} onChange={(e) => setShowAll(e.target.checked)} />
Show all types
</label>
<ul>
{data.releases.map((r) => (
<li key={r.rgMbid} id={`rg-${r.rgMbid}`}>
<strong>{r.album}</strong>{" "}
<small>
{r.primaryType}
{r.firstReleaseDate ? ` · ${r.firstReleaseDate.slice(0, 4)}` : ""}
</small>{" "}
{r.have ? <em>In library</em> : r.monitored ? <em>Monitored</em> : null}{" "}
<button onClick={() => want(r)} disabled={r.have || r.monitored}>
</div>
<ul className="list">
{data.releases.map((r) => {
const t = tracks[r.rgMbid];
return (
<li key={r.rgMbid} id={`rg-${r.rgMbid}`} className="list-row">
<div className="main">
<div className="rtitle">{r.album}</div>
<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
</button>{" "}
<button onClick={() => toggleTracks(r.rgMbid)}>{tracks[r.rgMbid] ? "Hide tracks" : "Tracks"}</button>
{tracks[r.rgMbid] === "loading" && <p>Loading tracks</p>}
{tracks[r.rgMbid] === "error" && <p>Couldnt load tracks.</p>}
{Array.isArray(tracks[r.rgMbid]) && (
<ol>
{(tracks[r.rgMbid] as Track[]).map((t) => (
<li key={t.position}>
{t.title} {t.lengthMs != null && <small>({fmt(t.lengthMs)})</small>}
</button>
<button className="btn sm ghost" onClick={() => toggleTracks(r.rgMbid)}>
{t ? "Hide tracks" : "Tracks"}
</button>
</div>
{t === "loading" ? <p className="muted-note" style={{ flexBasis: "100%" }}>Loading tracks</p> : null}
{t === "error" ? <p className="notice" style={{ flexBasis: "100%" }}>Couldnt load tracks.</p> : null}
{Array.isArray(t) ? (
<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>
))}
</ol>
)}
) : null}
</li>
))}
);
})}
</ul>
</div>
);
+88 -52
View File
@@ -1,6 +1,8 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { PageHead } from "../_ui/page-head";
import { SectionHeader } from "../_ui/section-header";
type Suggestion = {
id: string;
@@ -93,80 +95,114 @@ export function DiscoverClient() {
return (
<div>
<section>
<button onClick={discoverNow} disabled={running}>
<PageHead title="Discover" eyebrow="A&R · similar artists & new releases" />
<div className="tool-row">
<button className="btn accent" onClick={discoverNow} disabled={running}>
{running ? "Discovering…" : "Discover now"}
</button>
{result && <span> Last run: {result}</span>}
</section>
{result ? <span className="rmeta">last run · {result}</span> : null}
</div>
<section>
<h2>Seed search</h2>
<form onSubmit={runSearch}>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Artist name…"
aria-label="Seed artist"
/>
<button type="submit" disabled={busy}>Find similar</button>
<SectionHeader title="Find similar" />
<form className="request-form" onSubmit={runSearch}>
<label className="field">
<span>Seed artist</span>
<input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Artist name…" aria-label="Seed artist" />
</label>
<button type="submit" className="btn" disabled={busy}>
{busy ? "Searching…" : "Find similar"}
</button>
</form>
{search && !search.seed && <p>No MusicBrainz match.</p>}
{search?.seed && (
<ul>
{search && !search.seed ? <p className="muted-note">No MusicBrainz match.</p> : null}
{search?.seed ? (
<ul className="list">
{search.similar.map((s) => (
<li key={s.mbid}>
<a href={`/discover/artist/${s.mbid}?name=${encodeURIComponent(s.name)}`}>{s.name}</a>{" "}
<small>({s.score.toFixed(1)})</small>{" "}
<li key={s.mbid} className="list-row">
<div className="main">
<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) ? (
<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>
))}
</ul>
)}
</section>
) : null}
<section>
<h2>Suggested artists ({feed.artists.length})</h2>
<ul>
<SectionHeader title="Suggested artists" note={`${feed.artists.length}`} />
{feed.artists.length === 0 ? (
<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) => (
<li key={s.id}>
<strong>
<a href={`/discover/artist/${s.artistMbid}?name=${encodeURIComponent(s.artistName)}`}>
{s.artistName}
</a>
</strong>{" "}
<small>score {s.score.toFixed(2)} · {s.seedCount} seed(s) · {s.sources.join(", ")}</small>{" "}
<button onClick={() => act(s.id, "follow")}>Follow</button>{" "}
<button onClick={() => act(s.id, "dismiss")}>Dismiss</button>
<li key={s.id} className="list-row">
<div className="main">
<div className="rtitle">
<a href={`/discover/artist/${s.artistMbid}?name=${encodeURIComponent(s.artistName)}`}>{s.artistName}</a>
</div>
<div className="rmeta">
<span className="score">score {s.score.toFixed(2)}</span>
<span className="dot">·</span>
<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>
))}
</ul>
</section>
)}
<section>
<h2>Suggested albums ({feed.albums.length})</h2>
<ul>
<SectionHeader title="Suggested albums" note={`${feed.albums.length}`} />
{feed.albums.length === 0 ? (
<p className="muted-note">No album suggestions yet.</p>
) : (
<ul className="list">
{feed.albums.map((s) => (
<li key={s.id}>
<strong>
<a
href={`/discover/artist/${s.artistMbid}?name=${encodeURIComponent(s.artistName)}#rg-${s.rgMbid}`}
>
<li key={s.id} className="list-row">
<div className="main">
<div className="rtitle">
<a href={`/discover/artist/${s.artistMbid}?name=${encodeURIComponent(s.artistName)}#rg-${s.rgMbid}`}>
{s.album}
</a>
</strong>{" "}
{s.artistName}{" "}
<small>score {s.score.toFixed(2)}</small>{" "}
<button onClick={() => act(s.id, "want")}>Want</button>{" "}
<button onClick={() => act(s.id, "dismiss")}>Dismiss</button>
</a>{" "}
<span className="artist">· {s.artistName}</span>
</div>
<div className="rmeta">
<span className="score">score {s.score.toFixed(2)}</span>
</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>
))}
</ul>
</section>
)}
</div>
);
}
+1 -10
View File
@@ -1,14 +1,5 @@
import { DiscoverClient } from "./discover-client";
export default function DiscoverPage() {
return (
<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>
);
return <DiscoverClient />;
}
+1 -7
View File
@@ -1,11 +1,5 @@
import { WantedClient } from "./wanted-client";
export default function WantedPage() {
return (
<main>
<h1>Wanted</h1>
<p><a href="/">Home</a> · <a href="/artists">Artists</a></p>
<WantedClient />
</main>
);
return <WantedClient />;
}
+43 -9
View File
@@ -1,6 +1,8 @@
"use client";
import { useEffect, useState } from "react";
import { PageHead } from "../_ui/page-head";
import { SectionHeader } from "../_ui/section-header";
type Wanted = {
id: string;
@@ -11,6 +13,11 @@ type Wanted = {
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() {
const [rows, setRows] = useState<Wanted[]>([]);
const [artist, setArtist] = useState("");
@@ -35,7 +42,7 @@ export function WantedClient() {
body: JSON.stringify({ artist, album }),
});
if (!res.ok) {
setError((await res.json()).error ?? "failed to add");
setError((await res.json()).error ?? "Couldnt add that — no MusicBrainz match?");
return;
}
setArtist("");
@@ -50,21 +57,48 @@ export function WantedClient() {
return (
<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)} />
</label>
<label className="field">
<span>Album</span>
<input aria-label="wanted album" placeholder="Album" value={album} onChange={(e) => setAlbum(e.target.value)} />
<button type="submit">Add to wanted</button>
{error ? <span> {error}</span> : null}
</label>
<button type="submit" className="btn accent">
Add to wanted
</button>
{error ? <span className="notice">{error}</span> : null}
</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) => (
<li key={w.id}>
{w.artistName} {w.album} · <em>{w.currentQualityClass !== null ? `have q${w.currentQualityClass}, upgrading` : w.state}</em>
{w.lastSearchedAt ? ` · last tried ${new Date(w.lastSearchedAt).toLocaleString()}` : " · never tried"}{" "}
<button onClick={() => searchNow(w)}>Search now</button>
<li key={w.id} className="list-row">
<div className="main">
<div className="rtitle">
{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>
))}
</ul>
)}
</div>
);
}