webstack-author/src/components/admin/DownloadsManager.tsx
2026-08-18 16:07:15 +02:00

123 lines
9 KiB
TypeScript

import { useEffect, useState } from "react";
import { ArrowDown, ArrowUp, Edit2, Plus, Save, Trash2 } from "lucide-react";
import type { DownloadPublication } from "../../types";
import { adminApi } from "../../api/adminApi";
import ImagePicker from "../ImagePicker";
import PublicationFilePicker from "./PublicationFilePicker";
interface DownloadsManagerProps {
downloads: DownloadPublication[];
title: string;
subtitle: string;
loading: boolean;
onSave: (downloads: DownloadPublication[], title: string, subtitle: string) => Promise<void>;
onError: (message: string) => void;
}
const emptyForm = (): Partial<DownloadPublication> => ({ title: "", description: "", imageUrl: "", pdfUrl: "", epubUrl: "", publishedAt: "" });
async function deleteUnreferencedFiles(urls: Array<string | undefined>) {
await Promise.all(urls.filter(Boolean).map(async (url) => {
const name = url!.split("/").pop();
if (name) await adminApi.deletePublication(name).catch(() => undefined);
}));
}
export default function DownloadsManager({ downloads, title, subtitle, loading, onSave, onError }: DownloadsManagerProps) {
const [sectionTitle, setSectionTitle] = useState(title);
const [sectionSubtitle, setSectionSubtitle] = useState(subtitle);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState<Partial<DownloadPublication>>(emptyForm());
useEffect(() => setSectionTitle(title), [title]);
useEffect(() => setSectionSubtitle(subtitle), [subtitle]);
const persist = async (nextDownloads: DownloadPublication[]) => {
try {
await onSave(nextDownloads, sectionTitle, sectionSubtitle);
} catch (error) {
onError(error instanceof Error ? error.message : "Die Downloads konnten nicht gespeichert werden.");
throw error;
}
};
const saveEntry = async () => {
if (!form.title?.trim()) {
onError("Bitte einen Titel für den Download eintragen.");
return;
}
if (!form.pdfUrl && !form.epubUrl) {
onError("Bitte mindestens eine PDF- oder ePUB-Datei hochladen.");
return;
}
const previous = editingId && editingId !== "new" ? downloads.find((item) => item.id === editingId) : undefined;
const entry: DownloadPublication = {
id: editingId === "new" ? `download_${Date.now()}` : editingId!,
title: form.title.trim(),
description: form.description || "",
imageUrl: form.imageUrl || "",
pdfUrl: form.pdfUrl || "",
epubUrl: form.epubUrl || "",
publishedAt: form.publishedAt || "",
};
const next = editingId === "new" ? [entry, ...downloads] : downloads.map((item) => item.id === editingId ? entry : item);
await persist(next);
await deleteUnreferencedFiles([
previous?.pdfUrl && previous.pdfUrl !== entry.pdfUrl ? previous.pdfUrl : undefined,
previous?.epubUrl && previous.epubUrl !== entry.epubUrl ? previous.epubUrl : undefined,
]);
setEditingId(null);
setForm(emptyForm());
};
const remove = async (entry: DownloadPublication) => {
if (!confirm(`„${entry.title}“ wirklich löschen?`)) return;
await persist(downloads.filter((item) => item.id !== entry.id));
await deleteUnreferencedFiles([entry.pdfUrl, entry.epubUrl]);
};
const move = async (index: number, direction: -1 | 1) => {
const target = index + direction;
if (target < 0 || target >= downloads.length) return;
const next = [...downloads];
[next[index], next[target]] = [next[target], next[index]];
await persist(next);
};
if (editingId) {
return (
<div className="space-y-5 rounded-2xl border border-slate-800 bg-slate-900 p-6">
<h2 className="text-lg font-bold text-white">{editingId === "new" ? "Neuen Download anlegen" : "Download bearbeiten"}</h2>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-1 md:col-span-2"><label className="text-xs font-mono uppercase text-slate-400">Titel</label><input value={form.title || ""} onChange={(event) => setForm({ ...form, title: event.target.value })} className="w-full rounded-xl border border-slate-800 bg-slate-950 px-3 py-2 text-white" /></div>
<div className="space-y-1 md:col-span-2"><label className="text-xs font-mono uppercase text-slate-400">Kurze Beschreibung</label><textarea rows={4} value={form.description || ""} onChange={(event) => setForm({ ...form, description: event.target.value })} className="w-full rounded-xl border border-slate-800 bg-slate-950 px-3 py-2 text-white" /></div>
<div className="space-y-1"><label className="text-xs font-mono uppercase text-slate-400">Veröffentlichungsdatum (optional)</label><input type="date" value={form.publishedAt || ""} onChange={(event) => setForm({ ...form, publishedAt: event.target.value })} className="w-full rounded-xl border border-slate-800 bg-slate-950 px-3 py-2 text-white" /></div>
<div className="space-y-1"><label className="text-xs font-mono uppercase text-slate-400">Optionales Bild</label><ImagePicker value={form.imageUrl || ""} onChange={(url) => setForm({ ...form, imageUrl: url })} label="Download-Bild" /></div>
<div className="space-y-1"><label className="text-xs font-mono uppercase text-slate-400">PDF-Datei</label><PublicationFilePicker format="pdf" value={form.pdfUrl || ""} onChange={(url) => setForm({ ...form, pdfUrl: url })} /></div>
<div className="space-y-1"><label className="text-xs font-mono uppercase text-slate-400">ePUB-Datei</label><PublicationFilePicker format="epub" value={form.epubUrl || ""} onChange={(url) => setForm({ ...form, epubUrl: url })} /></div>
</div>
<div className="flex gap-3 border-t border-slate-800 pt-4"><button type="button" disabled={loading} onClick={saveEntry} className="rounded-lg bg-emerald-600 px-4 py-2 text-xs font-bold text-white"><Save className="mr-1 inline h-4 w-4" />Speichern</button><button type="button" onClick={() => { setEditingId(null); setForm(emptyForm()); }} className="rounded-lg bg-slate-800 px-4 py-2 text-xs text-slate-300">Abbrechen</button></div>
</div>
);
}
return (
<div className="space-y-6">
<div className="space-y-4 rounded-2xl border border-slate-800 bg-slate-900 p-6">
<div className="flex flex-wrap items-center justify-between gap-3"><div><h2 className="text-lg font-bold text-white">Kurzgeschichten & Downloads</h2><p className="text-xs text-slate-400">Die ersten drei Einträge werden öffentlich sofort angezeigt.</p></div><button type="button" onClick={() => { setEditingId("new"); setForm(emptyForm()); }} className="rounded-lg bg-emerald-600 px-4 py-2 text-xs font-bold text-white"><Plus className="mr-1 inline h-4 w-4" />Neuer Download</button></div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2"><div><label className="text-xs font-mono uppercase text-slate-400">Abschnittstitel</label><input value={sectionTitle} onChange={(event) => setSectionTitle(event.target.value)} className="mt-1 w-full rounded-xl border border-slate-800 bg-slate-950 px-3 py-2 text-white" /></div><div><label className="text-xs font-mono uppercase text-slate-400">Untertitel</label><input value={sectionSubtitle} onChange={(event) => setSectionSubtitle(event.target.value)} className="mt-1 w-full rounded-xl border border-slate-800 bg-slate-950 px-3 py-2 text-white" /></div></div>
<button type="button" disabled={loading} onClick={() => persist(downloads)} className="rounded-lg bg-indigo-600 px-4 py-2 text-xs font-bold text-white">Überschriften speichern</button>
</div>
<div className="space-y-3">
{downloads.length === 0 && <p className="rounded-xl border border-dashed border-slate-800 p-8 text-center text-sm text-slate-500">Noch keine Downloads hinterlegt.</p>}
{downloads.map((entry, index) => (
<div key={entry.id} className="flex items-center gap-4 rounded-xl border border-slate-800 bg-slate-900 p-4">
{entry.imageUrl && <img src={entry.imageUrl} alt="" className="h-16 w-16 rounded-lg object-cover" />}
<div className="min-w-0 flex-1"><h3 className="truncate font-bold text-white">{entry.title}</h3><p className="line-clamp-1 text-xs text-slate-400">{entry.description}</p><p className="mt-1 text-[10px] text-slate-500">{entry.pdfUrl ? "PDF " : ""}{entry.epubUrl ? "ePUB" : ""}</p></div>
<div className="flex items-center gap-1"><button type="button" disabled={index === 0} onClick={() => move(index, -1)} className="p-2 text-slate-400 disabled:opacity-20" aria-label="Nach oben"><ArrowUp className="h-4 w-4" /></button><button type="button" disabled={index === downloads.length - 1} onClick={() => move(index, 1)} className="p-2 text-slate-400 disabled:opacity-20" aria-label="Nach unten"><ArrowDown className="h-4 w-4" /></button><button type="button" onClick={() => { setEditingId(entry.id); setForm(entry); }} className="p-2 text-slate-400 hover:text-white" aria-label="Bearbeiten"><Edit2 className="h-4 w-4" /></button><button type="button" onClick={() => remove(entry)} className="p-2 text-rose-400" aria-label="Löschen"><Trash2 className="h-4 w-4" /></button></div>
</div>
))}
</div>
</div>
);
}