feat: dateien als reiter in downloads (breadcrumbs, redirect)
CI / backend-tests (push) Successful in 10s
Docker images / version (push) Successful in 2s
CI / docker (push) Successful in 22s
Docker images / backend (push) Successful in 8s
Docker images / frontend (push) Successful in 8s

This commit is contained in:
loki committed 2026-10-04 14:45:30 +02:00
1 parent 4d9912ff96
commit 36f36a131b
4 files changed
+136 -77

No files matched your search

+126
View File
@@ -0,0 +1,126 @@
"use client";
import { useEffect, useState } from "react";
type Entry = { name: string; type: string; size?: number | null; rel?: string };
function fmtSize(size?: number | null): string {
if (typeof size !== "number") return "";
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${Math.round(size / 1024)} KB`;
return `${Math.round(size / 1024 / 1024)} MB`;
}
export default function FilesBrowser() {
const [path, setPath] = useState("");
const [entries, setEntries] = useState<Entry[]>([]);
const [loading, setLoading] = useState(false);
const [confirm, setConfirm] = useState<Entry | null>(null);
async function load(p: string) {
setLoading(true);
try {
const r = await fetch(`/api/files?path=${encodeURIComponent(p)}`);
if (!r.ok) return;
const b = await r.json();
setPath(b.path ?? "");
setEntries(b.entries ?? []);
} finally {
setLoading(false);
}
}
useEffect(() => {
load("");
}, []);
function up() {
if (!path) return;
const parts = path.split("/").filter(Boolean);
parts.pop();
load(parts.join("/"));
}
async function remove(entry: Entry) {
const r = await fetch(`/api/files?path=${encodeURIComponent(entry.rel ?? entry.name)}`, { method: "DELETE" });
setConfirm(null);
if (r.ok) load(path);
else alert(`Löschen fehlgeschlagen (${r.status})`);
}
const crumbs = path.split("/").filter(Boolean);
return (
<div>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 12, flexWrap: "wrap" }}>
<button onClick={() => load("")} className="btn" style={{ padding: "4px 10px" }}>
⌂ Root
</button>
{crumbs.map((c, i) => (
<span key={i} style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span className="muted">/</span>
<button
onClick={() => load(crumbs.slice(0, i + 1).join("/"))}
className="btn"
style={{ padding: "4px 10px" }}
>
{c}
</button>
</span>
))}
{path && (
<button onClick={up} className="btn" style={{ padding: "4px 10px" }}>
↑ Ebene hoch
</button>
)}
</div>
{loading && <p className="muted">Lädt …</p>}
{!loading && entries.length === 0 && <p className="muted">Ordner ist leer.</p>}
<ol style={{ listStyle: "none", margin: 0, padding: 0, display: "flex", flexDirection: "column", gap: 8 }}>
{entries.map((e) => (
<li key={e.rel ?? e.name} className="card" style={{ display: "flex", alignItems: "center", gap: 12, padding: "10px 14px" }}>
<span style={{ fontSize: 20 }} aria-hidden>{e.type === "dir" ? "📁" : "🎵"}</span>
<div style={{ flex: 1, minWidth: 0 }}>
{e.type === "dir" ? (
<button onClick={() => load(e.rel ?? e.name)} style={{ background: "none", border: "none", padding: 0, cursor: "pointer", color: "var(--text)", fontWeight: 600, fontSize: 15, textAlign: "left" }}>
{e.name}
</button>
) : (
<>
<div style={{ fontWeight: 600, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{e.name}</div>
<div className="muted" style={{ fontSize: 13 }}>{fmtSize(e.size)}</div>
</>
)}
</div>
{e.type === "file" && (
<>
<a href={`/api/files/download?path=${encodeURIComponent(e.rel ?? e.name)}`} download className="btn" style={{ textDecoration: "none", whiteSpace: "nowrap" }}>
↓ Laden
</a>
<button onClick={() => setConfirm(e)} className="btn" style={{ whiteSpace: "nowrap" }}>
Löschen
</button>
</>
)}
</li>
))}
</ol>
{confirm && (
<div style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,.6)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 100 }} onClick={() => setConfirm(null)}>
<div className="card" style={{ padding: 20, maxWidth: 400, width: "90%", background: "var(--surface-2)" }} onClick={(ev) => ev.stopPropagation()}>
<h3 style={{ marginTop: 0 }}>Wirklich löschen?</h3>
<p><strong>{confirm.name}</strong>{confirm.size ? ` (${fmtSize(confirm.size)})` : ""}</p>
<p className="muted">Endgültig — kein Papierkorb.</p>
<div style={{ display: "flex", gap: 8 }}>
<button onClick={() => remove(confirm)} className="btn btn-primary">Ja, löschen</button>
<button onClick={() => setConfirm(null)} className="btn">Abbrechen</button>
</div>
</div>
</div>
)}
</div>
);
}
-1
View File
@@ -10,7 +10,6 @@ const NAV = [
{ href: "/discover", label: "Entdecken" },
{ href: "/downloads", label: "Downloads" },
{ href: "/basket", label: "Korb" },
{ href: "/files", label: "Dateien" },
{ href: "/settings", label: "Einstellungen" },
];
+7 -1
View File
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import FilesBrowser from "../components/FilesBrowser";
type Job = { job_id: string; status: string; progress: number; params: Record<string, unknown>; logs: string[]; error?: string | null };
type Entry = { id: string; source: string; media_type: string; title: string; artist: string; created_at: number };
@@ -16,7 +17,7 @@ function jobTitle(j: Job): string {
}
export default function DownloadsPage() {
const [tab, setTab] = useState<"aktiv" | "verlauf">("aktiv");
const [tab, setTab] = useState<"aktiv" | "verlauf" | "dateien">("aktiv");
const [jobs, setJobs] = useState<Job[]>([]);
const [entries, setEntries] = useState<Entry[]>([]);
const [legacy, setLegacy] = useState<{ id: string }[]>([]);
@@ -78,9 +79,14 @@ export default function DownloadsPage() {
<button onClick={() => setTab("verlauf")} className={tab === "verlauf" ? "btn btn-primary" : "btn"}>
Verlauf
</button>
<button onClick={() => setTab("dateien")} className={tab === "dateien" ? "btn btn-primary" : "btn"}>
Dateien
</button>
</div>
{msg && <p>{msg}</p>}
{tab === "dateien" && <FilesBrowser />}
{tab === "aktiv" && (
<div>
{jobs.length === 0 && <p className="muted">Keine Jobs — starte einen Download über die Suche.</p>}
+3 -75
View File
@@ -1,77 +1,5 @@
"use client";
import { redirect } from "next/navigation";
import { useEffect, useState } from "react";
type Entry = { name: string; type: string; size?: number | null; rel?: string };
export default function FilesPage() {
const [path, setPath] = useState("");
const [entries, setEntries] = useState<Entry[]>([]);
const [confirm, setConfirm] = useState<Entry | null>(null);
async function load(p: string) {
const r = await fetch(`/api/files?path=${encodeURIComponent(p)}`);
if (!r.ok) return;
const b = await r.json();
setPath(b.path ?? "");
setEntries(b.entries ?? []);
}
useEffect(() => {
load("");
}, []);
async function remove(entry: Entry) {
const r = await fetch(`/api/files?path=${encodeURIComponent(entry.rel ?? entry.name)}`, { method: "DELETE" });
setConfirm(null);
if (r.ok) load(path);
else alert(`Löschen fehlgeschlagen (${r.status})`);
}
function up() {
if (!path) return;
const parts = path.split("/").filter(Boolean);
parts.pop();
load(parts.join("/"));
}
return (
<div>
<h1>Dateien</h1>
<p className="muted">/{path}</p>
{path && <button onClick={up} className="btn" style={{ marginBottom: 10 }}>↑ Ebene hoch</button>}
<ul>
{entries.map((e) => (
<li key={e.rel ?? e.name}>
{e.type === "dir" ? (
<button onClick={() => load(e.rel ?? e.name)} className="btn">📁 {e.name}</button>
) : (
<span>
🎵 {e.name}{typeof e.size === "number" ? ` (${Math.round(e.size / 1024 / 1024)} MB)` : ""}{" "}
<a href={`/api/files/download?path=${encodeURIComponent(e.rel ?? e.name)}`} download>
Herunterladen ↓
</a>{" "}
<button onClick={() => setConfirm(e)} className="btn" style={{ padding: "2px 8px" }}>
Löschen
</button>
</span>
)}
</li>
))}
</ul>
{confirm && (
<div style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,.6)", display: "flex", alignItems: "center", justifyContent: "center" }} onClick={() => setConfirm(null)}>
<div className="card" style={{ padding: 20, maxWidth: 400, background: "var(--surface-2)" }} onClick={(ev) => ev.stopPropagation()}>
<h3 style={{ marginTop: 0 }}>Wirklich löschen?</h3>
<p><strong>{confirm.name}</strong>{typeof confirm.size === "number" ? ` (${Math.round(confirm.size / 1024 / 1024)} MB)` : ""}</p>
<p className="muted">Endgültig — kein Papierkorb.</p>
<div style={{ display: "flex", gap: 8 }}>
<button onClick={() => remove(confirm)} className="btn btn-primary">Ja, löschen</button>
<button onClick={() => setConfirm(null)} className="btn">Abbrechen</button>
</div>
</div>
</div>
)}
</div>
);
export default function FilesRedirect() {
redirect("/downloads");
}