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:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ export default function Home() {
|
|||||||
return (
|
return (
|
||||||
<main>
|
<main>
|
||||||
<h1>Lyra</h1>
|
<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 />
|
<Queue />
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user