diff --git a/web/src/app/design.css b/web/src/app/design.css index c738932..7d7e992 100644 --- a/web/src/app/design.css +++ b/web/src/app/design.css @@ -286,6 +286,17 @@ nav.contents .sep { flex: 1; } .save-row { display: flex; align-items: center; gap: 14px; margin-top: 8px; } .edit-meta { display: flex; flex-direction: column; gap: 8px; width: 100%; } .rmeta.why { font-style: italic; color: var(--graphite); margin-top: 2px; } + +/* Discover unified row: artist block | inline album thumbnails | actions */ +.disco-row { align-items: flex-start; flex-wrap: wrap; gap: 14px; } +.disco-row .main { flex: 1 1 180px; } +.disco-albums { display: flex; gap: 12px; flex-wrap: wrap; } +.disco-album { display: flex; flex-direction: column; gap: 4px; width: 92px; } +.disco-thumb { display: flex; flex-direction: column; gap: 4px; background: none; border: 0; padding: 0; cursor: pointer; text-align: left; } +.disco-thumb .thumb { width: 92px; height: 92px; } +.disco-album .da-title { font-size: 12px; line-height: 1.2; color: var(--ink); overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; } +.disco-album .badge { font-family: var(--mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--graphite); } +.disco-album .btn.sm { padding: 2px 8px; } .sel-tools { display: inline-flex; align-items: center; gap: 10px; } .bulk-bar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin: 4px 0 12px; padding: 8px 12px; border: 1px solid var(--rule); background: var(--paper-2); } .album-card { position: relative; } diff --git a/web/src/app/discover/discover-client.tsx b/web/src/app/discover/discover-client.tsx index 152e6a5..94ef3e9 100644 --- a/web/src/app/discover/discover-client.tsx +++ b/web/src/app/discover/discover-client.tsx @@ -4,23 +4,28 @@ import { useCallback, useEffect, useState } from "react"; import { PageHead } from "../_ui/page-head"; import { timeAgo } from "../_ui/status"; import { SectionHeader } from "../_ui/section-header"; -import { ArtistModal } from "../_ui/artist-modal"; import { AlbumModal } from "../_ui/album-modal"; import { CoverArt } from "../_ui/cover-art"; import { toast } from "../_ui/toast"; -type Suggestion = { +type Album = { id: string; - artistMbid: string; - artistName: string; rgMbid: string | null; album: string | null; primaryType: string | null; secondaryTypes: string[]; + firstReleaseDate: string | null; + albumReason: string | null; +}; +type Row = { + id: string | null; + artistMbid: string; + artistName: string; score: number; seedCount: number; sources: string[]; seeds: string[]; + albums: Album[]; }; /** "Similar to Radiohead, Coldplay +2" — the followed artists that surfaced this suggestion. */ @@ -31,13 +36,12 @@ function similarTo(seeds: string[]): string | null { return `Similar to ${shown}${extra > 0 ? ` +${extra} more` : ""}`; } -type Tab = "Artists" | "Albums"; - -/** Only full-length albums belong in Suggested albums — hide singles/EPs and - * secondary-type releases (live/comp). New sweeps already filter server-side - * (discover.albumsOnly); this also sweeps any pre-filter rows still in the DB. */ -function isFullAlbum(s: Suggestion): boolean { - return (s.primaryType ?? "Album") === "Album" && (s.secondaryTypes?.length ?? 0) === 0; +/** Human badge for why an album is surfaced. */ +function badgeFor(reason: string | null): string | null { + if (reason === "newest") return "Newest"; + if (reason === "popular") return "Most played"; + if (reason === "newest,popular") return "Newest · Most played"; + return null; } /** "just now" / "3h ago" / "2d ago" — timeAgo has no suffix, so add one (except "just now"). */ @@ -46,14 +50,13 @@ function agoLabel(value: string): string { return !a || a === "just now" ? a || "recently" : `${a} ago`; } -type Feed = { artists: Suggestion[]; albums: Suggestion[] }; type SearchResult = { seed: { mbid: string; name: string } | null; similar: { mbid: string; name: string; score: number }[]; }; export function DiscoverClient() { - const [feed, setFeed] = useState({ artists: [], albums: [] }); + const [rows, setRows] = useState([]); const [result, setResult] = useState(null); const [lastRunAt, setLastRunAt] = useState(null); const [running, setRunning] = useState(false); @@ -61,18 +64,14 @@ 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 [tab, setTab] = useState("Artists"); + const [albumModal, setAlbumModal] = useState<{ a: Album; artistName: string; artistMbid: string } | null>(null); - const albums = feed.albums.filter(isFullAlbum); - - const loadFeed = useCallback(async () => { - setFeed(await (await fetch("/api/discover")).json()); + const loadRows = useCallback(async () => { + setRows((await (await fetch("/api/discover")).json()).rows ?? []); }, []); useEffect(() => { - loadFeed(); + loadRows(); fetch("/api/discover/run") .then((r) => r.json()) .then((s: { result: string | null; requested: boolean; inProgress: boolean; lastRunAt: string | null }) => { @@ -80,7 +79,7 @@ export function DiscoverClient() { setLastRunAt(s.lastRunAt); setRunning(s.requested || s.inProgress); }); - }, [loadFeed]); + }, [loadRows]); useEffect(() => { if (!running) return; @@ -89,13 +88,12 @@ export function DiscoverClient() { setResult(s.result); setLastRunAt(s.lastRunAt); if (!s.requested && !s.inProgress) { - // the sweep finished (neither queued nor in progress) — refresh the feed setRunning(false); - loadFeed(); + loadRows(); } }, 2000); return () => clearInterval(id); - }, [running, loadFeed]); + }, [running, loadRows]); async function discoverNow() { await fetch("/api/discover/run", { method: "POST" }); @@ -113,18 +111,26 @@ export function DiscoverClient() { } } - async function followSimilar(a: { mbid: string; name: string }) { + async function followByMbid(mbid: string, name: string) { const res = await fetch("/api/artists", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ mbid: a.mbid, name: a.name }), - }); - if (res.ok || res.status === 409) { + body: JSON.stringify({ mbid, name }), + }).catch(() => null); + return !!res && (res.ok || res.status === 409); + } + + async function followSimilar(a: { mbid: string; name: string }) { + if (await followByMbid(a.mbid, a.name)) { setFollowedSimilar((s) => new Set(s).add(a.mbid)); toast(`Following ${a.name}`); + } else { + toast(`Couldn't follow ${a.name}`); } } + // Suggestion actions key off a suggestion id. A row with no artist-suggestion id (album-only, + // e.g. the artist was dismissed earlier) falls back to a plain follow-by-MBID. async function act(id: string, action: "follow" | "want" | "dismiss") { const res = await fetch(`/api/discover/${id}`, { method: "POST", @@ -136,7 +142,17 @@ export function DiscoverClient() { return; } toast(action === "follow" ? "Following" : action === "want" ? "Added to wanted" : "Dismissed"); - loadFeed(); + loadRows(); + } + + async function followRow(row: Row) { + if (row.id) return act(row.id, "follow"); + if (await followByMbid(row.artistMbid, row.artistName)) { + toast(`Following ${row.artistName}`); + loadRows(); + } else { + toast(`Couldn't follow ${row.artistName}`); + } } return ( @@ -173,9 +189,7 @@ export function DiscoverClient() {
  • - + {s.name}
    similarity {s.score.toFixed(1)} @@ -195,96 +209,80 @@ export function DiscoverClient() { ) : null} -
    - - -
    - - {tab === "Artists" ? ( - feed.artists.length === 0 ? ( + + {rows.length === 0 ? (

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

    ) : (
      - {feed.artists.map((s) => ( -
    • + {rows.map((row) => ( +
    • - + + {row.artistName} +
      - score {s.score.toFixed(2)} + score {row.score.toFixed(2)} · - {s.seedCount} seed{s.seedCount === 1 ? "" : "s"} - · - {s.sources.join(", ")} + {row.sources.join(", ")}
      - {similarTo(s.seeds) ?
      {similarTo(s.seeds)}
      : null} + {similarTo(row.seeds) ?
      {similarTo(row.seeds)}
      : null}
      + + {row.albums.length > 0 ? ( +
      + {row.albums.map((a) => ( +
      + + +
      + ))} +
      + ) : null} +
      - - -
      -
    • - ))} -
    - ) - ) : albums.length === 0 ? ( -

    No album suggestions yet.

    - ) : ( -
      - {albums.map((s) => ( -
    • -
      - -
      -
      - - + {row.id ? ( + + ) : null}
    • ))}
    )} - {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, artistMbid: albumModal.artistMbid }} + album={{ + rgMbid: albumModal.a.rgMbid, + album: albumModal.a.album ?? "", + artist: albumModal.artistName, + year: albumModal.a.firstReleaseDate?.slice(0, 4) ?? null, + type: albumModal.a.primaryType, + artistMbid: albumModal.artistMbid, + }} actions={