diff --git a/web/src/app/_ui/page-head.tsx b/web/src/app/_ui/page-head.tsx new file mode 100644 index 0000000..705576f --- /dev/null +++ b/web/src/app/_ui/page-head.tsx @@ -0,0 +1,8 @@ +export function PageHead({ title, eyebrow }: { title: string; eyebrow?: string }) { + return ( +
+ {eyebrow ?

{eyebrow}

: null} +

{title}

+
+ ); +} diff --git a/web/src/app/artists/[id]/discography-client.tsx b/web/src/app/artists/[id]/discography-client.tsx index ac16328..6b7c4b4 100644 --- a/web/src/app/artists/[id]/discography-client.tsx +++ b/web/src/app/artists/[id]/discography-client.tsx @@ -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

Loading…

; + if (!detail) return

Loading…

; return (
-

{detail.name}

- -
); diff --git a/web/src/app/artists/[id]/page.tsx b/web/src/app/artists/[id]/page.tsx index 33339b6..610dcb3 100644 --- a/web/src/app/artists/[id]/page.tsx +++ b/web/src/app/artists/[id]/page.tsx @@ -2,10 +2,5 @@ import { DiscographyClient } from "./discography-client"; export default async function ArtistDetailPage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; - return ( -
-

← Artists

- -
- ); + return ; } diff --git a/web/src/app/artists/artists-client.tsx b/web/src/app/artists/artists-client.tsx index 729ab4d..5b78757 100644 --- a/web/src/app/artists/artists-client.tsx +++ b/web/src/app/artists/artists-client.tsx @@ -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 (
-
- setQuery(e.target.value)} /> - + + + + + + {searching ? searching… : null} - {searching ?

Searching…

: null} + {hits.length > 0 ? ( -
    - {hits.map((h) => ( -
  • - {h.name} - {h.disambiguation ? ` (${h.disambiguation})` : ""}{" "} - {followedMbids.has(h.mbid) ? ( - · following - ) : ( - - )} + <> + +
      + {hits.map((h) => ( +
    • +
      +
      + {h.name} + {h.disambiguation ? — {h.disambiguation} : null} +
      +
      +
      + {followedMbids.has(h.mbid) ? ( + Following + ) : ( + + )} +
      +
    • + ))} +
    + + ) : null} + + + {artists.length === 0 ? ( +

    Not following anyone yet. Search above and follow an artist to start watching their releases.

    + ) : ( +
      + {artists.map((a) => ( +
    • +
      +
      + {a.name} +
      +
      + + {a.haveCount} have · {a.monitoredCount} monitored · {a.releaseCount} releases + +
      +
      +
      + + +
    • ))}
    - ) : null} - -
      - {artists.map((a) => ( -
    • - {a.name} · {a.haveCount}/{a.monitoredCount} have/monitored of {a.releaseCount}{" "} - {" "} - -
    • - ))} -
    + )}
); } diff --git a/web/src/app/artists/page.tsx b/web/src/app/artists/page.tsx index 3c12d26..f1ec8b0 100644 --- a/web/src/app/artists/page.tsx +++ b/web/src/app/artists/page.tsx @@ -1,11 +1,5 @@ import { ArtistsClient } from "./artists-client"; export default function ArtistsPage() { - return ( -
-

Watched artists

-

Home · Wanted

- -
- ); + return ; } diff --git a/web/src/app/design.css b/web/src/app/design.css index a688c51..3d6b5ca 100644 --- a/web/src/app/design.css +++ b/web/src/app/design.css @@ -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); } diff --git a/web/src/app/discover/artist/[mbid]/page.tsx b/web/src/app/discover/artist/[mbid]/page.tsx index 057c0ba..2782718 100644 --- a/web/src/app/discover/artist/[mbid]/page.tsx +++ b/web/src/app/discover/artist/[mbid]/page.tsx @@ -9,12 +9,5 @@ export default async function PreviewPage({ }) { const { mbid } = await params; const { name } = await searchParams; - return ( -
-

- ← Discover · Artists -

- -
- ); + return ; } diff --git a/web/src/app/discover/artist/[mbid]/preview-client.tsx b/web/src/app/discover/artist/[mbid]/preview-client.tsx index 5261857..b730420 100644 --- a/web/src/app/discover/artist/[mbid]/preview-client.tsx +++ b/web/src/app/discover/artist/[mbid]/preview-client.tsx @@ -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 ( -

- Couldn’t load this artist. +

+ Couldn’t load this artist.{" "} +

); - if (!data) return

Loading…

; + if (!data) return

Loading…

