feat: expand author profiles and book details #18

Merged
Dada1981 merged 1 commit from codex/author-card-feature-suite into main 2026-08-15 18:19:36 +00:00
10 changed files with 430 additions and 32 deletions

View file

@ -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=<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.
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.

104
server.ts
View file

@ -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<void> {
async function initDatabase(): Promise<AuthorData> {
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("<!-- SEO_META -->\n <title>Autoren-Portfolio</title>", seoMeta(req, res.locals.scriptNonce));
const isKnownRoute = req.path === "/" || req.path === "/admin" || req.path.startsWith("/admin/");
const meta = isKnownRoute
? seoMeta(req, res.locals.scriptNonce)
: '<title>Seite nicht gefunden</title>\n <meta name="robots" content="noindex, nofollow, noarchive" />';
const html = indexTemplate.replace("<!-- SEO_META -->\n <title>Autoren-Portfolio</title>", 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);
});
}

View file

@ -82,6 +82,20 @@ export default function App() {
);
}
const isLocalPreview = ["localhost", "127.0.0.1"].includes(window.location.hostname);
if (currentPath !== "/" && !isLocalPreview) {
return (
<div className="min-h-screen bg-slate-950 text-slate-100 flex items-center justify-center p-4">
<div className="max-w-md rounded-2xl border border-slate-800 bg-slate-900 p-8 text-center space-y-4">
<p className="text-xs font-mono uppercase tracking-widest text-slate-500">404</p>
<h1 className="text-2xl font-bold">Seite nicht gefunden</h1>
<p className="text-sm text-slate-400">Die angeforderte Seite ist nicht verfügbar.</p>
<a href="/" className="inline-flex rounded-xl bg-slate-100 px-4 py-2 text-sm font-semibold text-slate-950">Zur Startseite</a>
</div>
</div>
);
}
return (
<Suspense fallback={<LoadingScreen />}>
<PortfolioPage data={publicData.profile} legalDocuments={publicData.legalDocuments} theme={publicData.theme} />

View file

@ -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" }),
};

View file

