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 (
|
||||
<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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user