Files
Lyra/web/src/app/discover/discover-client.tsx
T
Jonathan 92d1345503 feat(web): favicon + toast feedback (polish)
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>
2026-07-12 15:44:24 +02:00

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>
);
}