From 1fbe58013f71bab713dbd2255a1ae5b933efe08a Mon Sep 17 00:00:00 2001 From: Jonathan Date: Sun, 12 Jul 2026 15:29:46 +0200 Subject: [PATCH] =?UTF-8?q?feat(web):=20discovery=20modals=20=E2=80=94=20a?= =?UTF-8?q?rtist=20+=20album,=20not=20preview=20navigation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking a suggested (or find-similar) artist opens an ArtistModal: Follow + a cover-art grid of their releases with per-album Want (reuses the preview endpoint), plus a "Full page" link to the deep-linkable preview route. Clicking a suggested album opens the AlbumModal (tracklist + Want). Lighter than jumping to a full preview page. Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/app/_ui/artist-modal.tsx | 135 +++++++++++++++++++++++ web/src/app/design.css | 10 ++ web/src/app/discover/discover-client.tsx | 37 ++++++- 3 files changed, 178 insertions(+), 4 deletions(-) create mode 100644 web/src/app/_ui/artist-modal.tsx diff --git a/web/src/app/_ui/artist-modal.tsx b/web/src/app/_ui/artist-modal.tsx new file mode 100644 index 0000000..74ba3ea --- /dev/null +++ b/web/src/app/_ui/artist-modal.tsx @@ -0,0 +1,135 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Modal } from "./modal"; +import { CoverArt } from "./cover-art"; + +type Rel = { + rgMbid: string; + album: string; + primaryType: string | null; + secondaryTypes: string[]; + firstReleaseDate: string | null; + monitored: boolean; + have: boolean; +}; +type Preview = { artistName: string; followed: boolean; releases: Rel[] }; + +/** Lightweight artist preview in a modal: follow + a cover-art grid of their releases with + * per-album Want. Reuses the discovery preview endpoint. The full preview page stays for + * deep links (linked here as "Full page"). */ +export function ArtistModal({ + open, + onClose, + mbid, + initialName, +}: { + open: boolean; + onClose: () => void; + mbid: string; + initialName: string; +}) { + const [data, setData] = useState("loading"); + const [followed, setFollowed] = useState(false); + const [wanted, setWanted] = useState>(new Set()); + + useEffect(() => { + if (!open) return; + setData("loading"); + setWanted(new Set()); + let cancelled = false; + const qs = new URLSearchParams(); + if (initialName) qs.set("name", initialName); + fetch(`/api/discover/preview/${mbid}?${qs.toString()}`) + .then((r) => (r.ok ? r.json() : Promise.reject())) + .then((d: Preview) => { + if (cancelled) return; + setData(d); + setFollowed(d.followed); + }) + .catch(() => !cancelled && setData("error")); + return () => { + cancelled = true; + }; + }, [open, mbid, initialName]); + + const name = typeof data === "object" ? data.artistName || initialName : initialName; + + async function follow() { + const res = await fetch("/api/artists", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ mbid, name }), + }); + if (res.ok || res.status === 409) setFollowed(true); + } + + async function want(r: Rel) { + const res = await fetch("/api/discover/want", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + rgMbid: r.rgMbid, + artistMbid: mbid, + artistName: name, + album: r.album, + primaryType: r.primaryType, + secondaryTypes: r.secondaryTypes, + firstReleaseDate: r.firstReleaseDate, + }), + }); + if (res.ok) setWanted((s) => new Set(s).add(r.rgMbid)); + } + + return ( + +
+

{name || "Artist"}

+
+ {followed ? Following : ( + + )} + Full page ↗ +
+
+ + {data === "loading" ?

Loading…

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

Couldn’t load this artist.

: null} + {typeof data === "object" ? ( + data.releases.length === 0 ? ( +

No releases found.

+ ) : ( +
+ {data.releases.slice(0, 18).map((r) => { + const owned = r.have; + const monitored = r.monitored || wanted.has(r.rgMbid); + return ( +
+ +
{r.album}
+
+ {r.primaryType} + {r.firstReleaseDate ? ` · ${r.firstReleaseDate.slice(0, 4)}` : ""} +
+
+ {owned ? ( + In library + ) : monitored ? ( + Wanted + ) : ( + + )} +
+
+ ); + })} +
+ ) + ) : null} +
+ ); +} diff --git a/web/src/app/design.css b/web/src/app/design.css index 097ebe0..f70041c 100644 --- a/web/src/app/design.css +++ b/web/src/app/design.css @@ -322,3 +322,13 @@ nav.contents .sep { flex: 1; } .ac-title { font-size: 0.98rem; line-height: 1.25; } .ac-artist { color: var(--graphite); font-size: 0.86rem; margin-top: 1px; } .ac-meta { font-family: var(--mono); font-size: 0.6rem; letter-spacing: 0.1em; text-transform: uppercase; color: var(--graphite); margin-top: 5px; } + +/* ── Link-styled button + artist modal ────────────────── */ +.linkish { background: transparent; border: 0; padding: 0; cursor: pointer; color: inherit; font: inherit; text-align: left; } +.linkish:hover { text-decoration: underline; text-decoration-color: var(--accent); text-underline-offset: 3px; } +.artist-modal-head { margin-bottom: 18px; } +.artist-modal-actions { margin-top: 10px; align-items: center; } +.artist-modal-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 20px 14px; max-height: 56vh; overflow-y: auto; padding-top: 4px; } +.am-rel .cover { margin-bottom: 7px; } +.am-rel .ac-title { font-size: 0.9rem; } +.am-rel-action { margin-top: 7px; } diff --git a/web/src/app/discover/discover-client.tsx b/web/src/app/discover/discover-client.tsx index dc1f1c4..652dc73 100644 --- a/web/src/app/discover/discover-client.tsx +++ b/web/src/app/discover/discover-client.tsx @@ -3,6 +3,8 @@ import { useCallback, useEffect, useState } from "react"; import { PageHead } from "../_ui/page-head"; import { SectionHeader } from "../_ui/section-header"; +import { ArtistModal } from "../_ui/artist-modal"; +import { AlbumModal } from "../_ui/album-modal"; type Suggestion = { id: string; @@ -29,6 +31,8 @@ export function DiscoverClient() { const [search, setSearch] = useState(null); const [busy, setBusy] = useState(false); const [followedSimilar, setFollowedSimilar] = useState>(new Set()); + const [artistModal, setArtistModal] = useState<{ mbid: string; name: string } | null>(null); + const [albumModal, setAlbumModal] = useState(null); const loadFeed = useCallback(async () => { setFeed(await (await fetch("/api/discover")).json()); @@ -121,7 +125,9 @@ export function DiscoverClient() {
  • - {s.name} +
    similarity {s.score.toFixed(1)} @@ -150,7 +156,9 @@ export function DiscoverClient() {
  • - {s.artistName} +
    score {s.score.toFixed(2)} @@ -182,9 +190,9 @@ export function DiscoverClient() {
  • - + {" "} · {s.artistName}
    @@ -203,6 +211,27 @@ export function DiscoverClient() { ))} )} + {artistModal ? ( + setArtistModal(null)} mbid={artistModal.mbid} initialName={artistModal.name} /> + ) : null} + {albumModal ? ( + setAlbumModal(null)} + album={{ rgMbid: albumModal.rgMbid, album: albumModal.album ?? "", artist: albumModal.artistName, year: null, type: null }} + actions={ + + } + /> + ) : null}
    ); }