feat(web): discovery modals — artist + album, not preview navigation
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<Preview | "loading" | "error">("loading");
|
||||||
|
const [followed, setFollowed] = useState(false);
|
||||||
|
const [wanted, setWanted] = useState<Set<string>>(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 (
|
||||||
|
<Modal open={open} onClose={onClose} title="Artist" wide>
|
||||||
|
<div className="artist-modal-head">
|
||||||
|
<h2 className="album-modal-title">{name || "Artist"}</h2>
|
||||||
|
<div className="actions artist-modal-actions">
|
||||||
|
{followed ? <span className="following">Following</span> : (
|
||||||
|
<button className="btn accent sm" onClick={follow}>
|
||||||
|
Follow
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<a href={`/discover/artist/${mbid}?name=${encodeURIComponent(name)}`}>Full page ↗</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data === "loading" ? <p className="muted-note">Loading…</p> : null}
|
||||||
|
{data === "error" ? <p className="muted-note">Couldn’t load this artist.</p> : null}
|
||||||
|
{typeof data === "object" ? (
|
||||||
|
data.releases.length === 0 ? (
|
||||||
|
<p className="muted-note">No releases found.</p>
|
||||||
|
) : (
|
||||||
|
<div className="artist-modal-grid">
|
||||||
|
{data.releases.slice(0, 18).map((r) => {
|
||||||
|
const owned = r.have;
|
||||||
|
const monitored = r.monitored || wanted.has(r.rgMbid);
|
||||||
|
return (
|
||||||
|
<div key={r.rgMbid} className="am-rel">
|
||||||
|
<CoverArt rgMbid={r.rgMbid} alt={r.album} />
|
||||||
|
<div className="ac-title">{r.album}</div>
|
||||||
|
<div className="ac-meta">
|
||||||
|
{r.primaryType}
|
||||||
|
{r.firstReleaseDate ? ` · ${r.firstReleaseDate.slice(0, 4)}` : ""}
|
||||||
|
</div>
|
||||||
|
<div className="am-rel-action">
|
||||||
|
{owned ? (
|
||||||
|
<span className="chip done">In library</span>
|
||||||
|
) : monitored ? (
|
||||||
|
<span className="chip working">Wanted</span>
|
||||||
|
) : (
|
||||||
|
<button className="btn sm" onClick={() => want(r)}>
|
||||||
|
Want
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -322,3 +322,13 @@ nav.contents .sep { flex: 1; }
|
|||||||
.ac-title { font-size: 0.98rem; line-height: 1.25; }
|
.ac-title { font-size: 0.98rem; line-height: 1.25; }
|
||||||
.ac-artist { color: var(--graphite); font-size: 0.86rem; margin-top: 1px; }
|
.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; }
|
.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; }
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { PageHead } from "../_ui/page-head";
|
import { PageHead } from "../_ui/page-head";
|
||||||
import { SectionHeader } from "../_ui/section-header";
|
import { SectionHeader } from "../_ui/section-header";
|
||||||
|
import { ArtistModal } from "../_ui/artist-modal";
|
||||||
|
import { AlbumModal } from "../_ui/album-modal";
|
||||||
|
|
||||||
type Suggestion = {
|
type Suggestion = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -29,6 +31,8 @@ export function DiscoverClient() {
|
|||||||
const [search, setSearch] = useState<SearchResult | null>(null);
|
const [search, setSearch] = useState<SearchResult | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [followedSimilar, setFollowedSimilar] = useState<Set<string>>(new Set());
|
const [followedSimilar, setFollowedSimilar] = useState<Set<string>>(new Set());
|
||||||
|
const [artistModal, setArtistModal] = useState<{ mbid: string; name: string } | null>(null);
|
||||||
|
const [albumModal, setAlbumModal] = useState<Suggestion | null>(null);
|
||||||
|
|
||||||
const loadFeed = useCallback(async () => {
|
const loadFeed = useCallback(async () => {
|
||||||
setFeed(await (await fetch("/api/discover")).json());
|
setFeed(await (await fetch("/api/discover")).json());
|
||||||
@@ -121,7 +125,9 @@ export function DiscoverClient() {
|
|||||||
<li key={s.mbid} className="list-row">
|
<li key={s.mbid} className="list-row">
|
||||||
<div className="main">
|
<div className="main">
|
||||||
<div className="rtitle">
|
<div className="rtitle">
|
||||||
<a href={`/discover/artist/${s.mbid}?name=${encodeURIComponent(s.name)}`}>{s.name}</a>
|
<button className="linkish" onClick={() => setArtistModal({ mbid: s.mbid, name: s.name })}>
|
||||||
|
{s.name}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="rmeta">
|
<div className="rmeta">
|
||||||
<span className="score">similarity {s.score.toFixed(1)}</span>
|
<span className="score">similarity {s.score.toFixed(1)}</span>
|
||||||
@@ -150,7 +156,9 @@ export function DiscoverClient() {
|
|||||||
<li key={s.id} className="list-row">
|
<li key={s.id} className="list-row">
|
||||||
<div className="main">
|
<div className="main">
|
||||||
<div className="rtitle">
|
<div className="rtitle">
|
||||||
<a href={`/discover/artist/${s.artistMbid}?name=${encodeURIComponent(s.artistName)}`}>{s.artistName}</a>
|
<button className="linkish" onClick={() => setArtistModal({ mbid: s.artistMbid, name: s.artistName })}>
|
||||||
|
{s.artistName}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="rmeta">
|
<div className="rmeta">
|
||||||
<span className="score">score {s.score.toFixed(2)}</span>
|
<span className="score">score {s.score.toFixed(2)}</span>
|
||||||
@@ -182,9 +190,9 @@ export function DiscoverClient() {
|
|||||||
<li key={s.id} className="list-row">
|
<li key={s.id} className="list-row">
|
||||||
<div className="main">
|
<div className="main">
|
||||||
<div className="rtitle">
|
<div className="rtitle">
|
||||||
<a href={`/discover/artist/${s.artistMbid}?name=${encodeURIComponent(s.artistName)}#rg-${s.rgMbid}`}>
|
<button className="linkish" onClick={() => setAlbumModal(s)}>
|
||||||
{s.album}
|
{s.album}
|
||||||
</a>{" "}
|
</button>{" "}
|
||||||
<span className="artist">· {s.artistName}</span>
|
<span className="artist">· {s.artistName}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="rmeta">
|
<div className="rmeta">
|
||||||
@@ -203,6 +211,27 @@ export function DiscoverClient() {
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
{artistModal ? (
|
||||||
|
<ArtistModal open onClose={() => setArtistModal(null)} mbid={artistModal.mbid} initialName={artistModal.name} />
|
||||||
|
) : null}
|
||||||
|
{albumModal ? (
|
||||||
|
<AlbumModal
|
||||||
|
open
|
||||||
|
onClose={() => setAlbumModal(null)}
|
||||||
|
album={{ rgMbid: albumModal.rgMbid, album: albumModal.album ?? "", artist: albumModal.artistName, year: null, type: null }}
|
||||||
|
actions={
|
||||||
|
<button
|
||||||
|
className="btn sm"
|
||||||
|
onClick={() => {
|
||||||
|
act(albumModal.id, "want");
|
||||||
|
setAlbumModal(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Want
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user