From 040854ff1b64e2948d0de1808ec5f6f4d9557092 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20He=C3=9Fe?= Date: Sat, 15 Aug 2026 10:21:35 +0200 Subject: [PATCH] feat: expand author profiles and book details --- README.md | 25 +++++ server.ts | 104 +++++++++++++++++++- src/App.tsx | 14 +++ src/api/adminApi.ts | 4 + src/components/AdminPanel.tsx | 65 ++++++++++++- src/components/PortfolioPage.tsx | 118 +++++++++++++++++++---- src/components/SpotlightSection.tsx | 12 ++- src/components/admin/SamplePdfPicker.tsx | 68 +++++++++++++ src/types.ts | 8 ++ tests/server.test.mjs | 44 ++++++++- 10 files changed, 430 insertions(+), 32 deletions(-) create mode 100644 src/components/admin/SamplePdfPicker.tsx diff --git a/README.md b/README.md index 432f782..d9a0679 100644 --- a/README.md +++ b/README.md @@ -9,12 +9,18 @@ Technisch besteht die Anwendung aus einem React-/Tailwind-Frontend und einem Exp - Mehrere unabhängig konfigurierte Autorenprofile mit eigenen Domains und Themes - Biografie, Schlagworte, individuelle Texte und optionale Zusatzsektion mit bis zu drei CTA-Buttons - Bücherregal mit Detailansicht, Cover, Kauflink und optionaler Spotify-Playlist +- Buchreihen mit Serienname und automatisch sortierter Bandnummer +- getrennte KDP-Links für E-Book und Taschenbuch +- optionaler, lokal gehosteter PDF-Leseproben-Download pro Buch - Aktuelle Projekte mit Fortschritt, Markdown-Detailtext, Bild und optionaler Spotify-Playlist - Lokaler Bild-Upload über den Adminbereich - Verwaltung von Impressum, Datenschutzerklärung und weiteren Rechtstexten - Optionale serverseitige Gemini-Unterstützung für Klappentexte - Profilabhängige SEO-Metadaten, Open Graph, strukturierte Daten, Sitemap und `robots.txt` - Health-Endpunkte, persistente JSON-Datenhaltung und automatische Migrationsbackups +- optionale Kontaktsektion je Profil für E-Mail, Instagram und Threads +- adressierbare und teilbare Buch-/Projektansichten +- neutrale, nicht indexierbare 404-Seite ohne Hinweise auf andere Profile ## Trennung der Profile @@ -46,6 +52,7 @@ src/defaultData.ts Ausgangsdaten für eine neue Installation server.ts API, Routing, Sessions, Uploads und SEO data/database.json Persistente Inhaltsdaten data/uploads/ Lokal hochgeladene Bilder +data/downloads/ Lokal hochgeladene PDF-Leseproben data/backups/ Automatische Migrationsbackups tests/ Integrations- und Sicherheitstests ``` @@ -133,6 +140,7 @@ Für ein vollständiges Backup sollte der gesamte Ordner `data/` gesichert werde ```text data/database.json data/uploads/ +data/downloads/ data/backups/ ``` @@ -159,6 +167,22 @@ Im zusätzlichen Textabschnitt eines Profils können bis zu drei CTA-Buttons gep Die Buttons öffnen das Ziel in einem neuen Tab und werden automatisch mit den Akzentfarben des Profils gestaltet. Auf kleinen Bildschirmen stehen sie untereinander, auf größeren Bildschirmen nebeneinander. Damit können ausgewählte Pseudonyme bewusst miteinander verknüpft werden, ohne dass daraus eine automatische Verlinkung zu weiteren Profilen entsteht. +## Kontakt und Social Media + +Jedes Profil kann unabhängig eine Kontakt-E-Mail-Adresse sowie Links zu Instagram und Threads erhalten. Der Abschnitt erscheint unterhalb des zusätzlichen Textmoduls und zeigt nur tatsächlich gepflegte Angaben. Es gibt bewusst kein serverseitiges Kontaktformular; dadurch entstehen weder Spam-Endpunkt noch zusätzliche gespeicherte Kontaktdaten. + +## Buchreihen, KDP-Links und Leseproben + +Bücher können optional einem Seriennamen und einer Bandnummer zugeordnet werden. Serien werden im öffentlichen Bücherregal nach Name und Bandnummer sortiert; Einzelbände behalten ihre vorhandene Reihenfolge. Für den Bezug stehen getrennte Links für E-Book und Taschenbuch zur Verfügung. Der historische einzelne Kauflink bleibt für bestehende Daten als E-Book-Fallback kompatibel. + +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. + +## Teilbare Detailansichten + +Buch- und Projektmodale besitzen adressierbare URLs über `?book=` beziehungsweise `?project=`. 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. + +Unbekannte Produktionspfade liefern HTTP 404, `noindex` und eine profilneutrale Fehlerseite. Lokale pfadbasierte Entwicklungsvorschauen bleiben davon unberührt. + ## Spotify und externe Dienste Spotify-Playlists werden sowohl in Buch- als auch in Projektdetails nach dem Zwei-Klick-Prinzip eingebunden. Beim Öffnen eines Details erscheint zunächst nur ein lokaler Platzhalter. Erst nach einem bewussten Klick auf „Spotify-Player laden“ wird das Spotify-`iframe` erzeugt und eine Verbindung zu Spotify hergestellt. @@ -167,6 +191,7 @@ Weitere mögliche externe Verbindungen: - `GEMINI_API_KEY`: Der optionale Admin-Assistent sendet die eingegebenen Buchinformationen serverseitig an die Google-Gemini-API. - Kauf- und sonstige Markdown-Links: Erst ein Klick führt zur jeweiligen externen Website. +- Instagram-, Threads- und bewusst gepflegte Profilverlinkungen: Eine Verbindung entsteht erst beim Klick. - Externe Bild-URLs: Das System unterstützt sie weiterhin, empfohlen werden jedoch lokal hochgeladene Bilder. Die Anwendung enthält kein Analytics- oder Tracking-System und lädt keine externen Webfonts. Sie verwendet für Besucher weder `localStorage` noch `sessionStorage`. Der Browser speichert lediglich das notwendige Admin-Sitzungscookie nach einer Anmeldung. Diese Punkte sowie das übliche Logging des Reverse Proxys/Hosters sollten passend zur tatsächlichen Installation in der Datenschutzerklärung beschrieben werden. diff --git a/server.ts b/server.ts index b6f30e7..3a45f7c 100644 --- a/server.ts +++ b/server.ts @@ -16,6 +16,7 @@ const PORT = Number.parseInt(process.env.PORT || "3000", 10); const DATA_DIR = path.resolve(process.env.DATA_DIR || path.join(process.cwd(), "data")); const DATA_FILE = path.join(DATA_DIR, "database.json"); const BACKUP_DIR = path.join(DATA_DIR, "backups"); +const DOWNLOAD_DIR = path.join(DATA_DIR, "downloads"); const CURRENT_SCHEMA_VERSION = 2; const LEGACY_DEFAULT_IMAGE_URLS = new Set([ "https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?auto=format&fit=crop&q=80&w=300", @@ -43,7 +44,9 @@ const GEMINI_MAX_REQUESTS = 20; const UPLOAD_WINDOW_MS = 60 * 60 * 1000; const UPLOAD_MAX_REQUESTS = 30; const MAX_UPLOAD_BYTES = 8 * 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_PDF_NAME = /^(?!\.)[a-zA-Z0-9._-]+\.pdf$/i; interface SessionRecord { expiresAt: number; @@ -121,6 +124,7 @@ async function createMigrationBackup(rawData: string): Promise { async function initDatabase(): Promise { await fs.mkdir(DATA_DIR, { recursive: true }); await fs.mkdir(path.join(DATA_DIR, "uploads"), { recursive: true }); + await fs.mkdir(DOWNLOAD_DIR, { recursive: true }); try { const existingData = await fs.readFile(DATA_FILE, "utf-8"); if (!existingData.trim()) { @@ -261,7 +265,7 @@ app.use((_req, res, next) => { } next(); }); -app.use(express.json({ limit: "12mb" })); +app.use(express.json({ limit: "15mb" })); // Initialize Google GenAI if API key exists const getGeminiClient = () => { @@ -540,6 +544,8 @@ function validateProfile(profile: unknown): string | null { if (!isSafeContentUrl(value.avatarUrl) || !isSafeContentUrl(value.heroBannerUrl) || !isSafeContentUrl(value.socialImageUrl)) { return "Mindestens eine Bild-URL ist ungültig."; } + if (value.contactEmail && (value.contactEmail.length > 320 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.contactEmail))) return "Die Kontakt-E-Mail-Adresse ist ungültig."; + if (!isSafeContentUrl(value.instagramUrl) || !isSafeContentUrl(value.threadsUrl)) return "Mindestens ein Social-Media-Link ist ungültig."; if (value.fontFamily && !["sans", "serif", "mono"].includes(value.fontFamily)) return "Die Schriftart ist ungültig."; for (const project of value.projects) { if (!project || typeof project.id !== "string" || typeof project.title !== "string" || project.title.length > 500 || @@ -552,7 +558,11 @@ function validateProfile(profile: unknown): string | null { for (const book of value.books) { if (!book || typeof book.id !== "string" || typeof book.title !== "string" || book.title.length > 500 || typeof book.description !== "string" || book.description.length > 100_000 || - !isSafeContentUrl(book.coverUrl) || !isSafeContentUrl(book.buyLink) || !isSafeContentUrl(book.spotifyPlaylistId, true) || + !isSafeContentUrl(book.coverUrl) || !isSafeContentUrl(book.buyLink) || !isSafeContentUrl(book.ebookLink) || !isSafeContentUrl(book.paperbackLink) || + (book.samplePdfUrl !== undefined && book.samplePdfUrl !== "" && (typeof book.samplePdfUrl !== "string" || !book.samplePdfUrl.startsWith("/downloads/") || !SAFE_PDF_NAME.test(path.basename(book.samplePdfUrl)))) || + !isSafeContentUrl(book.spotifyPlaylistId, true) || + (book.seriesName !== undefined && (typeof book.seriesName !== "string" || book.seriesName.length > 300)) || + (book.seriesNumber !== undefined && book.seriesNumber !== "" && (!Number.isFinite(Number(book.seriesNumber)) || Number(book.seriesNumber) < 0 || Number(book.seriesNumber) > 999)) || (book.genres !== undefined && (!Array.isArray(book.genres) || book.genres.length > 20 || book.genres.some((genre) => typeof genre !== "string" || genre.length > 100)))) { return "Mindestens ein Buch enthält ungültige Werte."; } @@ -583,6 +593,10 @@ function isUploadReferenced(url: string): boolean { }); } +function isDownloadReferenced(url: string): boolean { + return profileKeys.some((key) => dbCache[key].books.some((book) => book.samplePdfUrl === url)); +} + function seoMeta(req: express.Request, scriptNonce?: string): string { const key = profileForRequest(req.hostname, req.path); const profile = dbCache[key]; @@ -910,6 +924,66 @@ app.delete("/api/admin/uploads/:name", verifySession, verifySameOrigin, async (r } }); +app.post("/api/admin/upload-sample", 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 } = req.body; + if (typeof fileName !== "string" || typeof base64Data !== "string" || path.extname(fileName).toLowerCase() !== ".pdf") { + res.status(400).json({ error: "Eine PDF-Datei ist erforderlich." }); + return; + } + const match = base64Data.match(/^data:application\/pdf;base64,([a-zA-Z0-9+/]+={0,2})$/); + if (!match) { + res.status(400).json({ error: "Das PDF-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 Leseprobe darf maximal 10 MB groß sein." }); + return; + } + if (buffer.subarray(0, 5).toString("ascii") !== "%PDF-") { + res.status(415).json({ error: "Die Datei besitzt keinen gültigen PDF-Dateikopf." }); + return; + } + try { + const generatedName = `${Date.now().toString(36)}-${randomBytes(16).toString("hex")}.pdf`; + await fs.writeFile(path.join(DOWNLOAD_DIR, generatedName), buffer, { flag: "wx" }); + res.status(201).json({ success: true, url: `/downloads/${generatedName}`, size: buffer.length }); + } catch (error) { + console.error("Sample upload failed:", error); + res.status(500).json({ error: "Die Leseprobe konnte nicht gespeichert werden." }); + } +}); + +app.delete("/api/admin/samples/:name", verifySession, verifySameOrigin, async (req, res) => { + const name = req.params.name; + if (!SAFE_PDF_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 Leseprobe wird noch von einem Buch 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 Leseprobe wurde nicht gefunden." }); + return; + } + res.status(500).json({ error: "Die Leseprobe konnte nicht gelöscht werden." }); + } +}); + // Configure Vite middleware or static serve async function startServer() { // Serve the dynamic uploads directory statically @@ -931,6 +1005,23 @@ async function startServer() { res.setHeader("X-Content-Type-Options", "nosniff"); }, })); + app.use("/downloads", (req, res, next) => { + const requestedName = path.basename(req.path); + if (!SAFE_PDF_NAME.test(requestedName)) { + res.status(404).end(); + return; + } + next(); + }); + app.use("/downloads", express.static(DOWNLOAD_DIR, { + dotfiles: "deny", + fallthrough: false, + setHeaders: (res, filePath) => { + res.setHeader("Content-Type", "application/pdf"); + res.setHeader("Content-Disposition", `attachment; filename="${path.basename(filePath)}"`); + res.setHeader("X-Content-Type-Options", "nosniff"); + }, + })); if (process.env.NODE_ENV !== "production") { const { createServer: createViteServer } = await import("vite"); @@ -959,9 +1050,14 @@ async function startServer() { app.use(express.static(distPath, { index: false })); app.get("*", (req: express.Request, res: express.Response) => { - const html = indexTemplate.replace("\n Autoren-Portfolio", seoMeta(req, res.locals.scriptNonce)); + const isKnownRoute = req.path === "/" || req.path === "/admin" || req.path.startsWith("/admin/"); + const meta = isKnownRoute + ? seoMeta(req, res.locals.scriptNonce) + : 'Seite nicht gefunden\n '; + const html = indexTemplate.replace("\n Autoren-Portfolio", meta); if (req.path.startsWith("/admin")) res.setHeader("X-Robots-Tag", "noindex, nofollow, noarchive"); - res.type("html").send(html); + if (!isKnownRoute) res.setHeader("X-Robots-Tag", "noindex, nofollow, noarchive"); + res.status(isKnownRoute ? 200 : 404).type("html").send(html); }); } diff --git a/src/App.tsx b/src/App.tsx index e691859..7e7cb77 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -82,6 +82,20 @@ export default function App() { ); } + const isLocalPreview = ["localhost", "127.0.0.1"].includes(window.location.hostname); + if (currentPath !== "/" && !isLocalPreview) { + return ( +
+
+

