Merge pull request #20 from Dada1981/codex/download-library

feat: add downloadable publication library
This commit is contained in:
Dada1981 2026-08-18 16:07:55 +02:00 committed by GitHub
commit b0c6f8d009
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 412 additions and 7 deletions

View file

@ -12,6 +12,7 @@ Technisch besteht die Anwendung aus einem React-/Tailwind-Frontend und einem Exp
- Buchreihen mit Serienname und automatisch sortierter Bandnummer - Buchreihen mit Serienname und automatisch sortierter Bandnummer
- getrennte KDP-Links für E-Book und Taschenbuch - getrennte KDP-Links für E-Book und Taschenbuch
- optionaler, lokal gehosteter PDF-Leseproben-Download pro Buch - optionaler, lokal gehosteter PDF-Leseproben-Download pro Buch
- optionaler Kurzgeschichten- und Download-Bereich mit PDF und ePUB
- Aktuelle Projekte mit Fortschritt, Markdown-Detailtext, Bild und optionaler Spotify-Playlist - Aktuelle Projekte mit Fortschritt, Markdown-Detailtext, Bild und optionaler Spotify-Playlist
- Lokaler Bild-Upload über den Adminbereich - Lokaler Bild-Upload über den Adminbereich
- Verwaltung von Impressum, Datenschutzerklärung und weiteren Rechtstexten - Verwaltung von Impressum, Datenschutzerklärung und weiteren Rechtstexten
@ -177,6 +178,14 @@ Bücher können optional einem Seriennamen und einer Bandnummer zugeordnet werde
Pro Buch kann im Adminbereich eine PDF-Leseprobe mit maximal 10 MB hochgeladen werden. Der Server prüft Dateiendung, MIME-Uploadformat und PDF-Dateikopf und liefert die Datei als Download mit `nosniff` aus. Ohne hinterlegte Datei erscheint kein Leseproben-Button. PDF-Dateien liegen getrennt von Bildern unter `data/downloads/` und müssen daher in Backups eingeschlossen werden. Pro Buch kann im Adminbereich eine PDF-Leseprobe mit maximal 10 MB hochgeladen werden. Der Server prüft Dateiendung, MIME-Uploadformat und PDF-Dateikopf und liefert die Datei als Download mit `nosniff` aus. Ohne hinterlegte Datei erscheint kein Leseproben-Button. PDF-Dateien liegen getrennt von Bildern unter `data/downloads/` und müssen daher in Backups eingeschlossen werden.
## Kurzgeschichten und Downloads
Jedes Profil kann einen eigenen optionalen Download-Bereich pflegen. Einträge bestehen aus Titel, kurzer Markdown-Beschreibung, optionalem Veröffentlichungsdatum, optionalem Bild sowie einer PDF- und/oder ePUB-Datei. Ein Eintrag ohne verfügbare Datei wird öffentlich nicht angezeigt; ohne Einträge verschwinden der gesamte Abschnitt und sein Navigationslink.
Öffentlich erscheinen zunächst höchstens drei Karten. Bei weiteren Einträgen können Besucher mit „Alle Kurzgeschichten anzeigen“ die vollständige Liste einblenden und anschließend wieder einklappen. Die Reihenfolge wird im Adminbereich explizit über Hoch-/Runter-Aktionen gepflegt; neu angelegte Downloads stehen zunächst oben.
PDF und ePUB werden getrennt validiert, lokal unter `data/downloads/` gespeichert und direkt als Download ausgeliefert. Beide Formate sind auf 10 MB begrenzt. Der Server prüft bei PDF den Dateikopf und bei ePUB die ZIP-/ePUB-Struktur. Ersetzte oder gelöschte Dateien werden entfernt, sobald kein veröffentlichter Eintrag mehr auf sie verweist.
## Teilbare Detailansichten ## Teilbare Detailansichten
Buch- und Projektmodale besitzen adressierbare URLs über `?book=<id>` beziehungsweise `?project=<id>`. Auf geeigneten Mobilgeräten öffnet „Teilen“ den nativen Teilen-Dialog; andernfalls wird die aktuelle URL in die Zwischenablage kopiert. Die URL enthält nur die ID innerhalb des aktuell aufgerufenen Profils und ermöglicht keinen Zugriff auf andere Profile. Buch- und Projektmodale besitzen adressierbare URLs über `?book=<id>` beziehungsweise `?project=<id>`. Auf geeigneten Mobilgeräten öffnet „Teilen“ den nativen Teilen-Dialog; andernfalls wird die aktuelle URL in die Zwischenablage kopiert. Die URL enthält nur die ID innerhalb des aktuell aufgerufenen Profils und ermöglicht keinen Zugriff auf andere Profile.
@ -224,6 +233,7 @@ Die Integrationstests verwenden ein temporäres Datenverzeichnis und einen kurzl
- Revisionskonflikte und serverseitige Inhaltsvalidierung, - Revisionskonflikte und serverseitige Inhaltsvalidierung,
- gültige und manipulierte Bild-Uploads, - gültige und manipulierte Bild-Uploads,
- Löschung unbenutzter sowie Schutz referenzierter Uploads, - Löschung unbenutzter sowie Schutz referenzierter Uploads,
- validierte PDF-/ePUB-Uploads und Schutz referenzierter Download-Dateien,
- wesentliche Sicherheitsheader. - wesentliche Sicherheitsheader.
## Hinweise zur Aktualisierung ## Hinweise zur Aktualisierung

