From 7e1515fa99f36f28978e3fd6a9475bc585afaf50 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Mon, 13 Jul 2026 19:08:12 +0200 Subject: [PATCH] feat(web): /lastfm browse page (artists/albums tabs, hide-in-library, follow/want) --- web/src/app/_ui/contents-nav.tsx | 1 + web/src/app/lastfm/lastfm-client.tsx | 345 +++++++++++++++++++++++++++ web/src/app/lastfm/page.tsx | 5 + 3 files changed, 351 insertions(+) create mode 100644 web/src/app/lastfm/lastfm-client.tsx create mode 100644 web/src/app/lastfm/page.tsx diff --git a/web/src/app/_ui/contents-nav.tsx b/web/src/app/_ui/contents-nav.tsx index 1a4b706..a6c88b3 100644 --- a/web/src/app/_ui/contents-nav.tsx +++ b/web/src/app/_ui/contents-nav.tsx @@ -7,6 +7,7 @@ const LINKS: { href: string; label: string }[] = [ { href: "/library", label: "Library" }, { href: "/artists", label: "Artists" }, { href: "/discover", label: "Discover" }, + { href: "/lastfm", label: "Last.fm" }, { href: "/wanted", label: "Wanted" }, ]; diff --git a/web/src/app/lastfm/lastfm-client.tsx b/web/src/app/lastfm/lastfm-client.tsx new file mode 100644 index 0000000..87e951d --- /dev/null +++ b/web/src/app/lastfm/lastfm-client.tsx @@ -0,0 +1,345 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { PageHead } from "../_ui/page-head"; +import { ArtistModal } from "../_ui/artist-modal"; +import { AlbumModal } from "../_ui/album-modal"; +import { toast } from "../_ui/toast"; + +type Tab = "Artists" | "Albums"; +type Period = "overall" | "7day" | "1month" | "3month" | "6month" | "12month"; +const PERIODS: { value: Period; label: string }[] = [ + { value: "overall", label: "Overall" }, + { value: "7day", label: "7 days" }, + { value: "1month", label: "1 month" }, + { value: "3month", label: "3 months" }, + { value: "6month", label: "6 months" }, + { value: "12month", label: "12 months" }, +]; + +type ArtistRow = { name: string; playcount: number; mbid: string | null; inLibrary: boolean; followed: boolean; owned: boolean }; +type AlbumRow = { name: string; artist: string; playcount: number; mbid: string | null; inLibrary: boolean; owned: boolean; wanted: boolean }; + +type ReleaseGroupMatch = { + rgMbid: string; + title: string; + primaryType: string | null; + secondaryTypes: string[]; + firstReleaseDate: string | null; + artistMbid: string; + artistName: string; +}; + +type LoadError = { kind: "creds" } | { kind: "lastfm"; message: string }; + +export function LastfmClient() { + const [tab, setTab] = useState("Artists"); + const [period, setPeriod] = useState("overall"); + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [hideInLibrary, setHideInLibrary] = useState(false); + const [filter, setFilter] = useState(""); + + const [artists, setArtists] = useState([]); + const [albums, setAlbums] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [reloadKey, setReloadKey] = useState(0); + + const [artistModal, setArtistModal] = useState<{ mbid: string; name: string } | null>(null); + const [albumTarget, setAlbumTarget] = useState<{ row: AlbumRow; match: ReleaseGroupMatch } | null>(null); + const [wantedSet, setWantedSet] = useState>(new Set()); + + function selectTab(t: Tab) { + setTab(t); + setPage(1); + } + function selectPeriod(p: Period) { + setPeriod(p); + setPage(1); + } + function retry() { + setReloadKey((k) => k + 1); + } + + useEffect(() => { + let cancelled = false; + setLoading(true); + setError(null); + const path = tab === "Artists" ? "/api/lastfm/top-artists" : "/api/lastfm/top-albums"; + fetch(`${path}?period=${period}&page=${page}`) + .then(async (res) => { + if (cancelled) return; + if (res.status === 400) { + setError({ kind: "creds" }); + return; + } + if (!res.ok) { + const d = await res.json().catch(() => ({}) as { error?: string }); + setError({ kind: "lastfm", message: d.error ?? "Last.fm request failed" }); + return; + } + const d = await res.json(); + if (tab === "Artists") { + setArtists(d.artists ?? []); + } else { + setAlbums(d.albums ?? []); + } + setTotalPages(d.totalPages ?? 1); + }) + .catch(() => { + if (!cancelled) setError({ kind: "lastfm", message: "Last.fm request failed" }); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [tab, period, page, reloadKey]); + + const shownArtists = useMemo(() => { + const needle = filter.trim().toLowerCase(); + return artists.filter((a) => { + if (hideInLibrary && a.inLibrary) return false; + if (needle && !a.name.toLowerCase().includes(needle)) return false; + return true; + }); + }, [artists, filter, hideInLibrary]); + + const shownAlbums = useMemo(() => { + const needle = filter.trim().toLowerCase(); + return albums.filter((a) => { + if (hideInLibrary && a.inLibrary) return false; + if (needle && !`${a.artist} ${a.name}`.toLowerCase().includes(needle)) return false; + return true; + }); + }, [albums, filter, hideInLibrary]); + + async function openArtist(row: ArtistRow) { + let mbid = row.mbid; + if (!mbid) { + const res = await fetch(`/api/mb/artists?q=${encodeURIComponent(row.name)}`); + if (res.ok) { + const d = await res.json(); + mbid = d.artists?.[0]?.mbid ?? null; + } + } + if (!mbid) { + toast(`No MusicBrainz match for ${row.name}`); + return; + } + setArtistModal({ mbid, name: row.name }); + } + + async function openAlbum(row: AlbumRow) { + const res = await fetch(`/api/mb/release-group?artist=${encodeURIComponent(row.artist)}&album=${encodeURIComponent(row.name)}`); + if (res.status === 404) { + toast("No MusicBrainz match"); + return; + } + if (!res.ok) { + toast("MusicBrainz unavailable"); + return; + } + const match: ReleaseGroupMatch = await res.json(); + setAlbumTarget({ row, match }); + } + + async function want() { + if (!albumTarget) return; + const { match } = albumTarget; + const res = await fetch("/api/discover/want", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + rgMbid: match.rgMbid, + artistMbid: match.artistMbid, + artistName: match.artistName, + album: match.title, + primaryType: match.primaryType, + secondaryTypes: match.secondaryTypes, + firstReleaseDate: match.firstReleaseDate, + }), + }); + if (res.ok) { + toast(`Wanted ${match.title}`); + setWantedSet((s) => new Set(s).add(match.rgMbid)); + setAlbumTarget(null); + } + } + + const rowCount = tab === "Artists" ? shownArtists.length : shownAlbums.length; + + return ( +
+ + +
+ + +
+ +
e.preventDefault()}> + + + +
+ + {error?.kind === "creds" ? ( +
+ Connect your Last.fm account in Settings to browse your top artists and albums. +
+ ) : null} + + {error?.kind === "lastfm" ? ( +

+ {error.message}{" "} + +

+ ) : null} + + {!error && loading ?

Loading…

: null} + + {!error && !loading && tab === "Artists" ? ( + shownArtists.length === 0 ? ( +

No artists on this page.

+ ) : ( +
    + {shownArtists.map((a) => ( +
  • +
    +
    + +
    +
    + {a.playcount} plays +
    +
    +
    + {a.owned ? ( + In library + ) : a.followed ? ( + Following + ) : null} +
    +
  • + ))} +
+ ) + ) : null} + + {!error && !loading && tab === "Albums" ? ( + shownAlbums.length === 0 ? ( +

No albums on this page.

+ ) : ( +
    + {shownAlbums.map((a) => ( +
  • +
    +
    + +
    +
    + {a.playcount} plays +
    +
    +
    + {a.owned ? ( + In library + ) : a.wanted ? ( + Wanted + ) : null} +
    +
  • + ))} +
+ ) + ) : null} + + {!error ? ( +
+ + + {page} / {totalPages} + + +
+ ) : null} + + {artistModal ? ( + setArtistModal(null)} mbid={artistModal.mbid} initialName={artistModal.name} /> + ) : null} + + {albumTarget ? ( + (() => { + const owned = albumTarget.row.owned; + const wanted = albumTarget.row.wanted || wantedSet.has(albumTarget.match.rgMbid); + return ( + setAlbumTarget(null)} + album={{ + rgMbid: albumTarget.match.rgMbid, + album: albumTarget.match.title, + artist: albumTarget.match.artistName, + year: albumTarget.match.firstReleaseDate?.slice(0, 4) ?? null, + type: albumTarget.match.primaryType, + }} + status={ + owned ? ( + In library + ) : wanted ? ( + Wanted + ) : null + } + actions={ + owned || wanted ? null : ( + + ) + } + /> + ); + })() + ) : null} +
+ ); +} diff --git a/web/src/app/lastfm/page.tsx b/web/src/app/lastfm/page.tsx new file mode 100644 index 0000000..d5ecf4c --- /dev/null +++ b/web/src/app/lastfm/page.tsx @@ -0,0 +1,5 @@ +import { LastfmClient } from "./lastfm-client"; + +export default function LastfmPage() { + return ; +}