229 lines
8.7 KiB
TypeScript
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}).`;
|
|
}
|