View file

@ -5,7 +5,7 @@ import { createHmac, randomBytes, timingSafeEqual } from "crypto";
import dotenv from "dotenv"; import dotenv from "dotenv";
import { GoogleGenAI } from "@google/genai"; import { GoogleGenAI } from "@google/genai";
import { defaultAuthorData } from "./src/defaultData.js"; import { defaultAuthorData } from "./src/defaultData.js";
import { AuthorData, AuthorProfile, CustomSectionLink, PortfolioTheme, PublicAuthorData } from "./src/types.js"; import { AuthorData, AuthorProfile, CustomSectionLink, DownloadPublication, PortfolioTheme, PublicAuthorData } from "./src/types.js";
dotenv.config(); dotenv.config();
@ -47,6 +47,7 @@ const MAX_UPLOAD_BYTES = 8 * 1024 * 1024;
const MAX_PDF_BYTES = 10 * 1024 * 1024; const MAX_PDF_BYTES = 10 * 1024 * 1024;
const SAFE_UPLOAD_NAME = /^(?!\.)[a-zA-Z0-9._-]+\.(?:jpe?g|png|webp|gif|avif)$/i; const SAFE_UPLOAD_NAME = /^(?!\.)[a-zA-Z0-9._-]+\.(?:jpe?g|png|webp|gif|avif)$/i;
const SAFE_PDF_NAME = /^(?!\.)[a-zA-Z0-9._-]+\.pdf$/i; const SAFE_PDF_NAME = /^(?!\.)[a-zA-Z0-9._-]+\.pdf$/i;
const SAFE_DOWNLOAD_NAME = /^(?!\.)[a-zA-Z0-9._-]+\.(?:pdf|epub)$/i;
interface SessionRecord { interface SessionRecord {
expiresAt: number; expiresAt: number;
@ -534,6 +535,7 @@ function validateProfile(profile: unknown): string | null {
const value = profile as AuthorProfile; const value = profile as AuthorProfile;
if (!Array.isArray(value.books) || value.books.length > 500) return "Die Bücherliste ist ungültig oder zu groß."; if (!Array.isArray(value.books) || value.books.length > 500) return "Die Bücherliste ist ungültig oder zu groß.";
if (!Array.isArray(value.projects) || value.projects.length > 200) return "Die Projektliste ist ungültig oder zu groß."; if (!Array.isArray(value.projects) || value.projects.length > 200) return "Die Projektliste ist ungültig oder zu groß.";
if (value.downloads !== undefined && (!Array.isArray(value.downloads) || value.downloads.length > 200)) return "Die Downloadliste ist ungültig oder zu groß.";
const requiredStrings: Array<[unknown, string, number]> = [ const requiredStrings: Array<[unknown, string, number]> = [
[value.name, "Name", 200], [value.bio, "Biografie", 50_000], [value.name, "Name", 200], [value.bio, "Biografie", 50_000],
[value.heroTitle, "Hero-Titel", 500], [value.heroSubtitle, "Hero-Untertitel", 500], [value.heroTitle, "Hero-Titel", 500], [value.heroSubtitle, "Hero-Untertitel", 500],
@ -567,6 +569,17 @@ function validateProfile(profile: unknown): string | null {
return "Mindestens ein Buch enthält ungültige Werte."; return "Mindestens ein Buch enthält ungültige Werte.";
} }
} }
for (const download of value.downloads || []) {
if (!download || typeof download.id !== "string" || download.id.length > 200 ||
typeof download.title !== "string" || !download.title.trim() || download.title.length > 500 ||
typeof download.description !== "string" || download.description.length > 50_000 ||
!isSafeContentUrl(download.imageUrl) ||
(download.pdfUrl !== undefined && download.pdfUrl !== "" && (typeof download.pdfUrl !== "string" || !download.pdfUrl.startsWith("/downloads/") || !SAFE_PDF_NAME.test(path.basename(download.pdfUrl)))) ||
(download.epubUrl !== undefined && download.epubUrl !== "" && (typeof download.epubUrl !== "string" || !download.epubUrl.startsWith("/downloads/") || !/\.epub$/i.test(path.basename(download.epubUrl)))) ||
(download.publishedAt !== undefined && (typeof download.publishedAt !== "string" || download.publishedAt.length > 50))) {
return "Mindestens ein Download enthält ungültige Werte.";
}
}
return null; return null;
} }
@ -594,7 +607,8 @@ function isUploadReferenced(url: string): boolean {
} }
function isDownloadReferenced(url: string): boolean { function isDownloadReferenced(url: string): boolean {
return profileKeys.some((key) => dbCache[key].books.some((book) => book.samplePdfUrl === url)); return profileKeys.some((key) => dbCache[key].books.some((book) => book.samplePdfUrl === url) ||
(dbCache[key].downloads || []).some((download: DownloadPublication) => download.pdfUrl === url || download.epubUrl === url));
} }
function seoMeta(req: express.Request, scriptNonce?: string): string { function seoMeta(req: express.Request, scriptNonce?: string): string {
@ -961,6 +975,49 @@ app.post("/api/admin/upload-sample", verifySession, verifySameOrigin, async (req
} }
}); });
app.post("/api/admin/upload-publication", verifySession, verifySameOrigin, async (req, res) => {
const sessionId = res.locals.sessionId as string;
const rate = rateRecord(uploadRequests, sessionId, UPLOAD_WINDOW_MS);
if (rate.count >= UPLOAD_MAX_REQUESTS) {
res.status(429).json({ error: "Das stündliche Upload-Limit ist erreicht." });
return;
}
rate.count += 1;
const { fileName, base64Data, format } = req.body;
if (typeof fileName !== "string" || typeof base64Data !== "string" || (format !== "pdf" && format !== "epub")) {
res.status(400).json({ error: "Dateiname, Datei und Format sind erforderlich." });
return;
}
if (path.extname(fileName).toLowerCase() !== `.${format}`) {
res.status(400).json({ error: "Dateiendung und gewähltes Format stimmen nicht überein." });
return;
}
const match = base64Data.match(/^data:(?:[^;]+)?;base64,([a-zA-Z0-9+/]+={0,2})$/);
if (!match) {
res.status(400).json({ error: "Das Uploadformat ist ungültig." });
return;
}
const buffer = Buffer.from(match[1], "base64");
if (buffer.length === 0 || buffer.length > MAX_PDF_BYTES) {
res.status(413).json({ error: "Die Datei darf maximal 10 MB groß sein." });
return;
}
const isPdf = buffer.subarray(0, 5).toString("ascii") === "%PDF-";
const isEpub = buffer.length > 58 && buffer[0] === 0x50 && buffer[1] === 0x4b && buffer.includes(Buffer.from("application/epub+zip"));
if ((format === "pdf" && !isPdf) || (format === "epub" && !isEpub)) {
res.status(415).json({ error: `Die Datei besitzt keine gültige ${format.toUpperCase()}-Struktur.` });
return;
}
try {
const generatedName = `${Date.now().toString(36)}-${randomBytes(16).toString("hex")}.${format}`;
await fs.writeFile(path.join(DOWNLOAD_DIR, generatedName), buffer, { flag: "wx" });
res.status(201).json({ success: true, url: `/downloads/${generatedName}`, size: buffer.length, format });
} catch (error) {
console.error("Publication upload failed:", error);
res.status(500).json({ error: "Die Datei konnte nicht gespeichert werden." });
}
});
app.delete("/api/admin/samples/:name", verifySession, verifySameOrigin, async (req, res) => { app.delete("/api/admin/samples/:name", verifySession, verifySameOrigin, async (req, res) => {
const name = req.params.name; const name = req.params.name;
if (!SAFE_PDF_NAME.test(name) || path.basename(name) !== name) { if (!SAFE_PDF_NAME.test(name) || path.basename(name) !== name) {
@ -984,6 +1041,29 @@ app.delete("/api/admin/samples/:name", verifySession, verifySameOrigin, async (r
} }
}); });
app.delete("/api/admin/publications/:name", verifySession, verifySameOrigin, async (req, res) => {
const name = req.params.name;
if (!SAFE_DOWNLOAD_NAME.test(name) || path.basename(name) !== name) {
res.status(400).json({ error: "Ungültiger Dateiname." });
return;
}
const url = `/downloads/${name}`;
if (isDownloadReferenced(url)) {
res.status(409).json({ error: "Die Datei wird noch in einem veröffentlichten Eintrag verwendet." });
return;
}
try {
await fs.unlink(path.join(DOWNLOAD_DIR, name));
res.json({ success: true });
} catch (error: any) {
if (error?.code === "ENOENT") {
res.status(404).json({ error: "Die Datei wurde nicht gefunden." });
return;
}
res.status(500).json({ error: "Die Datei konnte nicht gelöscht werden." });
}
});
// Configure Vite middleware or static serve // Configure Vite middleware or static serve
async function startServer() { async function startServer() {
// Serve the dynamic uploads directory statically // Serve the dynamic uploads directory statically
@ -1007,7 +1087,7 @@ async function startServer() {
})); }));
app.use("/downloads", (req, res, next) => { app.use("/downloads", (req, res, next) => {
const requestedName = path.basename(req.path); const requestedName = path.basename(req.path);
if (!SAFE_PDF_NAME.test(requestedName)) { if (!SAFE_DOWNLOAD_NAME.test(requestedName)) {
res.status(404).end(); res.status(404).end();
return; return;
} }
@ -1017,7 +1097,7 @@ async function startServer() {
dotfiles: "deny", dotfiles: "deny",
fallthrough: false, fallthrough: false,
setHeaders: (res, filePath) => { setHeaders: (res, filePath) => {
res.setHeader("Content-Type", "application/pdf"); res.setHeader("Content-Type", filePath.toLowerCase().endsWith(".epub") ? "application/epub+zip" : "application/pdf");
res.setHeader("Content-Disposition", `attachment; filename="${path.basename(filePath)}"`); res.setHeader("Content-Disposition", `attachment; filename="${path.basename(filePath)}"`);
res.setHeader("X-Content-Type-Options", "nosniff"); res.setHeader("X-Content-Type-Options", "nosniff");
}, },

View file

@ -42,4 +42,8 @@ export const adminApi = {
request<{ success: true; url: string }>("/api/admin/upload-sample", jsonPost({ fileName, base64Data })), request<{ success: true; url: string }>("/api/admin/upload-sample", jsonPost({ fileName, base64Data })),
deleteSample: (name: string) => deleteSample: (name: string) =>
request<{ success: true }>(`/api/admin/samples/${encodeURIComponent(name)}`, { method: "DELETE" }), request<{ success: true }>(`/api/admin/samples/${encodeURIComponent(name)}`, { method: "DELETE" }),
uploadPublication: (fileName: string, base64Data: string, format: "pdf" | "epub") =>
request<{ success: true; url: string }>("/api/admin/upload-publication", jsonPost({ fileName, base64Data, format })),
deletePublication: (name: string) =>
request<{ success: true }>(`/api/admin/publications/${encodeURIComponent(name)}`, { method: "DELETE" }),
}; };

View file

@ -5,11 +5,12 @@ import {
Save, Plus, Trash2, Edit2, Check, RefreshCw, Sparkles, Save, Plus, Trash2, Edit2, Check, RefreshCw, Sparkles,
BookOpen, Compass, Clipboard, Eye, Music, LogOut, ArrowLeftRight, BookOpen, Compass, Clipboard, Eye, Music, LogOut, ArrowLeftRight,
Palette, Type, Image as ImageIcon, Globe, Tag, Palette, Type, Image as ImageIcon, Globe, Tag,
Scale, ShieldCheck, FileText, CheckSquare, Square, Copy, ArrowRight Scale, ShieldCheck, FileText, CheckSquare, Square, Copy, ArrowRight, Download
} from "lucide-react"; } from "lucide-react";
import ImagePicker from "./ImagePicker"; import ImagePicker from "./ImagePicker";
import AdminLogin from "./admin/AdminLogin"; import AdminLogin from "./admin/AdminLogin";
import SamplePdfPicker from "./admin/SamplePdfPicker"; import SamplePdfPicker from "./admin/SamplePdfPicker";
import DownloadsManager from "./admin/DownloadsManager";
import { adminApi, AdminApiError } from "../api/adminApi"; import { adminApi, AdminApiError } from "../api/adminApi";
interface AdminPanelProps { interface AdminPanelProps {
@ -30,7 +31,7 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
const [activeProfile, setActiveProfile] = useState<"scifi" | "erotica" | "clara" | "renee">("scifi"); const [activeProfile, setActiveProfile] = useState<"scifi" | "erotica" | "clara" | "renee">("scifi");
// Sub-tab selection state ("profile" | "texts" | "design" | "books" | "projects" | "legal") // Sub-tab selection state ("profile" | "texts" | "design" | "books" | "projects" | "legal")
const [activeTab, setActiveTab] = useState<"profile" | "texts" | "design" | "books" | "projects" | "legal">("profile"); const [activeTab, setActiveTab] = useState<"profile" | "texts" | "design" | "books" | "projects" | "downloads" | "legal">("profile");
// Editing forms state // Editing forms state
const [editingBookId, setEditingBookId] = useState<string | null>(null); const [editingBookId, setEditingBookId] = useState<string | null>(null);
@ -563,6 +564,13 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
<BookOpen className="w-4 h-4" /> <BookOpen className="w-4 h-4" />
<span>Bücherregal ({profile.books.length})</span> <span>Bücherregal ({profile.books.length})</span>
</button> </button>
<button
onClick={() => setActiveTab("downloads")}
className={`px-3 py-2.5 rounded-xl text-left text-xs font-bold transition-all flex items-center gap-2 cursor-pointer ${activeTab === "downloads" ? "bg-slate-800 border-l-4 border-indigo-500 text-white" : "text-slate-400 hover:bg-slate-800/40 hover:text-white"}`}
>
<Download className="w-4 h-4" />
<span>Downloads ({(profile.downloads || []).length})</span>
</button>
<button <button
onClick={() => { setActiveTab("legal"); setEditingLegalId(null); setLegalForm({}); }} onClick={() => { setActiveTab("legal"); setEditingLegalId(null); setLegalForm({}); }}
className={`px-3 py-2.5 rounded-xl text-left text-xs font-bold transition-all flex items-center gap-2 cursor-pointer ${activeTab === "legal" ? "bg-slate-800 border-l-4 border-indigo-500 text-white" : "text-slate-400 hover:bg-slate-800/40 hover:text-white"}`} className={`px-3 py-2.5 rounded-xl text-left text-xs font-bold transition-all flex items-center gap-2 cursor-pointer ${activeTab === "legal" ? "bg-slate-800 border-l-4 border-indigo-500 text-white" : "text-slate-400 hover:bg-slate-800/40 hover:text-white"}`}
@ -1759,6 +1767,25 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
</div> </div>
)} )}
{activeTab === "downloads" && (
<DownloadsManager
downloads={profile.downloads || []}
title={profile.downloadsTitle || "Kurzgeschichten & Downloads"}
subtitle={profile.downloadsSubtitle || "Kostenlose Geschichten zum Herunterladen."}
loading={loading}
onError={(message) => showNotice(message)}
onSave={async (downloads, downloadsTitle, downloadsSubtitle) => {
setLoading(true);
try {
await persistProfile(activeProfile, { ...profile, downloads, downloadsTitle, downloadsSubtitle });
showNotice("Download-Bereich gespeichert.", "success");
} finally {
setLoading(false);
}
}}
/>
)}
{/* TAB: LEGAL DOCUMENTS (IMPRESSUM & DATENSCHUTZ) */} {/* TAB: LEGAL DOCUMENTS (IMPRESSUM & DATENSCHUTZ) */}
{activeTab === "legal" && ( {activeTab === "legal" && (
<div className="p-6 md:p-8 space-y-8"> <div className="p-6 md:p-8 space-y-8">

View file

@ -1,7 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Markdown from "react-markdown"; import Markdown from "react-markdown";
import { Book, Project, AuthorProfile, LegalDocument, PortfolioTheme } from "../types"; import { Book, Project, AuthorProfile, LegalDocument, PortfolioTheme } from "../types";
import { BookOpen, Star, HelpCircle, Award, Compass, ArrowRight, ExternalLink, Moon, Mail, Instagram, AtSign, Share2, FileText, MessageCircle } from "lucide-react"; import { BookOpen, Star, HelpCircle, Award, Compass, ArrowRight, ExternalLink, Moon, Mail, Instagram, AtSign, Share2, FileText, MessageCircle, Download, ChevronDown, ChevronUp } from "lucide-react";
import SpotlightSection from "./SpotlightSection"; import SpotlightSection from "./SpotlightSection";
import LegalModal from "./LegalModal"; import LegalModal from "./LegalModal";
import ProjectDetailMedia from "./ProjectDetailMedia"; import ProjectDetailMedia from "./ProjectDetailMedia";
@ -36,6 +36,7 @@ export default function PortfolioPage({ data, legalDocuments = [], theme }: Port
const [legalModalOpen, setLegalModalOpen] = useState(false); const [legalModalOpen, setLegalModalOpen] = useState(false);
const [activeLegalId, setActiveLegalId] = useState<string | null>(null); const [activeLegalId, setActiveLegalId] = useState<string | null>(null);
const [shareNotice, setShareNotice] = useState(""); const [shareNotice, setShareNotice] = useState("");
const [showAllDownloads, setShowAllDownloads] = useState(false);
const relevantLegalDocs = legalDocuments; const relevantLegalDocs = legalDocuments;
@ -57,6 +58,8 @@ export default function PortfolioPage({ data, legalDocuments = [], theme }: Port
const seriesComparison = left.book.seriesName.localeCompare(right.book.seriesName, "de"); const seriesComparison = left.book.seriesName.localeCompare(right.book.seriesName, "de");
return seriesComparison || Number(left.book.seriesNumber || 0) - Number(right.book.seriesNumber || 0) || left.index - right.index; return seriesComparison || Number(left.book.seriesNumber || 0) - Number(right.book.seriesNumber || 0) || left.index - right.index;
}).map(({ book }) => book); }).map(({ book }) => book);
const availableDownloads = (data.downloads || []).filter((entry) => entry.pdfUrl || entry.epubUrl);
const visibleDownloads = showAllDownloads ? availableDownloads : availableDownloads.slice(0, 3);
const syncDetailFromUrl = () => { const syncDetailFromUrl = () => {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
@ -100,6 +103,12 @@ export default function PortfolioPage({ data, legalDocuments = [], theme }: Port
} }
}; };
const toggleDownloads = () => {
const isCollapsing = showAllDownloads;
setShowAllDownloads((current) => !current);
if (isCollapsing) window.requestAnimationFrame(() => document.getElementById("downloads")?.scrollIntoView({ behavior: "smooth", block: "start" }));
};
return ( return (
<div <div
className={`min-h-screen text-slate-100 ${fontClass} selection:bg-cyan-500 selection:text-white overflow-x-hidden relative`} className={`min-h-screen text-slate-100 ${fontClass} selection:bg-cyan-500 selection:text-white overflow-x-hidden relative`}
@ -167,6 +176,11 @@ export default function PortfolioPage({ data, legalDocuments = [], theme }: Port
> >
🪐 {data.booksTitle || "Bücherregal"} 🪐 {data.booksTitle || "Bücherregal"}
</a> </a>
{availableDownloads.length > 0 && (
<a href="#downloads" className="px-4 py-2 rounded-full border border-slate-800 bg-slate-900/60 hover:border-cyan-400 hover:text-cyan-400 transition-all text-slate-300 uppercase tracking-wider">
⬇ {data.downloadsTitle || "Downloads"}
</a>
)}
</nav> </nav>
</div> </div>
</header> </header>
@ -352,6 +366,38 @@ export default function PortfolioPage({ data, legalDocuments = [], theme }: Port
</div> </div>
</section> </section>
{availableDownloads.length > 0 && (
<section id="downloads" className="space-y-8 scroll-mt-6">
<div className="border-b border-slate-800 pb-4">
<h2 className="flex items-center gap-3 text-3xl font-bold tracking-tight text-white"><Download className="h-7 w-7" style={{ color: accentColor }} /><span>{data.downloadsTitle || "Kurzgeschichten & Downloads"}</span></h2>
{data.downloadsSubtitle && <p className="mt-1 text-sm text-slate-400">{data.downloadsSubtitle}</p>}
</div>
<div id="download-grid" className="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-3">
{visibleDownloads.map((entry) => (
<article key={entry.id} className="flex flex-col overflow-hidden rounded-2xl border shadow-lg" style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}30` }}>
{entry.imageUrl && <img src={entry.imageUrl} alt={`Motiv zu ${entry.title}`} className="block h-auto w-full" loading="lazy" referrerPolicy="no-referrer" />}
<div className="flex flex-1 flex-col p-6">
{entry.publishedAt && <p className="mb-2 text-[10px] font-mono uppercase tracking-widest text-slate-500">{entry.publishedAt}</p>}
<h3 className="text-xl font-bold text-white">{entry.title}</h3>
{entry.description && <div className="mt-3 flex-1 text-sm leading-relaxed text-slate-300 markdown-body"><Markdown>{entry.description}</Markdown></div>}
<div className="mt-6 flex flex-col gap-2 sm:flex-row">
{entry.pdfUrl && <a href={entry.pdfUrl} className="inline-flex flex-1 items-center justify-center gap-2 rounded-xl border px-4 py-2.5 text-xs font-bold text-white hover:bg-white/10" style={{ borderColor: `${accentColor}60` }}><FileText className="h-4 w-4" /><span>PDF</span><Download className="h-3.5 w-3.5" /></a>}
{entry.epubUrl && <a href={entry.epubUrl} className="inline-flex flex-1 items-center justify-center gap-2 rounded-xl border px-4 py-2.5 text-xs font-bold text-white hover:bg-white/10" style={{ borderColor: `${secondaryColor}60` }}><BookOpen className="h-4 w-4" /><span>ePUB</span><Download className="h-3.5 w-3.5" /></a>}
</div>
</div>
</article>
))}
</div>
{availableDownloads.length > 3 && (
<div className="text-center">
<button type="button" onClick={toggleDownloads} aria-expanded={showAllDownloads} aria-controls="download-grid" className="inline-flex items-center gap-2 rounded-xl border border-slate-700 bg-slate-900 px-5 py-3 text-sm font-semibold text-white hover:bg-slate-800">
{showAllDownloads ? <><ChevronUp className="h-4 w-4" /><span>Weniger anzeigen</span></> : <><ChevronDown className="h-4 w-4" /><span>Alle Kurzgeschichten anzeigen ({availableDownloads.length})</span></>}
</button>
</div>
)}
</section>
)}
{/* Section 4: Custom Highlight Section if present */} {/* Section 4: Custom Highlight Section if present */}
{(data.customSectionTitle || data.customSectionContent || customSectionLinks.length > 0) && ( {(data.customSectionTitle || data.customSectionContent || customSectionLinks.length > 0) && (
<section className="p-8 rounded-2xl border relative overflow-hidden" style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}30` }}> <section className="p-8 rounded-2xl border relative overflow-hidden" style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}30` }}>

View file

@ -0,0 +1,123 @@
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>
);
}

View file

@ -0,0 +1,62 @@
import { useRef, useState } from "react";
import { FileText, Loader2, Trash2, Upload } from "lucide-react";
import { adminApi, AdminApiError } from "../../api/adminApi";
interface PublicationFilePickerProps {
format: "pdf" | "epub";
value: string;
onChange: (url: string) => void;
}
export default function PublicationFilePicker({ format, value, onChange }: PublicationFilePickerProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const upload = (file?: File) => {
if (!file) return;
if (!file.name.toLowerCase().endsWith(`.${format}`)) {
setError(`Bitte eine ${format.toUpperCase()}-Datei auswählen.`);
return;
}
if (file.size > 10 * 1024 * 1024) {
setError("Die Datei darf maximal 10 MB groß sein.");
return;
}
setLoading(true);
setError("");
const reader = new FileReader();
reader.onload = async () => {
try {
const result = await adminApi.uploadPublication(file.name, String(reader.result), format);
onChange(result.url);
} catch (uploadError) {
setError(uploadError instanceof AdminApiError ? uploadError.message : "Upload fehlgeschlagen.");
} finally {
setLoading(false);
}
};
reader.onerror = () => {
setError("Die Datei konnte nicht gelesen werden.");
setLoading(false);
};
reader.readAsDataURL(file);
};
return (
<div className="space-y-2 rounded-xl border border-slate-800 bg-slate-950/50 p-3">
<input ref={inputRef} type="file" accept={format === "pdf" ? "application/pdf,.pdf" : "application/epub+zip,.epub"} className="hidden" onChange={(event) => upload(event.target.files?.[0])} />
{value ? (
<div className="flex items-center justify-between gap-2">
<span className="min-w-0 truncate text-xs text-slate-300"><FileText className="mr-1 inline h-4 w-4" />{value.split("/").pop()}</span>
<button type="button" onClick={() => onChange("")} className="p-1.5 text-slate-400 hover:text-rose-400" aria-label={`${format.toUpperCase()} entfernen`}><Trash2 className="h-4 w-4" /></button>
</div>
) : (
<button type="button" disabled={loading} onClick={() => inputRef.current?.click()} className="flex w-full items-center justify-center gap-2 rounded-lg bg-indigo-600 px-3 py-2 text-xs font-semibold text-white hover:bg-indigo-500 disabled:opacity-60">
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}<span>{format.toUpperCase()} hochladen</span>
</button>
)}
{error && <p role="alert" className="text-xs text-rose-400">{error}</p>}
</div>
);
}

View file

@ -32,6 +32,16 @@ export interface CustomSectionLink {
url: string; url: string;
} }
export interface DownloadPublication {
id: string;
title: string;
description: string;
imageUrl?: string;
pdfUrl?: string;
epubUrl?: string;
publishedAt?: string;
}
export interface AuthorProfile { export interface AuthorProfile {
name: string; name: string;
bio: string; bio: string;
@ -47,6 +57,9 @@ export interface AuthorProfile {
instagramUrl?: string; instagramUrl?: string;
threadsUrl?: string; threadsUrl?: string;
discordUrl?: string; discordUrl?: string;
downloads?: DownloadPublication[];
downloadsTitle?: string;
downloadsSubtitle?: string;
// Domain & Path Routing // Domain & Path Routing
customDomain?: string; // e.g., "annieslone.de, annie-slone.de" customDomain?: string; // e.g., "annieslone.de, annie-slone.de"

View file

@ -212,7 +212,36 @@ test("production hardening and public routing", async (t) => {
assert.equal(response.status, 200); assert.equal(response.status, 200);
assert.match(response.headers.get("content-disposition") || "", /attachment/); assert.match(response.headers.get("content-disposition") || "", /attachment/);
response = await fetch(`${baseUrl}/api/admin/upload-publication`, {
method: "POST",
headers: { "content-type": "application/json", origin: baseUrl, cookie },
body: JSON.stringify({ fileName: "geschichte.pdf", base64Data: validPdf, format: "pdf" }),
});
assert.equal(response.status, 201);
const publicationPdf = await response.json();
const epubBuffer = Buffer.concat([Buffer.from([0x50, 0x4b, 0x03, 0x04]), Buffer.alloc(60), Buffer.from("application/epub+zip")]);
response = await fetch(`${baseUrl}/api/admin/upload-publication`, {
method: "POST",
headers: { "content-type": "application/json", origin: baseUrl, cookie },
body: JSON.stringify({ fileName: "geschichte.epub", base64Data: `data:application/epub+zip;base64,${epubBuffer.toString("base64")}`, format: "epub" }),
});
assert.equal(response.status, 201);
const publicationEpub = await response.json();
response = await fetch(`${baseUrl}${publicationEpub.url}`);
assert.equal(response.status, 200);
assert.equal(response.headers.get("content-type"), "application/epub+zip");
adminData.erotica.books[0].samplePdfUrl = sample.url; adminData.erotica.books[0].samplePdfUrl = sample.url;
adminData.erotica.downloadsTitle = "Kostenlose Geschichten";
adminData.erotica.downloads = [{
id: "download_test",
title: "Testgeschichte",
description: "Eine kurze Beschreibung.",
imageUrl: "",
pdfUrl: publicationPdf.url,
epubUrl: publicationEpub.url,
publishedAt: "2026-08-18",
}];
response = await fetch(`${baseUrl}/api/admin/save-profile`, { response = await fetch(`${baseUrl}/api/admin/save-profile`, {
method: "POST", method: "POST",
headers: { "content-type": "application/json", origin: baseUrl, cookie }, headers: { "content-type": "application/json", origin: baseUrl, cookie },
@ -220,8 +249,14 @@ test("production hardening and public routing", async (t) => {
}); });
assert.equal(response.status, 200); assert.equal(response.status, 200);
const sampleSave = await response.json(); const sampleSave = await response.json();
const downloadPublicResponse = await getWithHost(`${baseUrl}/api/author-data`, "annieslone.de");
const downloadPublicData = JSON.parse(downloadPublicResponse.body);
assert.equal(downloadPublicData.profile.downloads[0].title, "Testgeschichte");
assert.equal(downloadPublicData.profile.downloads[0].epubUrl, publicationEpub.url);
response = await fetch(`${baseUrl}/api/admin/samples/${path.basename(sample.url)}`, { method: "DELETE", headers: { origin: baseUrl, cookie } }); response = await fetch(`${baseUrl}/api/admin/samples/${path.basename(sample.url)}`, { method: "DELETE", headers: { origin: baseUrl, cookie } });
assert.equal(response.status, 409); assert.equal(response.status, 409);
response = await fetch(`${baseUrl}/api/admin/publications/${path.basename(publicationEpub.url)}`, { method: "DELETE", headers: { origin: baseUrl, cookie } });
assert.equal(response.status, 409);
response = await fetch(`${baseUrl}${uploaded.url}`); response = await fetch(`${baseUrl}${uploaded.url}`);
assert.equal(response.status, 200); assert.equal(response.status, 200);
@ -245,6 +280,7 @@ test("production hardening and public routing", async (t) => {
adminData.erotica.avatarUrl = ""; adminData.erotica.avatarUrl = "";
adminData.erotica.books[0].samplePdfUrl = ""; adminData.erotica.books[0].samplePdfUrl = "";
adminData.erotica.downloads = [];
response = await fetch(`${baseUrl}/api/admin/save-profile`, { response = await fetch(`${baseUrl}/api/admin/save-profile`, {
method: "POST", method: "POST",
headers: { "content-type": "application/json", origin: baseUrl, cookie }, headers: { "content-type": "application/json", origin: baseUrl, cookie },
@ -261,6 +297,10 @@ test("production hardening and public routing", async (t) => {
assert.equal(response.status, 404); assert.equal(response.status, 404);
response = await fetch(`${baseUrl}/api/admin/samples/${path.basename(sample.url)}`, { method: "DELETE", headers: { origin: baseUrl, cookie } }); response = await fetch(`${baseUrl}/api/admin/samples/${path.basename(sample.url)}`, { method: "DELETE", headers: { origin: baseUrl, cookie } });
assert.equal(response.status, 200); assert.equal(response.status, 200);
response = await fetch(`${baseUrl}/api/admin/publications/${path.basename(publicationPdf.url)}`, { method: "DELETE", headers: { origin: baseUrl, cookie } });
assert.equal(response.status, 200);
response = await fetch(`${baseUrl}/api/admin/publications/${path.basename(publicationEpub.url)}`, { method: "DELETE", headers: { origin: baseUrl, cookie } });
assert.equal(response.status, 200);
response = await fetch(`${baseUrl}/api/admin/logout`, { method: "POST", headers: { origin: baseUrl, cookie } }); response = await fetch(`${baseUrl}/api/admin/logout`, { method: "POST", headers: { origin: baseUrl, cookie } });
assert.equal(response.status, 200); assert.equal(response.status, 200);