92d1345503
Add a vinyl-record favicon (icon.svg) — fixes the favicon.ico 404 console error. Lightweight toast system (ToastHost + toast()) fired on request, wanted add, search, follow, and want actions so submits give explicit feedback. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
244 lines
8.4 KiB
TypeScript
244 lines
8.4 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useState } from "react";
|
|
import { PageHead } from "../_ui/page-head";
|
|
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 = {
|
|
id: string;
|
|
artistMbid: string;
|
|
artistName: string;
|
|
rgMbid: string | null;
|
|
album: string | null;
|
|
score: number;
|
|
seedCount: number;
|
|
sources: string[];
|
|
};
|
|
|
|
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<Feed>({ artists: [], albums: [] });
|
|
const [result, setResult] = useState<string | null>(null);
|
|
const [running, setRunning] = useState(false);
|
|
const [query, setQuery] = useState("");
|
|
const [search, setSearch] = useState<SearchResult | null>(null);
|
|
const [busy, setBusy] = useState(false);
|
|
const [followedSimilar, setFollowedSimilar] = useState<Set<string>>(new Set());
|
|
const [artistModal, setArtistModal] = useState<{ mbid: string; name: string } | null>(null);
|
|
const [albumModal, setAlbumModal] = useState<Suggestion | null>(null);
|
|
|
|
const loadFeed = useCallback(async () => {
|
|
setFeed(await (await fetch("/api/discover")).json());
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
loadFeed();
|
|
fetch("/api/discover/run")
|
|
.then((r) => r.json())
|
|
.then((s: { result: string | null; requested: boolean }) => {
|
|
setResult(s.result);
|
|
setRunning(s.requested);
|
|
});
|
|
}, [loadFeed]);
|
|
|
|
useEffect(() => {
|
|
if (!running) return;
|
|
const id = setInterval(async () => {
|
|
const s = await (await fetch("/api/discover/run")).json();
|
|
setResult(s.result);
|
|
if (!s.requested) {
|
|
setRunning(false);
|
|
loadFeed();
|
|
}
|
|
}, 2000);
|
|
return () => clearInterval(id);
|
|
}, [running, loadFeed]);
|
|
|
|
async function discoverNow() {
|
|
await fetch("/api/discover/run", { method: "POST" });
|
|
setRunning(true);
|
|
}
|
|
|
|
async function runSearch(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
if (!query.trim()) return;
|
|
setBusy(true);
|
|
try {
|
|
setSearch(await (await fetch(`/api/discover/search?artist=${encodeURIComponent(query.trim())}`)).json());
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function followSimilar(a: { 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) {
|
|
setFollowedSimilar((s) => new Set(s).add(a.mbid));
|
|
toast(`Following ${a.name}`);
|
|
}
|
|
}
|
|
|
|
async function act(id: string, action: "follow" | "want" | "dismiss") {
|
|
await fetch(`/api/discover/${id}`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ action }),
|
|
});
|
|
toast(action === "follow" ? "Following" : action === "want" ? "Added to wanted" : "Dismissed");
|
|
loadFeed();
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<PageHead title="Discover" eyebrow="A&R · similar artists & new releases" />
|
|
|
|
<div className="tool-row">
|
|
<button className="btn accent" onClick={discoverNow} disabled={running}>
|
|
{running ? "Discovering…" : "Discover now"}
|
|
</button>
|
|
{result ? <span className="rmeta">last run · {result}</span> : null}
|
|
</div>
|
|
|
|
<SectionHeader title="Find similar" />
|
|
<form className="request-form" onSubmit={runSearch}>
|
|
<label className="field">
|
|
<span>Seed artist</span>
|
|
<input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Artist name…" aria-label="Seed artist" />
|
|
</label>
|
|
<button type="submit" className="btn" disabled={busy}>
|
|
{busy ? "Searching…" : "Find similar"}
|
|
</button>
|
|
</form>
|
|
{search && !search.seed ? <p className="muted-note">No MusicBrainz match.</p> : null}
|
|
{search?.seed ? (
|
|
<ul className="list">
|
|
{search.similar.map((s) => (
|
|
<li key={s.mbid} className="list-row">
|
|
<div className="main">
|
|
<div className="rtitle">
|
|
<button className="linkish" onClick={() => setArtistModal({ mbid: s.mbid, name: s.name })}>
|
|
{s.name}
|
|
</button>
|
|
</div>
|
|
<div className="rmeta">
|
|
<span className="score">similarity {s.score.toFixed(1)}</span>
|
|
</div>
|
|
</div>
|
|
<div className="actions">
|
|
{followedSimilar.has(s.mbid) ? (
|
|
<span className="following">Following</span>
|
|
) : (
|
|
<button className="btn sm" onClick={() => followSimilar(s)}>
|
|
Follow
|
|
</button>
|
|
)}
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
) : null}
|
|
|
|
<SectionHeader title="Suggested artists" note={`${feed.artists.length}`} />
|
|
{feed.artists.length === 0 ? (
|
|
<p className="muted-note">No suggestions yet — run Discover, or follow a few artists to seed it.</p>
|
|
) : (
|
|
<ul className="list">
|
|
{feed.artists.map((s) => (
|
|
<li key={s.id} className="list-row">
|
|
<div className="main">
|
|
<div className="rtitle">
|
|
<button className="linkish" onClick={() => setArtistModal({ mbid: s.artistMbid, name: s.artistName })}>
|
|
{s.artistName}
|
|
</button>
|
|
</div>
|
|
<div className="rmeta">
|
|
<span className="score">score {s.score.toFixed(2)}</span>
|
|
<span className="dot">·</span>
|
|
<span>{s.seedCount} seed{s.seedCount === 1 ? "" : "s"}</span>
|
|
<span className="dot">·</span>
|
|
<span>{s.sources.join(", ")}</span>
|
|
</div>
|
|
</div>
|
|
<div className="actions">
|
|
<button className="btn sm" onClick={() => act(s.id, "follow")}>
|
|
Follow
|
|
</button>
|
|
<button className="btn sm ghost" onClick={() => act(s.id, "dismiss")}>
|
|
Dismiss
|
|
</button>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
|
|
<SectionHeader title="Suggested albums" note={`${feed.albums.length}`} />
|
|
{feed.albums.length === 0 ? (
|
|
<p className="muted-note">No album suggestions yet.</p>
|
|
) : (
|
|
<ul className="list">
|
|
{feed.albums.map((s) => (
|
|
<li key={s.id} className="list-row">
|
|
<div className="main">
|
|
<button className="disco-open" onClick={() => setAlbumModal(s)} aria-label={`Open ${s.album ?? ""}`}>
|
|
<CoverArt rgMbid={s.rgMbid} alt={s.album ?? ""} className="thumb" />
|
|
<span>
|
|
<span className="rtitle">
|
|
{s.album} <span className="artist">· {s.artistName}</span>
|
|
</span>
|
|
<span className="rmeta">
|
|
<span className="score">score {s.score.toFixed(2)}</span>
|
|
</span>
|
|
</span>
|
|
</button>
|
|
</div>
|
|
<div className="actions">
|
|
<button className="btn sm" onClick={() => act(s.id, "want")}>
|
|
Want
|
|
</button>
|
|
<button className="btn sm ghost" onClick={() => act(s.id, "dismiss")}>
|
|
Dismiss
|
|
</button>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
{artistModal ? (
|
|
<ArtistModal open onClose={() => setArtistModal(null)} mbid={artistModal.mbid} initialName={artistModal.name} />
|
|
) : null}
|
|
{albumModal ? (
|
|
<AlbumModal
|
|
open
|
|
onClose={() => setAlbumModal(null)}
|
|
album={{ rgMbid: albumModal.rgMbid, album: albumModal.album ?? "", artist: albumModal.artistName, year: null, type: null }}
|
|
actions={
|
|
<button
|
|
className="btn sm"
|
|
onClick={() => {
|
|
act(albumModal.id, "want");
|
|
setAlbumModal(null);
|
|
}}
|
|
>
|
|
Want
|
|
</button>
|
|
}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|