feat(web): /lastfm browse page (artists/albums tabs, hide-in-library, follow/want)
This commit is contained in:
@@ -7,6 +7,7 @@ const LINKS: { href: string; label: string }[] = [
|
|||||||
{ href: "/library", label: "Library" },
|
{ href: "/library", label: "Library" },
|
||||||
{ href: "/artists", label: "Artists" },
|
{ href: "/artists", label: "Artists" },
|
||||||
{ href: "/discover", label: "Discover" },
|
{ href: "/discover", label: "Discover" },
|
||||||
|
{ href: "/lastfm", label: "Last.fm" },
|
||||||
{ href: "/wanted", label: "Wanted" },
|
{ href: "/wanted", label: "Wanted" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { LastfmClient } from "./lastfm-client";
|
||||||
|
|
||||||
|
export default function LastfmPage() {
|
||||||
|
return <LastfmClient />;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user