feat(discovery): /discover feed page + seed search UI + nav link

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan
2026-07-12 00:44:11 +02:00
parent 10262f053a
commit 3de7461245
3 changed files with 158 additions and 1 deletions
+143
View File
@@ -0,0 +1,143 @@
"use client";
import { useCallback, useEffect, useState } from "react";
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 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 act(id: string, action: "follow" | "want" | "dismiss") {
await fetch(`/api/discover/${id}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ action }),
});
loadFeed();
}
return (
<div>
<section>
<button onClick={discoverNow} disabled={running}>
{running ? "Discovering…" : "Discover now"}
</button>
{result && <span> Last run: {result}</span>}
</section>
<section>
<h2>Seed search</h2>
<form onSubmit={runSearch}>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Artist name…"
aria-label="Seed artist"
/>
<button type="submit" disabled={busy}>Find similar</button>
</form>
{search && !search.seed && <p>No MusicBrainz match.</p>}
{search?.seed && (
<ul>
{search.similar.map((s) => (
<li key={s.mbid}>
{s.name} <small>({s.score.toFixed(1)})</small>
</li>
))}
</ul>
)}
</section>
<section>
<h2>Suggested artists ({feed.artists.length})</h2>
<ul>
{feed.artists.map((s) => (
<li key={s.id}>
<strong>{s.artistName}</strong>{" "}
<small>score {s.score.toFixed(2)} · {s.seedCount} seed(s) · {s.sources.join(", ")}</small>{" "}
<button onClick={() => act(s.id, "follow")}>Follow</button>{" "}
<button onClick={() => act(s.id, "dismiss")}>Dismiss</button>
</li>
))}
</ul>
</section>
<section>
<h2>Suggested albums ({feed.albums.length})</h2>
<ul>
{feed.albums.map((s) => (
<li key={s.id}>
<strong>{s.album}</strong> {s.artistName}{" "}
<small>score {s.score.toFixed(2)}</small>{" "}
<button onClick={() => act(s.id, "want")}>Want</button>{" "}
<button onClick={() => act(s.id, "dismiss")}>Dismiss</button>
</li>
))}
</ul>
</section>
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
import { DiscoverClient } from "./discover-client";
export default function DiscoverPage() {
return (
<main>
<h1>Discover</h1>
<p>
<a href="/">Home</a> · <a href="/artists">Artists</a> · <a href="/wanted">Wanted</a> ·{" "}
<a href="/settings">Settings</a>
</p>
<DiscoverClient />
</main>
);
}
+1 -1
View File
@@ -4,7 +4,7 @@ export default function Home() {
return (
<main>
<h1>Lyra</h1>
<p><a href="/artists">Artists</a> · <a href="/wanted">Wanted</a> · <a href="/settings">Settings</a></p>
<p><a href="/artists">Artists</a> · <a href="/discover">Discover</a> · <a href="/wanted">Wanted</a> · <a href="/settings">Settings</a></p>
<Queue />
</main>
);