404

+

Seite nicht gefunden

+

Die angeforderte Seite ist nicht verfügbar.

+ Zur Startseite +
+
+ ); + } + return ( }> diff --git a/src/api/adminApi.ts b/src/api/adminApi.ts index 097d243..9575a73 100644 --- a/src/api/adminApi.ts +++ b/src/api/adminApi.ts @@ -38,4 +38,8 @@ export const adminApi = { request<{ success: true; url: string }>("/api/admin/upload-file", jsonPost({ fileName, base64Data })), deleteUpload: (name: string) => request<{ success: true }>(`/api/admin/uploads/${encodeURIComponent(name)}`, { method: "DELETE" }), + uploadSample: (fileName: string, base64Data: string) => + request<{ success: true; url: string }>("/api/admin/upload-sample", jsonPost({ fileName, base64Data })), + deleteSample: (name: string) => + request<{ success: true }>(`/api/admin/samples/${encodeURIComponent(name)}`, { method: "DELETE" }), }; diff --git a/src/components/AdminPanel.tsx b/src/components/AdminPanel.tsx index b1e4340..c80cdf3 100644 --- a/src/components/AdminPanel.tsx +++ b/src/components/AdminPanel.tsx @@ -9,6 +9,7 @@ import { } from "lucide-react"; import ImagePicker from "./ImagePicker"; import AdminLogin from "./admin/AdminLogin"; +import SamplePdfPicker from "./admin/SamplePdfPicker"; import { adminApi, AdminApiError } from "../api/adminApi"; interface AdminPanelProps { @@ -238,6 +239,7 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) { const parsedGenres = genreInputRaw.split(",").map(g => g.trim()).filter(Boolean); let updatedBooks = [...profile.books]; + const previousSample = editingBookId && editingBookId !== "new" ? profile.books.find((book) => book.id === editingBookId)?.samplePdfUrl : undefined; // If current book is marked as spotlight, clear spotlight flag on all other books if (bookForm.isSpotlight) { @@ -253,6 +255,11 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) { description: bookForm.description || "", publishedYear: bookForm.publishedYear || new Date().getFullYear(), buyLink: bookForm.buyLink || "", + ebookLink: bookForm.ebookLink || bookForm.buyLink || "", + paperbackLink: bookForm.paperbackLink || "", + samplePdfUrl: bookForm.samplePdfUrl || "", + seriesName: bookForm.seriesName || "", + seriesNumber: bookForm.seriesNumber || "", spotifyPlaylistId: bookForm.spotifyPlaylistId || "", genres: parsedGenres, isSpotlight: !!bookForm.isSpotlight, @@ -275,6 +282,11 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) { setLoading(true); try { await persistProfile(activeProfile, updatedProfile); + const savedSample = updatedBooks.find((book) => book.id === editingBookId || (editingBookId === "new" && book === updatedBooks.at(-1)))?.samplePdfUrl; + if (previousSample && previousSample !== savedSample) { + const name = previousSample.split("/").pop(); + if (name) adminApi.deleteSample(name).catch(() => undefined); + } setEditingBookId(null); setBookForm({}); } catch (err) { @@ -287,11 +299,14 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) { const handleDeleteBook = async (bookId: string) => { if (!authorData || !profile || !confirm("Möchten Sie dieses Buch aus dem Bücherregal löschen?")) return; + const removedSample = profile.books.find((book) => book.id === bookId)?.samplePdfUrl; const updatedBooks = profile.books.filter(b => b.id !== bookId); const updatedProfile = { ...profile, books: updatedBooks }; setLoading(true); try { await persistProfile(activeProfile, updatedProfile); + const name = removedSample?.split("/").pop(); + if (name) adminApi.deleteSample(name).catch(() => undefined); } catch (err) { showNotice(err instanceof AdminApiError ? err.message : "Netzwerkfehler."); } finally { @@ -836,6 +851,31 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) { + + {/* Contact Section */} +
+
+

+ + Kontakt & Social Media +

+

Der Kontaktabschnitt erscheint unter dem zusätzlichen Textmodul. Es werden ausschließlich ausgefüllte Angaben angezeigt.

+
+
+
+ + updateProfileField("contactEmail", event.target.value)} placeholder="kontakt@autorin.de" className="w-full px-3 py-2 bg-slate-950 border border-slate-800 rounded-xl text-white outline-none focus:border-indigo-500 text-sm" /> +
+
+ + updateProfileField("instagramUrl", event.target.value)} placeholder="https://www.instagram.com/…" className="w-full px-3 py-2 bg-slate-950 border border-slate-800 rounded-xl text-white outline-none focus:border-indigo-500 text-sm" /> +
+
+ + updateProfileField("threadsUrl", event.target.value)} placeholder="https://www.threads.net/@…" className="w-full px-3 py-2 bg-slate-950 border border-slate-800 rounded-xl text-white outline-none focus:border-indigo-500 text-sm" /> +
+
+
)} @@ -1386,6 +1426,7 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) { setBookForm({ title: "", subtitle: "", coverUrl: "", description: "", publishedYear: new Date().getFullYear(), buyLink: "", + ebookLink: "", paperbackLink: "", samplePdfUrl: "", seriesName: "", seriesNumber: "", spotifyPlaylistId: "", genres: [], isSpotlight: false, spotlightBadge: "" }); setGenreInputRaw(""); @@ -1521,6 +1562,14 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) { className="w-full px-3 py-2 bg-slate-950 border border-slate-800 rounded-xl text-white outline-none focus:border-indigo-500 text-sm" /> +
+ + setBookForm({ ...bookForm, seriesName: event.target.value })} placeholder="z.B. Arche-Chroniken" className="w-full px-3 py-2 bg-slate-950 border border-slate-800 rounded-xl text-white outline-none focus:border-indigo-500 text-sm" /> +
+
+ + setBookForm({ ...bookForm, seriesNumber: event.target.value })} placeholder="1" className="w-full px-3 py-2 bg-slate-950 border border-slate-800 rounded-xl text-white outline-none focus:border-indigo-500 text-sm" /> +
setBookForm({ ...bookForm, buyLink: e.target.value })} + type="url" + value={bookForm.ebookLink || bookForm.buyLink || ""} + onChange={(e) => setBookForm({ ...bookForm, ebookLink: e.target.value, buyLink: "" })} placeholder="https://..." className="w-full px-3 py-2 bg-slate-950 border border-slate-800 rounded-xl text-white outline-none focus:border-indigo-500 text-sm" />
+
+ + setBookForm({ ...bookForm, paperbackLink: event.target.value })} placeholder="https://..." className="w-full px-3 py-2 bg-slate-950 border border-slate-800 rounded-xl text-white outline-none focus:border-indigo-500 text-sm" /> +
+
+ + setBookForm({ ...bookForm, samplePdfUrl: url })} /> +
{/* Buch-Klappentext Synopsis / Description */}
diff --git a/src/components/PortfolioPage.tsx b/src/components/PortfolioPage.tsx index f358df3..68d484a 100644 --- a/src/components/PortfolioPage.tsx +++ b/src/components/PortfolioPage.tsx @@ -1,7 +1,7 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import Markdown from "react-markdown"; import { Book, Project, AuthorProfile, LegalDocument, PortfolioTheme } from "../types"; -import { BookOpen, Star, HelpCircle, Award, Compass, ArrowRight, ExternalLink, Moon } from "lucide-react"; +import { BookOpen, Star, HelpCircle, Award, Compass, ArrowRight, ExternalLink, Moon, Mail, Instagram, AtSign, Share2, FileText } from "lucide-react"; import SpotlightSection from "./SpotlightSection"; import LegalModal from "./LegalModal"; import ProjectDetailMedia from "./ProjectDetailMedia"; @@ -35,6 +35,7 @@ export default function PortfolioPage({ data, legalDocuments = [], theme }: Port const [selectedProject, setSelectedProject] = useState(null); const [legalModalOpen, setLegalModalOpen] = useState(false); const [activeLegalId, setActiveLegalId] = useState(null); + const [shareNotice, setShareNotice] = useState(""); const relevantLegalDocs = legalDocuments; @@ -49,6 +50,55 @@ export default function PortfolioPage({ data, legalDocuments = [], theme }: Port const url = safeExternalUrl(link.url); return link.label.trim() && url ? [{ label: link.label.trim(), url }] : []; }); + const displayedBooks = data.books.map((book, index) => ({ book, index })).sort((left, right) => { + if (!left.book.seriesName && !right.book.seriesName) return left.index - right.index; + if (!left.book.seriesName) return 1; + if (!right.book.seriesName) return -1; + 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; + }).map(({ book }) => book); + + const syncDetailFromUrl = () => { + const params = new URLSearchParams(window.location.search); + setSelectedBook(data.books.find((book) => book.id === params.get("book")) || null); + setSelectedProject(data.projects.find((project) => project.id === params.get("project")) || null); + }; + + useEffect(() => { + syncDetailFromUrl(); + window.addEventListener("popstate", syncDetailFromUrl); + return () => window.removeEventListener("popstate", syncDetailFromUrl); + }, [data]); + + const openDetail = (kind: "book" | "project", id: string) => { + const url = new URL(window.location.href); + url.search = ""; + url.searchParams.set(kind, id); + window.history.pushState({}, "", url); + syncDetailFromUrl(); + }; + + const closeDetail = () => { + const url = new URL(window.location.href); + url.search = ""; + window.history.replaceState({}, "", url); + setSelectedBook(null); + setSelectedProject(null); + }; + + const shareDetail = async (title: string) => { + try { + if (navigator.share) await navigator.share({ title, url: window.location.href }); + else { + await navigator.clipboard.writeText(window.location.href); + setShareNotice("Link wurde kopiert."); + window.setTimeout(() => setShareNotice(""), 3000); + } + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") return; + setShareNotice("Der Link konnte nicht kopiert werden."); + } + }; return (
(
- {data.books.map((book) => ( + {displayedBooks.map((book) => ( @@ -355,15 +406,41 @@ export default function PortfolioPage({ data, legalDocuments = [], theme }: Port )} + {(data.contactEmail || data.instagramUrl || data.threadsUrl) && ( +
+

Kontakt & Social Media

+

Weitere Neuigkeiten, Einblicke und Kontaktmöglichkeiten.

+
+ {data.contactEmail && ( + + E-Mail + + )} + {safeExternalUrl(data.instagramUrl || "") && ( + + Instagram + + )} + {safeExternalUrl(data.threadsUrl || "") && ( + + Threads + + )} +
+
+ )} + + {shareNotice &&
{shareNotice}
} + {/* Book details modular interface modal */} {selectedBook && ( - setSelectedBook(null)}> + {/* Close Button */}
- {selectedBook.buyLink && ( - - Zum Buch - - - )} +
+ {(selectedBook.ebookLink || selectedBook.buyLink) && E-Book} + {selectedBook.paperbackLink && Taschenbuch} + {selectedBook.samplePdfUrl && Leseprobe (PDF)} +
{/* Book Details */} @@ -417,6 +488,11 @@ export default function PortfolioPage({ data, legalDocuments = [], theme }: Port {selectedBook.subtitle} )} + {selectedBook.seriesName && ( +

{selectedBook.seriesName}{selectedBook.seriesNumber !== undefined && selectedBook.seriesNumber !== "" ? ` · Band ${selectedBook.seriesNumber}` : ""}

+ )} + +
@@ -439,11 +515,11 @@ export default function PortfolioPage({ data, legalDocuments = [], theme }: Port {/* Project details modular modal */} {selectedProject && ( - setSelectedProject(null)} maxWidthClass="sm:max-w-5xl"> + {/* Close Button */} +
diff --git a/src/components/SpotlightSection.tsx b/src/components/SpotlightSection.tsx index f278cf0..7bf03f8 100644 --- a/src/components/SpotlightSection.tsx +++ b/src/components/SpotlightSection.tsx @@ -101,23 +101,29 @@ export default function SpotlightSection({
- {book.buyLink && ( + {(book.ebookLink || book.buyLink) && ( - {isFuture ? "Jetzt vorbestellen" : "Jetzt im Handel"} + {isFuture ? "E-Book vorbestellen" : "E-Book"} )} + {book.paperbackLink && ( + + Taschenbuch + + )}
{/* Details column */}
+ {book.seriesName &&

{book.seriesName}{book.seriesNumber !== undefined && book.seriesNumber !== "" ? ` · Band ${book.seriesNumber}` : ""}

}
void; +} + +export default function SamplePdfPicker({ value, onChange }: SamplePdfPickerProps) { + const inputRef = useRef(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + const upload = (file?: File) => { + if (!file) return; + if (file.type !== "application/pdf" || !file.name.toLowerCase().endsWith(".pdf")) { + setError("Bitte eine PDF-Datei auswählen."); + return; + } + if (file.size > 10 * 1024 * 1024) { + setError("Die Leseprobe darf maximal 10 MB groß sein."); + return; + } + setLoading(true); + setError(""); + const reader = new FileReader(); + reader.onload = async () => { + try { + const result = await adminApi.uploadSample(file.name, String(reader.result)); + 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 ( +
+ upload(event.target.files?.[0])} /> + {value ? ( +
+
+ + {value.split("/").pop()} +
+ +
+ ) : ( + + )} +

PDF, maximal 10 MB. Ohne Datei erscheint kein Download.

+ {error &&

{error}

} +
+ ); +} diff --git a/src/types.ts b/src/types.ts index 076fdb2..b57f4c5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -16,6 +16,11 @@ export interface Book { description: string; publishedYear: number | string; // Accepts year (2026) or full date string (15.10.2026, Herbst 2026) buyLink?: string; + ebookLink?: string; + paperbackLink?: string; + samplePdfUrl?: string; + seriesName?: string; + seriesNumber?: number | string; spotifyPlaylistId?: string; // Just the playlist ID (e.g., "47R6ZAdk7Xf6M4i9mR0fP1") genres?: string[]; isSpotlight?: boolean; // Highlighted as spotlight / new release @@ -38,6 +43,9 @@ export interface AuthorProfile { customSectionTitle?: string; customSectionContent?: string; customSectionLinks?: CustomSectionLink[]; + contactEmail?: string; + instagramUrl?: string; + threadsUrl?: string; // Domain & Path Routing customDomain?: string; // e.g., "annieslone.de, annie-slone.de" diff --git a/tests/server.test.mjs b/tests/server.test.mjs index acb4028..3e60f1e 100644 --- a/tests/server.test.mjs +++ b/tests/server.test.mjs @@ -119,6 +119,12 @@ test("production hardening and public routing", async (t) => { response = await fetch(`${baseUrl}/api/admin/author-data`, { headers: { cookie } }); assert.equal(response.status, 200); const adminData = await response.json(); + adminData.erotica.contactEmail = "kontakt@example.test"; + adminData.erotica.instagramUrl = "https://instagram.com/example-author"; + adminData.erotica.books[0].seriesName = "Beispiel-Reihe"; + adminData.erotica.books[0].seriesNumber = 1; + adminData.erotica.books[0].ebookLink = "https://amazon.example/ebook"; + adminData.erotica.books[0].paperbackLink = "https://amazon.example/paperback"; adminData.erotica.customSectionLinks = [ { label: "Zum befreundeten Pseudonym", url: "https://example-pseudonym.test/" }, ]; @@ -159,6 +165,9 @@ test("production hardening and public routing", async (t) => { assert.deepEqual(publicData.profile.customSectionLinks, [ { label: "Zum befreundeten Pseudonym", url: "https://example-pseudonym.test/" }, ]); + assert.equal(publicData.profile.contactEmail, "kontakt@example.test"); + assert.equal(publicData.profile.books[0].seriesName, "Beispiel-Reihe"); + assert.equal(publicData.profile.books[0].seriesNumber, 1); assert.equal(publicResponse.body.includes("Clara Finch"), false); assert.equal(publicResponse.body.includes("Renee Heart"), false); assert.equal(publicResponse.body.includes("Daniel Hesse"), false); @@ -188,6 +197,30 @@ test("production hardening and public routing", async (t) => { response = await upload("wrong.jpg", validPng); assert.equal(response.status, 415); + const validPdf = `data:application/pdf;base64,${Buffer.from("%PDF-1.4\n%%EOF").toString("base64")}`; + response = await fetch(`${baseUrl}/api/admin/upload-sample`, { + method: "POST", + headers: { "content-type": "application/json", origin: baseUrl, cookie }, + body: JSON.stringify({ fileName: "leseprobe.pdf", base64Data: validPdf }), + }); + assert.equal(response.status, 201); + const sample = await response.json(); + assert.match(sample.url, /^\/downloads\/[a-z0-9-]+\.pdf$/); + response = await fetch(`${baseUrl}${sample.url}`); + assert.equal(response.status, 200); + assert.match(response.headers.get("content-disposition") || "", /attachment/); + + adminData.erotica.books[0].samplePdfUrl = sample.url; + response = await fetch(`${baseUrl}/api/admin/save-profile`, { + method: "POST", + headers: { "content-type": "application/json", origin: baseUrl, cookie }, + body: JSON.stringify({ profileKey: "erotica", profileData: adminData.erotica, expectedRevision: firstSave.revision }), + }); + assert.equal(response.status, 200); + const sampleSave = await response.json(); + response = await fetch(`${baseUrl}/api/admin/samples/${path.basename(sample.url)}`, { method: "DELETE", headers: { origin: baseUrl, cookie } }); + assert.equal(response.status, 409); + response = await fetch(`${baseUrl}${uploaded.url}`); assert.equal(response.status, 200); assert.equal(response.headers.get("x-content-type-options"), "nosniff"); @@ -197,7 +230,7 @@ test("production hardening and public routing", async (t) => { response = await fetch(`${baseUrl}/api/admin/save-profile`, { method: "POST", headers: { "content-type": "application/json", origin: baseUrl, cookie }, - body: JSON.stringify({ profileKey: "erotica", profileData: adminData.erotica, expectedRevision: firstSave.revision }), + body: JSON.stringify({ profileKey: "erotica", profileData: adminData.erotica, expectedRevision: sampleSave.revision }), }); assert.equal(response.status, 200); const referencedSave = await response.json(); @@ -209,6 +242,7 @@ test("production hardening and public routing", async (t) => { assert.equal(response.status, 409); adminData.erotica.avatarUrl = ""; + adminData.erotica.books[0].samplePdfUrl = ""; response = await fetch(`${baseUrl}/api/admin/save-profile`, { method: "POST", headers: { "content-type": "application/json", origin: baseUrl, cookie }, @@ -223,6 +257,8 @@ test("production hardening and public routing", async (t) => { assert.equal(response.status, 200); response = await fetch(`${baseUrl}${uploaded.url}`); assert.equal(response.status, 404); + response = await fetch(`${baseUrl}/api/admin/samples/${path.basename(sample.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 } }); assert.equal(response.status, 200); @@ -243,6 +279,12 @@ test("production hardening and public routing", async (t) => { response = await fetch(`${baseUrl}/`); assert.equal(response.headers.get("x-frame-options"), "DENY"); assert.ok(response.headers.get("content-security-policy")); + response = await fetch(`${baseUrl}/does-not-exist`); + assert.equal(response.status, 404); + assert.match(response.headers.get("x-robots-tag") || "", /noindex/); + const notFoundBody = await response.text(); + assert.match(notFoundBody, /Seite nicht gefunden/); + assert.equal(notFoundBody.includes("Daniel Hesse"), false); } finally { await stopServer(child); await rm(dataDir, { recursive: true, force: true }); -- 2.45.2