diff --git a/web/src/app/_ui/categories.ts b/web/src/app/_ui/categories.ts new file mode 100644 index 0000000..184f03d --- /dev/null +++ b/web/src/app/_ui/categories.ts @@ -0,0 +1,14 @@ +export type Category = "Albums" | "Singles" | "EPs" | "Live" | "Comps" | "Other"; + +export const TAB_ORDER: Category[] = ["Albums", "Singles", "EPs", "Live", "Comps", "Other"]; + +/** Bucket a release-group into a discography category, for the artist-detail + preview tabs. */ +export function categoryOf(r: { primaryType: string | null; secondaryTypes: string[] }): Category { + if (r.secondaryTypes.includes("Live")) return "Live"; + if (r.secondaryTypes.includes("Compilation")) return "Comps"; + if (r.secondaryTypes.length > 0) return "Other"; + if (r.primaryType === "Album") return "Albums"; + if (r.primaryType === "Single") return "Singles"; + if (r.primaryType === "EP") return "EPs"; + return "Other"; +} diff --git a/web/src/app/artists/[id]/discography-client.tsx b/web/src/app/artists/[id]/discography-client.tsx index 0399d55..e2f1903 100644 --- a/web/src/app/artists/[id]/discography-client.tsx +++ b/web/src/app/artists/[id]/discography-client.tsx @@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react"; import { PageHead } from "../../_ui/page-head"; import { CoverArt } from "../../_ui/cover-art"; import { AlbumModal } from "../../_ui/album-modal"; +import { categoryOf, TAB_ORDER, type Category } from "../../_ui/categories"; type Release = { id: string; @@ -18,19 +19,6 @@ type Release = { }; type Detail = { id: string; name: string; autoMonitorFuture: boolean; showAllTypes: boolean; releases: Release[] }; -type Category = "Albums" | "Singles" | "EPs" | "Live" | "Comps" | "Other"; -const TAB_ORDER: Category[] = ["Albums", "Singles", "EPs", "Live", "Comps", "Other"]; - -function categoryOf(r: Release): Category { - if (r.secondaryTypes.includes("Live")) return "Live"; - if (r.secondaryTypes.includes("Compilation")) return "Comps"; - if (r.secondaryTypes.length > 0) return "Other"; - if (r.primaryType === "Album") return "Albums"; - if (r.primaryType === "Single") return "Singles"; - if (r.primaryType === "EP") return "EPs"; - return "Other"; -} - 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" }; diff --git a/web/src/app/discover/artist/[mbid]/preview-client.tsx b/web/src/app/discover/artist/[mbid]/preview-client.tsx index b730420..0f31644 100644 --- a/web/src/app/discover/artist/[mbid]/preview-client.tsx +++ b/web/src/app/discover/artist/[mbid]/preview-client.tsx @@ -1,7 +1,11 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { PageHead } from "../../../_ui/page-head"; +import { CoverArt } from "../../../_ui/cover-art"; +import { AlbumModal } from "../../../_ui/album-modal"; +import { categoryOf, TAB_ORDER, type Category } from "../../../_ui/categories"; +import { toast } from "../../../_ui/toast"; type Release = { rgMbid: string; @@ -13,26 +17,19 @@ type Release = { have: boolean; }; type Preview = { artistName: string; followed: boolean; releases: Release[] }; -type Track = { position: number; title: string; lengthMs: number | null }; - -function fmt(ms: number | null): string { - if (ms == null) return ""; - const s = Math.round(ms / 1000); - return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; -} export function PreviewClient({ mbid, initialName }: { mbid: string; initialName: string }) { const [data, setData] = useState(null); - const [showAll, setShowAll] = useState(false); const [followed, setFollowed] = useState(false); - const [tracks, setTracks] = useState>({}); const [error, setError] = useState(false); + const [tab, setTab] = useState<"All" | Category>("Albums"); + const [wanted, setWanted] = useState>(new Set()); + const [open, setOpen] = useState(null); const load = useCallback(async () => { setError(false); - const qs = new URLSearchParams(); + const qs = new URLSearchParams({ all: "true" }); if (initialName) qs.set("name", initialName); - if (showAll) qs.set("all", "true"); const res = await fetch(`/api/discover/preview/${mbid}?${qs.toString()}`); if (!res.ok) { setError(true); @@ -41,28 +38,24 @@ export function PreviewClient({ mbid, initialName }: { mbid: string; initialName const d: Preview = await res.json(); setData(d); setFollowed(d.followed); - }, [mbid, initialName, showAll]); + }, [mbid, initialName]); useEffect(() => { load(); }, [load]); - // Scroll to the album an album-suggestion deep-linked to (#rg-) once data is in. - useEffect(() => { - if (!data) return; - const hash = window.location.hash; - if (!hash) return; - document.getElementById(hash.slice(1))?.scrollIntoView(); - }, [data]); + const name = data?.artistName || initialName; async function follow() { - const name = data?.artistName || initialName; 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); + if (res.ok || res.status === 409) { + setFollowed(true); + toast(`Following ${name}`); + } } async function want(r: Release) { @@ -72,36 +65,31 @@ export function PreviewClient({ mbid, initialName }: { mbid: string; initialName body: JSON.stringify({ rgMbid: r.rgMbid, artistMbid: mbid, - artistName: data?.artistName || initialName, + artistName: name, album: r.album, primaryType: r.primaryType, secondaryTypes: r.secondaryTypes, firstReleaseDate: r.firstReleaseDate, }), }); - if (!res.ok) return; // don't flip the badge if the want failed - setData((d) => - d ? { ...d, releases: d.releases.map((x) => (x.rgMbid === r.rgMbid ? { ...x, monitored: true } : x)) } : d, - ); + if (res.ok) { + setWanted((s) => new Set(s).add(r.rgMbid)); + toast(`Added ${r.album}`); + } } - async function toggleTracks(rgMbid: string) { - if (tracks[rgMbid]) { - setTracks((t) => { - const n = { ...t }; - delete n[rgMbid]; - return n; - }); - return; - } - setTracks((t) => ({ ...t, [rgMbid]: "loading" })); - const res = await fetch(`/api/mb/release-groups/${rgMbid}/tracks`); - if (!res.ok) { - setTracks((t) => ({ ...t, [rgMbid]: "error" })); - return; - } - const { tracks: ts } = await res.json(); - setTracks((t) => ({ ...t, [rgMbid]: ts })); + const counts = useMemo(() => { + const c: Record = {}; + data?.releases.forEach((r) => { + c[categoryOf(r)] = (c[categoryOf(r)] ?? 0) + 1; + }); + return c; + }, [data]); + + function chipFor(r: Release) { + if (r.have) return In library; + if (r.monitored || wanted.has(r.rgMbid)) return Wanted; + return null; } if (error) @@ -115,9 +103,12 @@ export function PreviewClient({ mbid, initialName }: { mbid: string; initialName ); if (!data) return

Loading…

; + const tabs: ("All" | Category)[] = ["All", ...TAB_ORDER.filter((t) => counts[t])]; + const visible = tab === "All" ? data.releases : data.releases.filter((r) => categoryOf(r) === tab); + return (
- +
{followed ? ( @@ -130,55 +121,77 @@ export function PreviewClient({ mbid, initialName }: { mbid: string; initialName MusicBrainz ↗ - +
+ +
+ {tabs.map((t) => ( + + ))}
    - {data.releases.map((r) => { - const t = tracks[r.rgMbid]; + {visible.map((r) => { + const owned = r.have || r.monitored || wanted.has(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}
  • ); })}
+ + {open ? ( + setOpen(null)} + album={{ + rgMbid: open.rgMbid, + album: open.album, + artist: name, + year: open.firstReleaseDate?.slice(0, 4) ?? null, + type: open.primaryType, + }} + status={chipFor(open)} + actions={ + open.have || open.monitored || wanted.has(open.rgMbid) ? null : ( + + ) + } + /> + ) : null}
); }