Files
streamrip-ui/frontend/app/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

229 lines
8.7 KiB
TypeScript

"use client";
import Image from "next/image";
import { useState } from "react";
import DownloadDialog, { type SearchItem } from "./DownloadDialog";
import BasketButton from "./components/BasketButton";
const TYPES = ["track", "album", "artist", "playlist", "podcast"] as const;
type MediaType = (typeof TYPES)[number];
const TYPE_LABEL: Record<MediaType, string> = {
track: "Titel",
album: "Alben",
artist: "Artists",
playlist: "Playlists",
podcast: "Podcasts",
};
export default function SearchPage() {
const [q, setQ] = useState("");
const [tab, setTab] = useState<MediaType>("track");
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 [error, setError] = useState("");
const [searched, setSearched] = useState(false);
const [selected, setSelected] = useState<SearchItem | null>(null);
const [link, setLink] = useState("");
const [linkMsg, setLinkMsg] = useState("");
const LIMIT = 24;
async function fetchPage(query: string, mediaType: MediaType, idx: number, append: boolean) {
const r = await fetch(`/api/search?type=${mediaType}&q=${encodeURIComponent(query)}&limit=${LIMIT}&index=${idx}`);
if (!r.ok) throw new Error(hintFor(r.status));
const body = await r.json();
const batch = body.results ?? [];
setResults((prev) => (append ? [...prev, ...batch] : batch));
setTotal(typeof body.total === "number" ? body.total : null);
setPageIndex(idx);
// Volle Seite => evtl. mehr da (Deezer-Totals bei Charts lügen teils).
setHasMore(batch.length === LIMIT);
}
async function importLink() {
if (!link.trim()) return;
setLinkMsg("");
try {
const r = await fetch("/api/downloads", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: link.trim() }),
});
if (!r.ok) {
const body = await r.json().catch(() => ({}));
throw new Error(body.detail || `Fehler (${r.status})`);
}
const body = await r.json();
setLinkMsg(`Angelegt: ${body.job_id} — siehe Downloads.`);
setLink("");
} catch (e: unknown) {
setLinkMsg(e instanceof Error ? e.message : "Fehler.");
}
}
async function runSearch() {
if (!q.trim()) return;
setLoading(true);
setError("");
setSearched(true);
try {
await fetchPage(q, tab, 0, false);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Suche fehlgeschlagen.");
setResults([]);
setTotal(null);
} finally {
setLoading(false);
}
}
async function loadMore() {
setLoadingMore(true);
try {
await fetchPage(q, tab, pageIndex + LIMIT, true);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Suche fehlgeschlagen.");
} finally {
setLoadingMore(false);
}
}
const canLoadMore = searched && !loading && hasMore;
return (
<div>
<h1 style={{ fontSize: 40, margin: "8px 0 4px", letterSpacing: "-0.02em" }}>Was willst du laden?</h1>
<p className="muted" style={{ marginTop: 0 }}>Deezer-Suche — Treffer direkt als Download starten.</p>
<div style={{ display: "flex", gap: 8, margin: "16px 0" }}>
{TYPES.map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={tab === t ? "btn btn-primary" : "btn"}
>
{TYPE_LABEL[t]}
</button>
))}
</div>
<form
onSubmit={(e) => {
e.preventDefault();
runSearch();
}}
style={{ display: "flex", gap: 8, marginBottom: 20 }}
>
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Titel, Artist, Album …"
className="input"
style={{ fontSize: 17, padding: "12px 14px" }}
/>
<button type="submit" className="btn btn-primary" style={{ padding: "12px 22px", whiteSpace: "nowrap" }}>
{loading ? "Sucht …" : "Suchen"}
</button>
</form>
{error && <p className="error">{error}</p>}
<div className="card" style={{ display: "flex", gap: 8, marginBottom: 20, padding: 12, alignItems: "center" }}>
<input
value={link}
onChange={(e) => setLink(e.target.value)}
placeholder="Deezer-Link einfügen … (Album/Track/Artist/Playlist)"
className="input"
style={{ fontFamily: "monospace", fontSize: 13 }}
/>
<button onClick={importLink} className="btn" style={{ whiteSpace: "nowrap" }}>
Link laden
</button>
</div>
{linkMsg && <p>{linkMsg}</p>}
{!loading && searched && results.length === 0 && !error && (
<p className="muted">Keine Treffer — anders formulieren oder anderen Typ wählen.</p>
)}
{searched && !loading && results.length > 0 && (
<p className="muted" style={{ fontSize: 13 }}>
{results.length}{total !== null && total > results.length ? ` von ${total}` : ""} Treffer
</p>
)}
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(180px, 1fr))", gap: 14 }}>
{results.map((item) => {
const detailHref =
item.media_type === "artist" ? `/artist/${item.id}` :
item.media_type === "album" ? `/album/${item.id}` :
item.media_type === "playlist" ? `/playlist/${item.id}` :
item.media_type === "podcast" ? `/podcast/${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">
{detailHref ? <a href={detailHref} title="Details ansehen">{cover}</a> : cover}
<div style={{ fontWeight: 600, marginTop: 8 }}>
{detailHref ? <a href={detailHref} style={{ color: "inherit", textDecoration: "none" }}>{item.title}</a> : item.title}
</div>
<div className="muted" style={{ fontSize: 13 }}>{item.artist}</div>
{item.media_type === "artist" && (
<a href={`/artist/${item.id}`} style={{ display: "block", marginTop: 8 }}>
Alben, EPs & Singles ansehen →
</a>
)}
{item.media_type === "album" && (
<a href={`/album/${item.id}`} style={{ display: "block", marginTop: 8 }}>
Trackliste ansehen →
</a>
)}
{item.media_type === "playlist" && (
<a href={`/playlist/${item.id}`} style={{ display: "block", marginTop: 8 }}>
Trackliste ansehen →
</a>
)}
{item.media_type === "podcast" && (
<a href={`/podcast/${item.id}`} style={{ display: "block", marginTop: 8 }}>
Episoden ansehen →
</a>
)}
{item.preview_url && (
<audio controls preload="none" src={item.preview_url} style={{ width: "100%", marginTop: 8 }} />
)}
{item.media_type === "podcast" ? (
<p className="muted" style={{ fontSize: 13, marginTop: 8 }}>
Nur Übersicht — Download nicht möglich.
</p>
) : (
<>
<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>
{canLoadMore && (
<div style={{ textAlign: "center", margin: "20px 0" }}>
<button onClick={loadMore} disabled={loadingMore} className="btn btn-primary" style={{ padding: "10px 28px" }}>
{loadingMore ? "Lädt …" : "Mehr laden"}
</button>
</div>
)}
{selected && <DownloadDialog item={selected} onClose={() => setSelected(null)} />}
</div>
);
}
function hintFor(status: number): string {
if (status === 502) return "Suche fehlgeschlagen — Backend prüfen (ARL in Einstellungen kontrollieren).";
return `Suche fehlgeschlagen (${status}).`;
}