feat(web): /lastfm browse page (artists/albums tabs, hide-in-library, follow/want)

This commit is contained in:
Jonathan
2026-07-13 19:08:12 +02:00
parent ecdad88973
commit 7e1515fa99
3 changed files with 351 additions and 0 deletions
+1
View File
@@ -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" },
];
+345
View File
@@ -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<Tab>("Artists");
const [period, setPeriod] = useState<Period>("overall");
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [hideInLibrary, setHideInLibrary] = useState(false);
const [filter, setFilter] = useState("");
const [artists, setArtists] = useState<ArtistRow[]>([]);
const [albums, setAlbums] = useState<AlbumRow[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<LoadError | null>(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<Set<string>>(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 (
<div>
<PageHead title="Last.fm" eyebrow="Your listening history · follow what's missing" />
<div className="tabs">
<button className={`tab${tab === "Artists" ? " active" : ""}`} onClick={() => selectTab("Artists")}>
Artists
<span className="n">{tab === "Artists" ? rowCount : ""}</span>
</button>
<button className={`tab${tab === "Albums" ? " active" : ""}`} onClick={() => selectTab("Albums")}>
Albums
<span className="n">{tab === "Albums" ? rowCount : ""}</span>
</button>
</div>
<form className="request-form" onSubmit={(e) => e.preventDefault()}>
<label className="field">
<span>Period</span>
<select aria-label="period" value={period} onChange={(e) => selectPeriod(e.target.value as Period)}>
{PERIODS.map((p) => (
<option key={p.value} value={p.value}>
{p.label}
</option>
))}
</select>
</label>
<label className="field">
<span>Filter</span>
<input aria-label="filter" placeholder="Artist or album…" value={filter} onChange={(e) => setFilter(e.target.value)} />
</label>
<label className="toggle">
<input
type="checkbox"
aria-label="hide items in my library"
checked={hideInLibrary}
onChange={(e) => setHideInLibrary(e.target.checked)}
/>
Hide items in my library
</label>
</form>
{error?.kind === "creds" ? (
<div className="info-panel">
Connect your Last.fm account in <a href="/settings">Settings</a> to browse your top artists and albums.
</div>
) : null}
{error?.kind === "lastfm" ? (
<p className="muted-note">
<span className="notice">{error.message}</span>{" "}
<button className="btn sm ghost" onClick={retry}>
Retry
</button>
</p>
) : null}
{!error && loading ? <p className="empty">Loading</p> : null}
{!error && !loading && tab === "Artists" ? (
shownArtists.length === 0 ? (
<p className="empty">No artists on this page.</p>
) : (
<ul className="list">
{shownArtists.map((a) => (
<li key={a.mbid ?? a.name} className="list-row">
<div className="main">
<div className="rtitle">
<button className="linkish" onClick={() => openArtist(a)}>
{a.name}
</button>
</div>
<div className="rmeta">
<span>{a.playcount} plays</span>
</div>
</div>
<div className="actions">
{a.owned ? (
<span className="chip done">In library</span>
) : a.followed ? (
<span className="chip working">Following</span>
) : null}
</div>
</li>
))}
</ul>
)
) : null}
{!error && !loading && tab === "Albums" ? (
shownAlbums.length === 0 ? (
<p className="empty">No albums on this page.</p>
) : (
<ul className="list">
{shownAlbums.map((a) => (
<li key={`${a.artist}-${a.name}`} className="list-row">
<div className="main">
<div className="rtitle">
<button className="linkish" onClick={() => openAlbum(a)}>
{a.artist} {a.name}
</button>
</div>
<div className="rmeta">
<span>{a.playcount} plays</span>
</div>
</div>
<div className="actions">
{a.owned ? (
<span className="chip done">In library</span>
) : a.wanted ? (
<span className="chip working">Wanted</span>
) : null}
</div>
</li>
))}
</ul>
)
) : null}
{!error ? (
<div className="tool-row">
<button className="btn sm ghost" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page <= 1}>
Prev
</button>
<span className="rmeta">
{page} / {totalPages}
</span>
<button className="btn sm ghost" onClick={() => setPage((p) => Math.min(totalPages, p + 1))} disabled={page >= totalPages}>
Next
</button>
</div>
) : null}
{artistModal ? (
<ArtistModal open onClose={() => 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 (
<AlbumModal
open
onClose={() => 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 ? (
<span className="chip done">In library</span>
) : wanted ? (
<span className="chip working">Wanted</span>
) : null
}
actions={
owned || wanted ? null : (
<button className="btn sm" onClick={want}>
Want
</button>
)
}
/>
);
})()
) : null}
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { LastfmClient } from "./lastfm-client";
export default function LastfmPage() {
return <LastfmClient />;
}