; return (
-

{data.artistName || "Artist"}

-

- {followed ? Following : } - {" · "} + + +

+ {followed ? ( + Following + ) : ( + + )} MusicBrainz ↗ -

- -
    - {data.releases.map((r) => ( -
  • - {r.album}{" "} - - {r.primaryType} - {r.firstReleaseDate ? ` · ${r.firstReleaseDate.slice(0, 4)}` : ""} - {" "} - {r.have ? In library : r.monitored ? Monitored : null}{" "} - {" "} - - {tracks[r.rgMbid] === "loading" &&

    Loading tracks…

    } - {tracks[r.rgMbid] === "error" &&

    Couldn’t load tracks.

    } - {Array.isArray(tracks[r.rgMbid]) && ( -
      - {(tracks[r.rgMbid] as Track[]).map((t) => ( -
    1. - {t.title} {t.lengthMs != null && ({fmt(t.lengthMs)})} -
    2. - ))} -
    - )} -
  • - ))} + +
+ +
    + {data.releases.map((r) => { + const t = tracks[r.rgMbid]; + return ( +
  • +
    +
    {r.album}
    +
    + {r.primaryType ? {r.primaryType} : null} + {r.firstReleaseDate ? ( + <> + · + {r.firstReleaseDate.slice(0, 4)} + + ) : null} +
    +
    +
    + {r.have ? In library : r.monitored ? Monitored : null} + + +
    + {t === "loading" ?

    Loading tracks…

    : null} + {t === "error" ?

    Couldn’t load tracks.

    : null} + {Array.isArray(t) ? ( +
      + {t.map((tr) => ( +
    1. + {tr.position} + {tr.title} + {tr.lengthMs != null ? {fmt(tr.lengthMs)} : null} +
    2. + ))} +
    + ) : null} +
  • + ); + })}
); diff --git a/web/src/app/discover/discover-client.tsx b/web/src/app/discover/discover-client.tsx index 6211d96..dc1f1c4 100644 --- a/web/src/app/discover/discover-client.tsx +++ b/web/src/app/discover/discover-client.tsx @@ -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 (
-
- - {result && Last run: {result}} -
+ {result ? last run · {result} : null} +
-
-

Seed search

-
- setQuery(e.target.value)} - placeholder="Artist name…" - aria-label="Seed artist" - /> - -
- {search && !search.seed &&

No MusicBrainz match.

} - {search?.seed && ( -
    - {search.similar.map((s) => ( -
  • - {s.name}{" "} - ({s.score.toFixed(1)}){" "} + +
    + + +
    + {search && !search.seed ?

    No MusicBrainz match.

    : null} + {search?.seed ? ( +
      + {search.similar.map((s) => ( +
    • +
      +
      + {s.name} +
      +
      + similarity {s.score.toFixed(1)} +
      +
      +
      {followedSimilar.has(s.mbid) ? ( - Followed + Following ) : ( - + )} -
    • - ))} -
    - )} -
+ + + ))} + + ) : null} -
-

Suggested artists ({feed.artists.length})

-
    + + {feed.artists.length === 0 ? ( +

    No suggestions yet — run Discover, or follow a few artists to seed it.

    + ) : ( +
      {feed.artists.map((s) => ( -
    • - - - {s.artistName} - - {" "} - score {s.score.toFixed(2)} · {s.seedCount} seed(s) · {s.sources.join(", ")}{" "} - {" "} - +
    • +
      + +
      + score {s.score.toFixed(2)} + · + {s.seedCount} seed{s.seedCount === 1 ? "" : "s"} + · + {s.sources.join(", ")} +
      +
      +
      + + +
    • ))}
    -
+ )} -
-

Suggested albums ({feed.albums.length})

-
    + + {feed.albums.length === 0 ? ( +

    No album suggestions yet.

    + ) : ( +
      {feed.albums.map((s) => ( -
    • - - - {s.album} - - {" "} - — {s.artistName}{" "} - score {s.score.toFixed(2)}{" "} - {" "} - +
    • +
      +
      + + {s.album} + {" "} + · {s.artistName} +
      +
      + score {s.score.toFixed(2)} +
      +
      +
      + + +
    • ))}
    -
+ )} ); } diff --git a/web/src/app/discover/page.tsx b/web/src/app/discover/page.tsx index 6990e86..ab5b50e 100644 --- a/web/src/app/discover/page.tsx +++ b/web/src/app/discover/page.tsx @@ -1,14 +1,5 @@ import { DiscoverClient } from "./discover-client"; export default function DiscoverPage() { - return ( -
-

Discover

-

- Home · Artists · Wanted ·{" "} - Settings -

- -
- ); + return ; } diff --git a/web/src/app/wanted/page.tsx b/web/src/app/wanted/page.tsx index 34b9aa1..43aec7a 100644 --- a/web/src/app/wanted/page.tsx +++ b/web/src/app/wanted/page.tsx @@ -1,11 +1,5 @@ import { WantedClient } from "./wanted-client"; export default function WantedPage() { - return ( -
-

Wanted

-

Home · Artists

- -
- ); + return ; } diff --git a/web/src/app/wanted/wanted-client.tsx b/web/src/app/wanted/wanted-client.tsx index ef9e32b..5dbbe70 100644 --- a/web/src/app/wanted/wanted-client.tsx +++ b/web/src/app/wanted/wanted-client.tsx @@ -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([]); 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 ?? "Couldn’t add that — no MusicBrainz match?"); return; } setArtist(""); @@ -50,21 +57,48 @@ export function WantedClient() { return (
-
- setArtist(e.target.value)} /> - setAlbum(e.target.value)} /> - - {error ? {error} : null} + + + + + + + {error ? {error} : null} -
    - {rows.map((w) => ( -
  • - {w.artistName} — {w.album} · {w.currentQualityClass !== null ? `have q${w.currentQualityClass}, upgrading` : w.state} - {w.lastSearchedAt ? ` · last tried ${new Date(w.lastSearchedAt).toLocaleString()}` : " · never tried"}{" "} - -
  • - ))} -
+ + + {rows.length === 0 ? ( +

Nothing wanted yet. Add a release above, or send one over from an artist’s discography.

+ ) : ( +
    + {rows.map((w) => ( +
  • +
    +
    + {w.album} · {w.artistName} +
    +
    + {w.lastSearchedAt ? `last tried ${new Date(w.lastSearchedAt).toLocaleDateString()}` : "never tried"} +
    +
    +
    + {stateLabel(w)} + +
    +
  • + ))} +
+ )}
); }