@ -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) {
</div>
</div>
</div>
{/* Contact Section */}
<div className="md:col-span-2 border-t border-slate-800/80 pt-6 mt-2 space-y-4">
<div>
<h4 className="text-sm font-bold text-white flex items-center gap-2">
<Globe className="w-4 h-4 text-emerald-400" />
<span>Kontakt & Social Media</span>
</h4>
<p className="text-xs text-slate-500">Der Kontaktabschnitt erscheint unter dem zusätzlichen Textmodul. Es werden ausschließlich ausgefüllte Angaben angezeigt.</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-1">
<label className="text-xs font-mono text-slate-400 uppercase">E-Mail-Adresse</label>
<input type="email" value={profile.contactEmail || ""} onChange={(event) => 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" />
</div>
<div className="space-y-1">
<label className="text-xs font-mono text-slate-400 uppercase">Instagram-Profil</label>
<input type="url" value={profile.instagramUrl || ""} onChange={(event) => 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" />
</div>
<div className="space-y-1">
<label className="text-xs font-mono text-slate-400 uppercase">Threads-Profil</label>
<input type="url" value={profile.threadsUrl || ""} onChange={(event) => 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" />
</div>
</div>
</div>
</div>
</div>
)}
@ -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"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-mono text-slate-400 uppercase">Serienname (optional)</label>
<input type="text" value={bookForm.seriesName || ""} onChange={(event) => 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" />
</div>
<div className="space-y-1">
<label className="text-xs font-mono text-slate-400 uppercase">Bandnummer (optional)</label>
<input type="number" min="0" max="999" step="1" value={bookForm.seriesNumber || ""} onChange={(event) => 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" />
</div>
<div className="space-y-1">
<label className="text-xs font-mono text-slate-400 uppercase font-bold text-white">Cover-Bild</label>
<ImagePicker
@ -1543,16 +1592,20 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
<div className="space-y-1">
<label className="text-xs font-mono text-slate-400 uppercase flex items-center gap-1">
<Eye className="w-3.5 h-3.5" />
<span>Buy-Link / Shop-Link (z.B. Amazon, Thalia)</span>
<span>KDP-Link: E-Book</span>
</label>
<input
type="text"
value={bookForm.buyLink || ""}
onChange={(e) => 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"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-mono text-slate-400 uppercase flex items-center gap-1"><BookOpen className="w-3.5 h-3.5" /><span>KDP-Link: Taschenbuch</span></label>
<input type="url" value={bookForm.paperbackLink || ""} onChange={(event) => 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" />
</div>
<div className="space-y-1">
<label className="text-xs font-mono text-slate-400 uppercase flex items-center gap-1">
<Music className="w-3.5 h-3.5" />
@ -1578,6 +1631,10 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
/>
<p className="text-[10px] text-slate-500">Trennen Sie mehrere Genres einfach durch Kommas.</p>
</div>
<div className="space-y-1 md:col-span-2">
<label className="text-xs font-mono text-slate-400 uppercase">PDF-Leseprobe (optional)</label>
<SamplePdfPicker value={bookForm.samplePdfUrl || ""} onChange={(url) => setBookForm({ ...bookForm, samplePdfUrl: url })} />
</div>
{/* Buch-Klappentext Synopsis / Description */}
<div className="space-y-1 md:col-span-2">

View file

@ -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<Project | null>(null);
const [legalModalOpen, setLegalModalOpen] = useState(false);
const [activeLegalId, setActiveLegalId] = useState<string | null>(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 (
<div
@ -215,7 +265,7 @@ export default function PortfolioPage({ data, legalDocuments = [], theme }: Port
{data.projects.map((project) => (
<button
key={project.id}
onClick={() => setSelectedProject(project)}
onClick={() => openDetail("project", project.id)}
className="p-6 border rounded-xl transition-all duration-300 flex flex-col text-left w-full justify-between group cursor-pointer hover:scale-[1.01] focus:outline-none shadow-lg"
style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}25` }}
>
@ -277,10 +327,10 @@ export default function PortfolioPage({ data, legalDocuments = [], theme }: Port
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-6 justify-items-center">
{data.books.map((book) => (
{displayedBooks.map((book) => (
<button
key={book.id}
onClick={() => setSelectedBook(book)}
onClick={() => openDetail("book", book.id)}
className="group relative focus:outline-none text-left w-full max-w-[210px] aspect-[2/3] rounded-xl overflow-hidden border hover:border-cyan-400 transition-all duration-300 hover:shadow-xl cursor-pointer"
style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}30` }}
>
@ -294,6 +344,7 @@ export default function PortfolioPage({ data, legalDocuments = [], theme }: Port
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-slate-950 via-slate-950/90 to-transparent p-4 opacity-80 group-hover:opacity-100 transition-all duration-350 z-20">
<p className="text-white text-xs font-bold truncate">{book.title}</p>
{book.seriesName && <p className="text-[9px] text-slate-300 truncate">{book.seriesName}{book.seriesNumber !== undefined && book.seriesNumber !== "" ? ` · Band ${book.seriesNumber}` : ""}</p>}
<p className="text-[10px] font-mono mt-0.5" style={{ color: accentColor }}>Details anzeigen <ArrowRight className="inline-block w-2.5 h-2.5 ml-0.5" /></p>
</div>
</button>
@ -355,15 +406,41 @@ export default function PortfolioPage({ data, legalDocuments = [], theme }: Port
</section>
)}
{(data.contactEmail || data.instagramUrl || data.threadsUrl) && (
<section className="rounded-2xl border p-8 text-center" style={{ backgroundColor: cardBgColor, borderColor: `${secondaryColor}35` }}>
<h2 className="text-2xl font-bold text-white">Kontakt & Social Media</h2>
<p className="mt-2 text-sm text-slate-400">Weitere Neuigkeiten, Einblicke und Kontaktmöglichkeiten.</p>
<div className="mt-6 flex flex-col sm:flex-row sm:flex-wrap justify-center gap-3">
{data.contactEmail && (
<a href={`mailto:${data.contactEmail}`} className="inline-flex items-center justify-center gap-2 rounded-xl border px-5 py-3 text-sm font-semibold text-white transition-colors hover:bg-white/10" style={{ borderColor: `${accentColor}60` }}>
<Mail className="h-4 w-4" /><span>E-Mail</span>
</a>
)}
{safeExternalUrl(data.instagramUrl || "") && (
<a href={safeExternalUrl(data.instagramUrl || "")!} target="_blank" rel="noopener noreferrer" className="inline-flex items-center justify-center gap-2 rounded-xl border px-5 py-3 text-sm font-semibold text-white transition-colors hover:bg-white/10" style={{ borderColor: `${accentColor}60` }}>
<Instagram className="h-4 w-4" /><span>Instagram</span>
</a>
)}
{safeExternalUrl(data.threadsUrl || "") && (
<a href={safeExternalUrl(data.threadsUrl || "")!} target="_blank" rel="noopener noreferrer" className="inline-flex items-center justify-center gap-2 rounded-xl border px-5 py-3 text-sm font-semibold text-white transition-colors hover:bg-white/10" style={{ borderColor: `${accentColor}60` }}>
<AtSign className="h-4 w-4" /><span>Threads</span>
</a>
)}
</div>
</section>
)}
</main>
{shareNotice && <div role="status" aria-live="polite" className="fixed bottom-5 left-1/2 z-[70] -translate-x-1/2 rounded-xl border border-slate-700 bg-slate-900 px-4 py-2 text-sm text-white shadow-xl">{shareNotice}</div>}
{/* Book details modular interface modal */}
{selectedBook && (
<AccessibleModal titleId="book-detail-title" onClose={() => setSelectedBook(null)}>
<AccessibleModal titleId="book-detail-title" onClose={closeDetail}>
{/* Close Button */}
<button
onClick={() => setSelectedBook(null)}
onClick={closeDetail}
className="absolute top-4 right-4 text-slate-400 hover:text-white hover:bg-slate-800/80 px-2.5 py-1 rounded-md text-xs font-mono border border-slate-800 transition-all cursor-pointer"
>
SCHLIESSEN [ESC]
@ -381,17 +458,11 @@ export default function PortfolioPage({ data, legalDocuments = [], theme }: Port
referrerPolicy="no-referrer"
/>
</div>
{selectedBook.buyLink && (
<a
href={selectedBook.buyLink}
target="_blank"
rel="noopener noreferrer"
className="mt-6 w-full max-w-[260px] py-3 text-center bg-gradient-to-r from-cyan-500 to-indigo-600 hover:from-cyan-400 hover:to-indigo-500 text-slate-950 font-bold font-sans rounded-xl flex items-center justify-center gap-2 transition-all hover:scale-[1.02] shadow-[0_4px_12px_rgba(6,182,212,0.3)] text-sm"
>
<span>Zum Buch</span>
<ExternalLink className="w-4 h-4" />
</a>
)}
<div className="mt-6 w-full max-w-[260px] space-y-2">
{(selectedBook.ebookLink || selectedBook.buyLink) && <a href={selectedBook.ebookLink || selectedBook.buyLink} target="_blank" rel="noopener noreferrer" className="w-full py-3 text-center bg-gradient-to-r from-cyan-500 to-indigo-600 text-slate-950 font-bold rounded-xl flex items-center justify-center gap-2 text-sm"><span>E-Book</span><ExternalLink className="w-4 h-4" /></a>}
{selectedBook.paperbackLink && <a href={selectedBook.paperbackLink} target="_blank" rel="noopener noreferrer" className="w-full py-3 text-center border border-cyan-700 bg-slate-950 text-white font-bold rounded-xl flex items-center justify-center gap-2 text-sm"><span>Taschenbuch</span><ExternalLink className="w-4 h-4" /></a>}
{selectedBook.samplePdfUrl && <a href={selectedBook.samplePdfUrl} className="w-full py-3 text-center border border-slate-700 bg-slate-800 text-white font-bold rounded-xl flex items-center justify-center gap-2 text-sm"><FileText className="w-4 h-4" /><span>Leseprobe (PDF)</span></a>}
</div>
</div>
{/* Book Details */}
@ -417,6 +488,11 @@ export default function PortfolioPage({ data, legalDocuments = [], theme }: Port
{selectedBook.subtitle}
</h4>
)}
{selectedBook.seriesName && (
<p className="text-sm font-semibold" style={{ color: accentColor }}>{selectedBook.seriesName}{selectedBook.seriesNumber !== undefined && selectedBook.seriesNumber !== "" ? ` · Band ${selectedBook.seriesNumber}` : ""}</p>
)}
<button type="button" onClick={() => shareDetail(selectedBook.title)} className="inline-flex w-fit items-center gap-2 rounded-lg border border-slate-700 px-3 py-2 text-xs text-slate-300 hover:bg-slate-800"><Share2 className="h-4 w-4" /><span>Teilen / Link kopieren</span></button>
<div className="h-px bg-indigo-950" />
@ -439,11 +515,11 @@ export default function PortfolioPage({ data, legalDocuments = [], theme }: Port
{/* Project details modular modal */}
{selectedProject && (
<AccessibleModal titleId="project-detail-title" onClose={() => setSelectedProject(null)} maxWidthClass="sm:max-w-5xl">
<AccessibleModal titleId="project-detail-title" onClose={closeDetail} maxWidthClass="sm:max-w-5xl">
{/* Close Button */}
<button
onClick={() => setSelectedProject(null)}
onClick={closeDetail}
className="absolute top-4 right-4 text-slate-400 hover:text-white hover:bg-slate-800/80 px-2.5 py-1 rounded-md text-xs font-mono border border-slate-800 transition-all cursor-pointer"
>
SCHLIESSEN [ESC]
@ -463,6 +539,8 @@ export default function PortfolioPage({ data, legalDocuments = [], theme }: Port
{selectedProject.title}
</h3>
<button type="button" onClick={() => shareDetail(selectedProject.title)} className="inline-flex w-fit items-center gap-2 rounded-lg border border-slate-700 px-3 py-2 text-xs text-slate-300 hover:bg-slate-800"><Share2 className="h-4 w-4" /><span>Teilen / Link kopieren</span></button>
<div className="h-px bg-indigo-950" />
<div className="space-y-2">

View file

@ -101,23 +101,29 @@ export default function SpotlightSection({
</div>
</div>
{book.buyLink && (
{(book.ebookLink || book.buyLink) && (
<a
href={book.buyLink}
href={book.ebookLink || book.buyLink}
target="_blank"
rel="noopener noreferrer"
className="w-full max-w-[260px] py-3 px-4 rounded-xl font-bold text-xs uppercase tracking-wider transition-all duration-300 flex items-center justify-center gap-2 shadow-lg cursor-pointer hover:scale-[1.02]"
style={{ backgroundColor: accentColor, color: "#000" }}
>
<ShoppingBag className="w-4 h-4" />
<span>{isFuture ? "Jetzt vorbestellen" : "Jetzt im Handel"}</span>
<span>{isFuture ? "E-Book vorbestellen" : "E-Book"}</span>
<ExternalLink className="w-3.5 h-3.5 ml-auto opacity-70" />
</a>
)}
{book.paperbackLink && (
<a href={book.paperbackLink} target="_blank" rel="noopener noreferrer" className="w-full max-w-[260px] py-3 px-4 rounded-xl border border-slate-700 bg-slate-900 text-white font-bold text-xs uppercase tracking-wider flex items-center justify-center gap-2 hover:bg-slate-800">
<ShoppingBag className="w-4 h-4" /><span>Taschenbuch</span><ExternalLink className="w-3.5 h-3.5 ml-auto opacity-70" />
</a>
)}
</div>
{/* Details column */}
<div className="md:col-span-8 space-y-5">
{book.seriesName && <p className="text-xs font-semibold uppercase tracking-widest" style={{ color: accentColor }}>{book.seriesName}{book.seriesNumber !== undefined && book.seriesNumber !== "" ? ` · Band ${book.seriesNumber}` : ""}</p>}
<div className="flex flex-wrap items-center gap-2">
<span
className="px-3 py-1 rounded-full text-xs font-mono font-bold tracking-wide uppercase border flex items-center gap-1.5 shadow-sm"

View file

@ -0,0 +1,68 @@
import { useRef, useState } from "react";
import { FileText, Loader2, Trash2, Upload } from "lucide-react";
import { adminApi, AdminApiError } from "../../api/adminApi";
interface SamplePdfPickerProps {
value: string;
onChange: (url: string) => void;
}
export default function SamplePdfPicker({ value, onChange }: SamplePdfPickerProps) {
const inputRef = useRef<HTMLInputElement>(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 (
<div className="space-y-2 rounded-xl border border-slate-800 bg-slate-950/50 p-3">
<input ref={inputRef} type="file" accept="application/pdf,.pdf" className="hidden" onChange={(event) => upload(event.target.files?.[0])} />
{value ? (
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 flex items-center gap-2 text-xs text-slate-300">
<FileText className="h-4 w-4 shrink-0 text-rose-400" />
<span className="truncate">{value.split("/").pop()}</span>
</div>
<button type="button" onClick={() => onChange("")} className="p-1.5 text-slate-400 hover:text-rose-400" aria-label="Leseprobe aus dem Buch 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>{loading ? "Leseprobe wird hochgeladen…" : "PDF-Leseprobe hochladen"}</span>
</button>
)}
<p className="text-[10px] text-slate-500">PDF, maximal 10 MB. Ohne Datei erscheint kein Download.</p>
{error && <p className="text-xs text-rose-400" role="alert">{error}</p>}
</div>
);
}

View file

@ -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"

View file

@ -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 });