Files
streamrip-ui/frontend/app/discover/page.tsx
T
loki 99cfda2b08
CI / backend-tests (push) Successful in 8s
Docker images / backend (push) Successful in 7s
Docker images / frontend (push) Successful in 22s
CI / docker (push) Successful in 3s
feat: mehr-laden paging (suche + entdecken, totals, fallback-skip)
2026-10-04 16:39:16 +02:00

238 lines
9.9 KiB
TypeScript

"use client";
import Image from "next/image";
import { useEffect, useState } from "react";
import DownloadDialog, { type SearchItem } from "../DownloadDialog";
import BasketButton from "../components/BasketButton";
type Genre = { id: number; name: string; cover?: string | null };
const KINDS = [
{ key: "new", label: "Neuheiten" },
{ key: "tracks", label: "Top-Titel" },
{ key: "albums", label: "Top-Alben" },
{ key: "artists", label: "Top-Artists" },
{ key: "playlists", label: "Playlists" },
] as const;
type Kind = (typeof KINDS)[number]["key"];
export default function DiscoverPage() {
const [genres, setGenres] = useState<Genre[]>([]);
const [genreId, setGenreId] = useState(0);
const [kind, setKind] = useState<Kind>("new");
const [results, setResults] = useState<SearchItem[]>([]);
const [total, setTotal] = useState<number | null>(null);
const [pageIndex, setPageIndex] = useState(0);
const [hasMore, setHasMore] = useState(false);
const [loading, setLoading] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [radios, setRadios] = useState<{ id: number; title: string; cover?: string | null }[]>([]);
const [error, setError] = useState("");
const [selected, setSelected] = useState<SearchItem | null>(null);
const [radioMsg, setRadioMsg] = useState("");
const [nonce, setNonce] = useState(0);
const [openRadio, setOpenRadio] = useState<number | null>(null);
const [radioTracks, setRadioTracks] = useState<SearchItem[]>([]);
const [radioLoading, setRadioLoading] = useState(false);
useEffect(() => {
fetch("/api/discover/genres").then((r) => r.json()).then((b) => setGenres(b.genres ?? [])).catch(() => {});
}, []);
const LIMIT = 24;
async function fetchDiscover(k: Kind, g: number, idx: number, append: boolean) {
const r = await fetch(`/api/discover?kind=${k}&genre_id=${g}&limit=${LIMIT}&index=${idx}`);
if (!r.ok) throw new Error(`Fehler (${r.status})`);
const b = await r.json();
const batch = b.results ?? [];
setResults((prev) => (append ? [...prev, ...batch] : batch));
setTotal(typeof b.total === "number" ? b.total : null);
setPageIndex(idx);
setHasMore(batch.length === LIMIT);
}
useEffect(() => {
setLoading(true);
setError("");
fetchDiscover(kind, genreId, 0, false)
.catch((e: unknown) => setError(e instanceof Error ? e.message : "Fehler."))
.finally(() => setLoading(false));
fetch(`/api/discover/radios?genre_id=${genreId}`)
.then((r) => r.json())
.then((b) => setRadios(b.radios ?? []))
.catch(() => {});
}, [kind, genreId, nonce]);
async function loadMore() {
setLoadingMore(true);
try {
await fetchDiscover(kind, genreId, pageIndex + LIMIT, true);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Fehler.");
} finally {
setLoadingMore(false);
}
}
const canLoadMore = !loading && results.length > 0 && (total === null || results.length < total);
async function toggleRadio(radioId: number) {
if (openRadio === radioId) {
setOpenRadio(null);
setRadioTracks([]);
return;
}
setOpenRadio(radioId);
setRadioTracks([]);
setRadioMsg("");
setRadioLoading(true);
try {
const t = await fetch(`/api/discover/radios/${radioId}/tracks?limit=25`).then((r) => {
if (!r.ok) throw new Error(`Fehler (${r.status})`);
return r.json();
});
setRadioTracks(t.tracks ?? []);
} catch (e: unknown) {
setRadioMsg(e instanceof Error ? e.message : "Fehler.");
} finally {
setRadioLoading(false);
}
}
return (
<div>
<h1 style={{ fontSize: 36, margin: "8px 0 4px", letterSpacing: "-0.02em" }}>Entdecken</h1>
<p className="muted" style={{ marginTop: 0 }}>Neuheiten und Charts nach Genre — direkt laden oder in den Korb legen.</p>
<div style={{ display: "flex", gap: 8, margin: "16px 0", flexWrap: "wrap" }}>
{KINDS.map((k) => (
<button key={k.key} onClick={() => setKind(k.key)} className={kind === k.key ? "btn btn-primary" : "btn"}>
{k.label}
</button>
))}
</div>
<div style={{ display: "flex", gap: 8, marginBottom: 20, flexWrap: "wrap" }}>
<button onClick={() => setGenreId(0)} className={genreId === 0 ? "btn btn-primary" : "btn"}>
Alle Genres
</button>
{genres.map((g) => (
<button key={g.id} onClick={() => setGenreId(g.id)} className={genreId === g.id ? "btn btn-primary" : "btn"}>
{g.name}
</button>
))}
</div>
{error && <p className="error">{error}</p>}
{loading && <p className="muted">Lädt …</p>}
{!loading && results.length === 0 && !error && (
<div>
<p className="muted">Derzeit keine Daten von Deezer für diese Auswahl.</p>
<button onClick={() => setNonce((n) => n + 1)} className="btn">
Erneut versuchen
</button>
</div>
)}
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(180px, 1fr))", gap: 14 }}>
{results.map((item) => {
const href =
item.media_type === "artist" ? `/artist/${item.id}` :
item.media_type === "album" ? `/album/${item.id}` :
item.media_type === "playlist" ? `/playlist/${item.id}` : null;
const cover = item.cover ? (
<Image src={item.cover} alt={item.title} width={300} height={300} style={{ width: "100%", height: "auto", borderRadius: 6 }} />
) : (
<div style={{ width: "100%", aspectRatio: "1", background: "var(--surface-2)", borderRadius: 6 }} />
);
return (
<div key={`${item.media_type}-${item.id}`} className="card">
{href ? <a href={href} title="Details ansehen">{cover}</a> : cover}
<div style={{ fontWeight: 600, marginTop: 8 }}>{item.title}</div>
<div className="muted" style={{ fontSize: 13 }}>{item.artist}</div>
{item.preview_url && <audio controls preload="none" src={item.preview_url} style={{ width: "100%", marginTop: 8 }} />}
<button onClick={() => setSelected(item)} className="btn" style={{ marginTop: 8, width: "100%" }}>
Download
</button>
<div style={{ marginTop: 8 }}>
<BasketButton item={{ source: "deezer", media_type: item.media_type, id: item.id, title: item.title, artist: item.artist }} />
</div>
</div>
);
})}
</div>
{!loading && results.length > 0 && (
<p className="muted" style={{ fontSize: 13 }}>
{results.length}{total !== null && total > results.length ? ` von ${total}` : ""} Einträge
</p>
)}
{!loading && hasMore && (
<div style={{ textAlign: "center", margin: "20px 0" }}>
<button
onClick={async () => {
setLoadingMore(true);
try {
await fetchDiscover(kind, genreId, pageIndex + LIMIT, true);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Fehler.");
} finally {
setLoadingMore(false);
}
}}
disabled={loadingMore}
className="btn btn-primary"
style={{ padding: "10px 28px" }}
>
{loadingMore ? "Lädt …" : "Mehr laden"}
</button>
</div>
)}
{selected && <DownloadDialog item={selected} onClose={() => setSelected(null)} />}
{radios.length > 0 && (
<section style={{ marginTop: 28 }}>
<h2 style={{ fontSize: 24 }}>Sender {genreId !== 0 ? "in diesem Genre" : ""}</h2>
{radioMsg && <p>{radioMsg}</p>}
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(160px, 1fr))", gap: 12 }}>
{radios.map((radio) => (
<div key={radio.id} className="card">
{radio.cover ? (
<Image src={radio.cover} alt={radio.title} width={200} height={200} style={{ width: "100%", height: "auto", borderRadius: 6 }} />
) : (
<div style={{ width: "100%", aspectRatio: "1", background: "var(--surface-2)", borderRadius: 6 }} />
)}
<div style={{ fontWeight: 600, marginTop: 6, fontSize: 14 }}>{radio.title}</div>
<button onClick={() => toggleRadio(radio.id)} className="btn" style={{ marginTop: 8, width: "100%" }}>
{openRadio === radio.id ? "Schließen" : "Lieder ansehen"}
</button>
</div>
))}
</div>
{openRadio !== null && (
<div className="card" style={{ marginTop: 12, padding: 12 }}>
{radioLoading && <p className="muted">Lädt …</p>}
{!radioLoading && radioTracks.length === 0 && <p className="muted">Keine Tracks gefunden.</p>}
<ol style={{ listStyle: "none", margin: 0, padding: 0, display: "flex", flexDirection: "column", gap: 8 }}>
{radioTracks.map((t) => (
<li key={t.id} style={{ display: "flex", alignItems: "center", gap: 12 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600 }}>{t.title}</div>
<div className="muted" style={{ fontSize: 13 }}>{t.artist}</div>
</div>
{t.preview_url && <audio controls preload="none" src={t.preview_url} style={{ width: 180, height: 32 }} />}
<BasketButton item={{ source: "deezer", media_type: "track", id: t.id, title: t.title, artist: t.artist }} />
<button onClick={() => setSelected(t)} className="btn" style={{ whiteSpace: "nowrap" }}>
Download
</button>
</li>
))}
</ol>
</div>
)}
</section>
)}
</div>
);
}