Compare commits

..

No commits in common. "main" and "codex/isolate-and-refactor-portfolios" have entirely different histories.

17 changed files with 282 additions and 6910 deletions

View file

@ -1,34 +0,0 @@
name: CI
on:
push:
pull_request:
workflow_dispatch:
jobs:
ci:
# This label must point to an isolated runner that has Node.js, Git,
# the Docker CLI, and access to a Docker/Podman daemon.
runs-on: docker-build
steps:
- name: Check out repository
uses: https://data.forgejo.org/actions/checkout@v6
- name: Set up Node.js
uses: https://data.forgejo.org/actions/setup-node@v4
with:
node-version: "20"
cache: npm
- name: Install dependencies
run: npm ci
- name: Typecheck
run: npm run typecheck
- name: Build and test
run: npm test
- name: Build container image
run: docker build --tag webstack-author:ci .

View file

@ -6,8 +6,8 @@ WORKDIR /app
# Copy dependency manifests # Copy dependency manifests
COPY package*.json ./ COPY package*.json ./
# Install exactly the dependency versions recorded in package-lock.json # Install all dependencies
RUN npm ci RUN npm install
# Copy full application codebase # Copy full application codebase
COPY . . COPY . .
@ -23,8 +23,8 @@ WORKDIR /app
# Copy configuration files # Copy configuration files
COPY package*.json ./ COPY package*.json ./
# Install exactly the locked production dependencies # Install only production dependencies (Express, dotenv, @google/genai)
RUN npm ci --omit=dev RUN npm install --omit=dev
# Copy compiled build resources from builder stage # Copy compiled build resources from builder stage
COPY --from=builder /app/dist ./dist COPY --from=builder /app/dist ./dist

View file

@ -9,19 +9,12 @@ Technisch besteht die Anwendung aus einem React-/Tailwind-Frontend und einem Exp
- Mehrere unabhängig konfigurierte Autorenprofile mit eigenen Domains und Themes - Mehrere unabhängig konfigurierte Autorenprofile mit eigenen Domains und Themes
- Biografie, Schlagworte, individuelle Texte und optionale Zusatzsektion mit bis zu drei CTA-Buttons - Biografie, Schlagworte, individuelle Texte und optionale Zusatzsektion mit bis zu drei CTA-Buttons
- Bücherregal mit Detailansicht, Cover, Kauflink und optionaler Spotify-Playlist - 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
- 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
- Optionale serverseitige Gemini-Unterstützung für Klappentexte - Optionale serverseitige Gemini-Unterstützung für Klappentexte
- Profilabhängige SEO-Metadaten, Open Graph, strukturierte Daten, Sitemap und `robots.txt` - Profilabhängige SEO-Metadaten, Open Graph, strukturierte Daten, Sitemap und `robots.txt`
- Health-Endpunkte, persistente JSON-Datenhaltung und automatische Migrationsbackups - Health-Endpunkte, persistente JSON-Datenhaltung und automatische Migrationsbackups
- optionale Kontaktsektion je Profil für E-Mail, Instagram, Threads und Discord
- adressierbare und teilbare Buch-/Projektansichten
- neutrale, nicht indexierbare 404-Seite ohne Hinweise auf andere Profile
## Trennung der Profile ## Trennung der Profile
@ -53,7 +46,6 @@ src/defaultData.ts Ausgangsdaten für eine neue Installation
server.ts API, Routing, Sessions, Uploads und SEO server.ts API, Routing, Sessions, Uploads und SEO
data/database.json Persistente Inhaltsdaten data/database.json Persistente Inhaltsdaten
data/uploads/ Lokal hochgeladene Bilder data/uploads/ Lokal hochgeladene Bilder
data/downloads/ Lokal hochgeladene PDF-Leseproben
data/backups/ Automatische Migrationsbackups data/backups/ Automatische Migrationsbackups
tests/ Integrations- und Sicherheitstests tests/ Integrations- und Sicherheitstests
``` ```
@ -123,12 +115,10 @@ Das Admin-Passwort wird nur beim Login übertragen und weder als Bearer-Token zu
- Sitzungsdauer: 24 Stunden - Sitzungsdauer: 24 Stunden
- Cookie: `HttpOnly`, `SameSite=Strict`, in Produktion zusätzlich `Secure` - Cookie: `HttpOnly`, `SameSite=Strict`, in Produktion zusätzlich `Secure`
- Sitzungen liegen nur im Arbeitsspeicher und enden spätestens bei einem Server-/Container-Neustart - Sitzungen liegen nur im Arbeitsspeicher und enden spätestens bei einem Server-/Container-Neustart
- Abgelaufene Sitzungen führen im Adminbereich kontrolliert zurück zur Anmeldung
- Schreibende Admin-Anfragen werden auf gleiche Herkunft geprüft - Schreibende Admin-Anfragen werden auf gleiche Herkunft geprüft
- Fehlgeschlagene Logins werden pro Client-IP begrenzt - Fehlgeschlagene Logins werden pro Client-IP begrenzt
- Uploads und Gemini-Aufrufe besitzen zusätzliche Sitzungslimits - Uploads und Gemini-Aufrufe besitzen zusätzliche Sitzungslimits
- Sicherheitsheader und eine Content Security Policy werden serverseitig gesetzt - Sicherheitsheader und eine Content Security Policy werden serverseitig gesetzt
- Profil- und Rechtstextänderungen verwenden eine Revision; parallele Änderungen werden mit einem Konflikthinweis abgelehnt statt überschrieben
Ein vergessenes Admin-Passwort wird über die Betriebsumgebung geändert; es gibt keine öffentliche Passwort-zurücksetzen-Funktion. Ein vergessenes Admin-Passwort wird über die Betriebsumgebung geändert; es gibt keine öffentliche Passwort-zurücksetzen-Funktion.
@ -141,7 +131,6 @@ Für ein vollständiges Backup sollte der gesamte Ordner `data/` gesichert werde
```text ```text
data/database.json data/database.json
data/uploads/ data/uploads/
data/downloads/
data/backups/ data/backups/
``` ```
@ -158,7 +147,6 @@ Der Server:
- erzeugt zufällige, nicht überschreibbare Dateinamen, - erzeugt zufällige, nicht überschreibbare Dateinamen,
- begrenzt Uploadversuche pro Admin-Sitzung und Stunde, - begrenzt Uploadversuche pro Admin-Sitzung und Stunde,
- liefert Uploads mit `nosniff` und langfristigen Cache-Headern aus. - liefert Uploads mit `nosniff` und langfristigen Cache-Headern aus.
- erlaubt das Löschen unbenutzter Bilder in der Server-Mediathek, schützt aber Bilder, die noch in einem Profil referenziert werden.
Die Bilder können für Avatare, Banner, Buchcover und aktuelle Projekte verwendet werden. Leere Bildfelder erzeugen keinen Request zu einem externen Standardbild. Die Bilder können für Avatare, Banner, Buchcover und aktuelle Projekte verwendet werden. Leere Bildfelder erzeugen keinen Request zu einem externen Standardbild.
@ -168,30 +156,6 @@ 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. 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, Threads und Discord 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.
## 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
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 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. 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.
@ -200,7 +164,6 @@ Weitere mögliche externe Verbindungen:
- `GEMINI_API_KEY`: Der optionale Admin-Assistent sendet die eingegebenen Buchinformationen serverseitig an die Google-Gemini-API. - `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. - Kauf- und sonstige Markdown-Links: Erst ein Klick führt zur jeweiligen externen Website.
- Instagram-, Threads-, Discord- 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. - 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. 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.
@ -230,10 +193,7 @@ Die Integrationstests verwenden ein temporäres Datenverzeichnis und einen kurzl
- Origin-Schutz und Login-Limit, - Origin-Schutz und Login-Limit,
- exaktes Domain-Routing und öffentliche Profilisolation, - exaktes Domain-Routing und öffentliche Profilisolation,
- Schutz des vollständigen Admin-Datenendpunkts, - Schutz des vollständigen Admin-Datenendpunkts,
- Revisionskonflikte und serverseitige Inhaltsvalidierung,
- gültige und manipulierte Bild-Uploads, - gültige und manipulierte Bild-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

5607
package-lock.json generated

File diff suppressed because it is too large Load diff

328
server.ts
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, DownloadPublication, PortfolioTheme, PublicAuthorData } from "./src/types.js"; import { AuthorData, AuthorProfile, CustomSectionLink, PortfolioTheme, PublicAuthorData } from "./src/types.js";
dotenv.config(); dotenv.config();
@ -16,7 +16,6 @@ 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_DIR = path.resolve(process.env.DATA_DIR || path.join(process.cwd(), "data"));
const DATA_FILE = path.join(DATA_DIR, "database.json"); const DATA_FILE = path.join(DATA_DIR, "database.json");
const BACKUP_DIR = path.join(DATA_DIR, "backups"); const BACKUP_DIR = path.join(DATA_DIR, "backups");
const DOWNLOAD_DIR = path.join(DATA_DIR, "downloads");
const CURRENT_SCHEMA_VERSION = 2; const CURRENT_SCHEMA_VERSION = 2;
const LEGACY_DEFAULT_IMAGE_URLS = new Set([ const LEGACY_DEFAULT_IMAGE_URLS = new Set([
"https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?auto=format&fit=crop&q=80&w=300", "https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?auto=format&fit=crop&q=80&w=300",
@ -44,10 +43,7 @@ const GEMINI_MAX_REQUESTS = 20;
const UPLOAD_WINDOW_MS = 60 * 60 * 1000; const UPLOAD_WINDOW_MS = 60 * 60 * 1000;
const UPLOAD_MAX_REQUESTS = 30; const UPLOAD_MAX_REQUESTS = 30;
const MAX_UPLOAD_BYTES = 8 * 1024 * 1024; 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_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_DOWNLOAD_NAME = /^(?!\.)[a-zA-Z0-9._-]+\.(?:pdf|epub)$/i;
interface SessionRecord { interface SessionRecord {
expiresAt: number; expiresAt: number;
@ -58,8 +54,6 @@ interface RateRecord {
resetAt: number; resetAt: number;
} }
class RevisionConflictError extends Error {}
const sessions = new Map<string, SessionRecord>(); const sessions = new Map<string, SessionRecord>();
const loginFailures = new Map<string, RateRecord>(); const loginFailures = new Map<string, RateRecord>();
const geminiRequests = new Map<string, RateRecord>(); const geminiRequests = new Map<string, RateRecord>();
@ -125,7 +119,6 @@ async function createMigrationBackup(rawData: string): Promise<void> {
async function initDatabase(): Promise<AuthorData> { async function initDatabase(): Promise<AuthorData> {
await fs.mkdir(DATA_DIR, { recursive: true }); await fs.mkdir(DATA_DIR, { recursive: true });
await fs.mkdir(path.join(DATA_DIR, "uploads"), { recursive: true }); await fs.mkdir(path.join(DATA_DIR, "uploads"), { recursive: true });
await fs.mkdir(DOWNLOAD_DIR, { recursive: true });
try { try {
const existingData = await fs.readFile(DATA_FILE, "utf-8"); const existingData = await fs.readFile(DATA_FILE, "utf-8");
if (!existingData.trim()) { if (!existingData.trim()) {
@ -266,7 +259,7 @@ app.use((_req, res, next) => {
} }
next(); next();
}); });
app.use(express.json({ limit: "15mb" })); app.use(express.json({ limit: "12mb" }));
// Initialize Google GenAI if API key exists // Initialize Google GenAI if API key exists
const getGeminiClient = () => { const getGeminiClient = () => {
@ -517,100 +510,6 @@ function normalizeCustomSectionLinks(value: unknown): CustomSectionLink[] | null
return links; return links;
} }
function isSafeContentUrl(value: unknown, allowSpotifyId = false): boolean {
if (value === undefined || value === "") return true;
if (typeof value !== "string" || value.length > 2048) return false;
if (allowSpotifyId && /^[a-zA-Z0-9]+$/.test(value)) return true;
if (value.startsWith("/uploads/")) return SAFE_UPLOAD_NAME.test(path.basename(value));
try {
const url = new URL(value);
return url.protocol === "https:" || url.protocol === "http:";
} catch {
return false;
}
}
function validateProfile(profile: unknown): string | null {
if (!profile || typeof profile !== "object") return "Profildaten fehlen.";
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.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]> = [
[value.name, "Name", 200], [value.bio, "Biografie", 50_000],
[value.heroTitle, "Hero-Titel", 500], [value.heroSubtitle, "Hero-Untertitel", 500],
];
for (const [field, label, maxLength] of requiredStrings) {
if (typeof field !== "string" || field.length > maxLength) return `${label} ist ungültig oder zu lang.`;
}
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) || !isSafeContentUrl(value.discordUrl)) 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 ||
typeof project.description !== "string" || project.description.length > 100_000 ||
!Number.isFinite(project.progress) || project.progress < 0 || project.progress > 100 ||
!isSafeContentUrl(project.imageUrl) || !isSafeContentUrl(project.spotifyPlaylistId, true)) {
return "Mindestens ein Projekt enthält ungültige Werte.";
}
}
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.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.";
}
}
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;
}
function validateLegalDocuments(value: unknown): value is AuthorData["legalDocuments"] {
return Array.isArray(value) && value.length <= 100 && value.every((document) =>
document && typeof document.id === "string" && document.id.length <= 200 &&
typeof document.title === "string" && document.title.length <= 500 &&
["impressum", "privacy", "custom"].includes(document.type) &&
typeof document.content === "string" && document.content.length <= 200_000 &&
Array.isArray(document.assignedProfiles) && document.assignedProfiles.length <= profileKeys.length &&
document.assignedProfiles.every((key: unknown) => profileKeys.includes(key as ProfileKey))
);
}
function hasRevisionConflict(expectedRevision: unknown): boolean {
return !Number.isInteger(expectedRevision) || expectedRevision !== (dbCache.revision ?? 0);
}
function isUploadReferenced(url: string): boolean {
return profileKeys.some((key) => {
const profile = dbCache[key];
return profile.avatarUrl === url || profile.heroBannerUrl === url || profile.socialImageUrl === url ||
profile.books.some((book) => book.coverUrl === url) || profile.projects.some((project) => project.imageUrl === url);
});
}
function isDownloadReferenced(url: string): boolean {
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 {
const key = profileForRequest(req.hostname, req.path); const key = profileForRequest(req.hostname, req.path);
const profile = dbCache[key]; const profile = dbCache[key];
@ -727,20 +626,15 @@ app.post("/api/admin/logout", verifySameOrigin, (req, res) => {
// 3. Save modified profile configurations (About, Projects, Books) // 3. Save modified profile configurations (About, Projects, Books)
app.post("/api/admin/save-profile", verifySession, verifySameOrigin, async (req, res) => { app.post("/api/admin/save-profile", verifySession, verifySameOrigin, async (req, res) => {
const { profileKey, profileData, expectedRevision } = req.body; const { profileKey, profileData } = req.body;
if (profileKey !== "scifi" && profileKey !== "erotica" && profileKey !== "clara" && profileKey !== "renee") { if (profileKey !== "scifi" && profileKey !== "erotica" && profileKey !== "clara" && profileKey !== "renee") {
res.status(400).json({ error: "Ungültiger Profilschlüssel." }); res.status(400).json({ error: "Ungültiger Profilschlüssel." });
return; return;
} }
try { try {
if (hasRevisionConflict(expectedRevision)) { if (!profileData || typeof profileData !== "object" || !Array.isArray(profileData.books) || !Array.isArray(profileData.projects)) {
res.status(409).json({ error: "Die Daten wurden zwischenzeitlich geändert. Bitte neu laden und die Änderung erneut vornehmen.", revision: dbCache.revision ?? 0 }); res.status(400).json({ error: "Profildaten besitzen nicht die erwartete Struktur." });
return;
}
const validationError = validateProfile(profileData);
if (validationError) {
res.status(400).json({ error: validationError });
return; return;
} }
const customSectionLinks = normalizeCustomSectionLinks(profileData.customSectionLinks); const customSectionLinks = normalizeCustomSectionLinks(profileData.customSectionLinks);
@ -749,16 +643,13 @@ app.post("/api/admin/save-profile", verifySession, verifySameOrigin, async (req,
return; return;
} }
const sanitizedProfileData = { ...profileData, customSectionLinks }; const sanitizedProfileData = { ...profileData, customSectionLinks };
const nextData = await updateDatabase((current) => { const nextData = await updateDatabase((current) => ({
if (expectedRevision !== (current.revision ?? 0)) throw new RevisionConflictError(); ...current,
return { ...current, [profileKey]: sanitizedProfileData, revision: (current.revision ?? 0) + 1 }; [profileKey]: sanitizedProfileData,
}); revision: (current.revision ?? 0) + 1,
}));
res.json({ success: true, revision: nextData.revision, message: "Profil erfolgreich gespeichert." }); res.json({ success: true, revision: nextData.revision, message: "Profil erfolgreich gespeichert." });
} catch (err: any) { } catch (err: any) {
if (err instanceof RevisionConflictError) {
res.status(409).json({ error: "Die Daten wurden zwischenzeitlich geändert. Bitte neu laden und die Änderung erneut vornehmen.", revision: dbCache.revision ?? 0 });
return;
}
console.error("Failed to write to database.json:", err); console.error("Failed to write to database.json:", err);
res.status(500).json({ error: "Fehler beim persistenten Speichern der Formulardaten." }); res.status(500).json({ error: "Fehler beim persistenten Speichern der Formulardaten." });
} }
@ -766,27 +657,20 @@ app.post("/api/admin/save-profile", verifySession, verifySameOrigin, async (req,
// 3b. Save legal documents (Impressum & Datenschutzerklärung) // 3b. Save legal documents (Impressum & Datenschutzerklärung)
app.post("/api/admin/save-legal", verifySession, verifySameOrigin, async (req, res) => { app.post("/api/admin/save-legal", verifySession, verifySameOrigin, async (req, res) => {
const { legalDocuments, expectedRevision } = req.body; const { legalDocuments } = req.body;
if (hasRevisionConflict(expectedRevision)) { if (!Array.isArray(legalDocuments)) {
res.status(409).json({ error: "Die Daten wurden zwischenzeitlich geändert. Bitte neu laden und die Änderung erneut vornehmen.", revision: dbCache.revision ?? 0 }); res.status(400).json({ error: "legalDocuments muss ein Array sein." });
return;
}
if (!validateLegalDocuments(legalDocuments)) {
res.status(400).json({ error: "Die rechtlichen Dokumente besitzen nicht die erwartete Struktur oder überschreiten zulässige Längen." });
return; return;
} }
try { try {
const nextData = await updateDatabase((current) => { const nextData = await updateDatabase((current) => ({
if (expectedRevision !== (current.revision ?? 0)) throw new RevisionConflictError(); ...current,
return { ...current, legalDocuments, revision: (current.revision ?? 0) + 1 }; legalDocuments,
}); revision: (current.revision ?? 0) + 1,
}));
res.json({ success: true, revision: nextData.revision, message: "Rechtliche Dokumente erfolgreich gespeichert." }); res.json({ success: true, revision: nextData.revision, message: "Rechtliche Dokumente erfolgreich gespeichert." });
} catch (err: any) { } catch (err: any) {
if (err instanceof RevisionConflictError) {
res.status(409).json({ error: "Die Daten wurden zwischenzeitlich geändert. Bitte neu laden und die Änderung erneut vornehmen.", revision: dbCache.revision ?? 0 });
return;
}
console.error("Failed to write legal documents to database.json:", err); console.error("Failed to write legal documents to database.json:", err);
res.status(500).json({ error: "Fehler beim Speichern der rechtlichen Dokumente." }); res.status(500).json({ error: "Fehler beim Speichern der rechtlichen Dokumente." });
} }
@ -914,156 +798,6 @@ app.get("/api/admin/list-uploads", verifySession, async (req, res) => {
} }
}); });
app.delete("/api/admin/uploads/:name", verifySession, verifySameOrigin, async (req, res) => {
const name = req.params.name;
if (!SAFE_UPLOAD_NAME.test(name) || path.basename(name) !== name) {
res.status(400).json({ error: "Ungültiger Dateiname." });
return;
}
const url = `/uploads/${name}`;
if (isUploadReferenced(url)) {
res.status(409).json({ error: "Das Bild wird noch in mindestens einem Profil verwendet und kann nicht gelöscht werden." });
return;
}
try {
await fs.unlink(path.join(DATA_DIR, "uploads", name));
res.json({ success: true });
} catch (error: any) {
if (error?.code === "ENOENT") {
res.status(404).json({ error: "Die Datei wurde nicht gefunden." });
return;
}
console.error("Failed to delete upload:", error);
res.status(500).json({ error: "Die Datei konnte nicht gelöscht werden." });
}
});
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.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) => {
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." });
}
});
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
@ -1085,23 +819,6 @@ async function startServer() {
res.setHeader("X-Content-Type-Options", "nosniff"); res.setHeader("X-Content-Type-Options", "nosniff");
}, },
})); }));
app.use("/downloads", (req, res, next) => {
const requestedName = path.basename(req.path);
if (!SAFE_DOWNLOAD_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", filePath.toLowerCase().endsWith(".epub") ? "application/epub+zip" : "application/pdf");
res.setHeader("Content-Disposition", `attachment; filename="${path.basename(filePath)}"`);
res.setHeader("X-Content-Type-Options", "nosniff");
},
}));
if (process.env.NODE_ENV !== "production") { if (process.env.NODE_ENV !== "production") {
const { createServer: createViteServer } = await import("vite"); const { createServer: createViteServer } = await import("vite");
@ -1130,14 +847,9 @@ async function startServer() {
app.use(express.static(distPath, { index: false })); app.use(express.static(distPath, { index: false }));
app.get("*", (req: express.Request, res: express.Response) => { app.get("*", (req: express.Request, res: express.Response) => {
const isKnownRoute = req.path === "/" || req.path === "/admin" || req.path.startsWith("/admin/"); const html = indexTemplate.replace("<!-- SEO_META -->\n <title>Autoren-Portfolio</title>", seoMeta(req, res.locals.scriptNonce));
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"); if (req.path.startsWith("/admin")) res.setHeader("X-Robots-Tag", "noindex, nofollow, noarchive");
if (!isKnownRoute) res.setHeader("X-Robots-Tag", "noindex, nofollow, noarchive"); res.type("html").send(html);
res.status(isKnownRoute ? 200 : 404).type("html").send(html);
}); });
} }

View file

@ -82,20 +82,6 @@ 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 ( return (
<Suspense fallback={<LoadingScreen />}> <Suspense fallback={<LoadingScreen />}>
<PortfolioPage data={publicData.profile} legalDocuments={publicData.legalDocuments} theme={publicData.theme} /> <PortfolioPage data={publicData.profile} legalDocuments={publicData.legalDocuments} theme={publicData.theme} />

View file

@ -1,49 +0,0 @@
import type { AuthorData, AuthorProfile, LegalDocument } from "../types";
export class AdminApiError extends Error {
constructor(message: string, public readonly status: number) {
super(message);
}
}
async function request<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, init);
const payload = await response.json().catch(() => ({})) as { error?: string };
if (!response.ok) {
if (response.status === 401 && url !== "/api/admin/login") window.dispatchEvent(new Event("admin-session-expired"));
throw new AdminApiError(payload.error || `Anfrage fehlgeschlagen (HTTP ${response.status}).`, response.status);
}
return payload as T;
}
const jsonPost = (body: unknown): RequestInit => ({
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
export const adminApi = {
session: () => request<{ success: true }>("/api/admin/session"),
login: (password: string) => request<{ success: true }>("/api/admin/login", jsonPost({ password })),
logout: () => request<{ success: true }>("/api/admin/logout", { method: "POST" }),
loadData: () => request<AuthorData>("/api/admin/author-data"),
saveProfile: (profileKey: "scifi" | "erotica" | "clara" | "renee", profileData: AuthorProfile, expectedRevision: number) =>
request<{ success: true; revision: number }>("/api/admin/save-profile", jsonPost({ profileKey, profileData, expectedRevision })),
saveLegal: (legalDocuments: LegalDocument[], expectedRevision: number) =>
request<{ success: true; revision: number }>("/api/admin/save-legal", jsonPost({ legalDocuments, expectedRevision })),
generateBlurb: (input: { title: string; genre: string; ideas: string; tone: string }) =>
request<{ success: true; text: string }>("/api/admin/generate-blurb", jsonPost(input)),
listUploads: () => request<{ success: true; files: Array<{ name: string; url: string }> }>("/api/admin/list-uploads"),
upload: (fileName: string, base64Data: string) =>
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" }),
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

@ -1,72 +0,0 @@
import { useEffect, useRef, type ReactNode } from "react";
interface AccessibleModalProps {
titleId: string;
onClose: () => void;
children: ReactNode;
maxWidthClass?: string;
}
const focusableSelector = [
"a[href]", "button:not([disabled])", "input:not([disabled])", "select:not([disabled])",
"textarea:not([disabled])", "[tabindex]:not([tabindex='-1'])",
].join(",");
export default function AccessibleModal({ titleId, onClose, children, maxWidthClass = "sm:max-w-4xl" }: AccessibleModalProps) {
const dialogRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
const dialog = dialogRef.current;
const firstFocusable = dialog?.querySelector<HTMLElement>(focusableSelector);
(firstFocusable || dialog)?.focus();
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
onClose();
return;
}
if (event.key !== "Tab" || !dialog) return;
const focusable = Array.from(dialog.querySelectorAll<HTMLElement>(focusableSelector));
if (focusable.length === 0) {
event.preventDefault();
dialog.focus();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
document.body.style.overflow = previousOverflow;
previousFocus?.focus();
};
}, [onClose]);
return (
<div className="fixed inset-0 z-50 overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby={titleId}>
<button type="button" className="fixed inset-0 bg-slate-950/80 backdrop-blur-md" onClick={onClose} aria-label="Dialog schließen" />
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
<div
ref={dialogRef}
tabIndex={-1}
className={`relative transform overflow-hidden rounded-2xl bg-slate-900 border border-slate-800 text-left shadow-2xl transition-all sm:my-8 sm:w-full ${maxWidthClass} p-6 md:p-8 space-y-6`}
>
{children}
</div>
</div>
</div>
);
}

View file

@ -5,13 +5,10 @@ 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, Download Scale, ShieldCheck, FileText, CheckSquare, Square, Copy, ArrowRight
} 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 DownloadsManager from "./admin/DownloadsManager";
import { adminApi, AdminApiError } from "../api/adminApi";
interface AdminPanelProps { interface AdminPanelProps {
onLogout: () => void; onLogout: () => void;
@ -22,7 +19,6 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
const [isLoggedIn, setIsLoggedIn] = useState(false); const [isLoggedIn, setIsLoggedIn] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [notice, setNotice] = useState<{ message: string; tone: "success" | "error" } | null>(null);
// Author Data State // Author Data State
const [authorData, setAuthorData] = useState<AuthorData | null>(null); const [authorData, setAuthorData] = useState<AuthorData | null>(null);
@ -31,7 +27,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" | "downloads" | "legal">("profile"); const [activeTab, setActiveTab] = useState<"profile" | "texts" | "design" | "books" | "projects" | "legal">("profile");
// Editing forms state // Editing forms state
const [editingBookId, setEditingBookId] = useState<string | null>(null); const [editingBookId, setEditingBookId] = useState<string | null>(null);
@ -61,9 +57,11 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
const restoreSession = async () => { const restoreSession = async () => {
setLoading(true); setLoading(true);
try { try {
await adminApi.session(); const response = await fetch("/api/admin/session");
setIsLoggedIn(true); if (response.ok) {
await fetchAuthorData(); setIsLoggedIn(true);
await fetchAuthorData();
}
} catch (err) { } catch (err) {
console.error("Admin session check failed:", err); console.error("Admin session check failed:", err);
} finally { } finally {
@ -73,20 +71,11 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
restoreSession(); restoreSession();
}, []); }, []);
useEffect(() => {
const handleExpiredSession = () => {
setIsLoggedIn(false);
setAuthorData(null);
setError("Die Admin-Sitzung ist abgelaufen. Bitte erneut anmelden.");
};
window.addEventListener("admin-session-expired", handleExpiredSession);
return () => window.removeEventListener("admin-session-expired", handleExpiredSession);
}, []);
const fetchAuthorData = async () => { const fetchAuthorData = async () => {
setLoading(true); setLoading(true);
try { try {
const data = await adminApi.loadData(); const res = await fetch("/api/admin/author-data");
const data = await res.json();
setAuthorData(data); setAuthorData(data);
} catch (err) { } catch (err) {
console.error("Error fetching author data:", err); console.error("Error fetching author data:", err);
@ -100,12 +89,21 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
setError(""); setError("");
setLoading(true); setLoading(true);
try { try {
await adminApi.login(password); const res = await fetch("/api/admin/login", {
setPassword(""); method: "POST",
setIsLoggedIn(true); headers: { "Content-Type": "application/json" },
await fetchAuthorData(); body: JSON.stringify({ password }),
});
const data = await res.json();
if (res.ok && data.success) {
setPassword("");
setIsLoggedIn(true);
await fetchAuthorData();
} else {
setError(data.error || "Ungültiges Passwort.");
}
} catch (err) { } catch (err) {
setError(err instanceof AdminApiError ? err.message : "Verbindung zum Server fehlgeschlagen."); setError("Verbindung zum Server fehlgeschlagen.");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -113,7 +111,7 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
const handleLogoutLocal = async () => { const handleLogoutLocal = async () => {
try { try {
await adminApi.logout(); await fetch("/api/admin/logout", { method: "POST" });
} catch (err) { } catch (err) {
console.error("Admin logout failed:", err); console.error("Admin logout failed:", err);
} }
@ -122,28 +120,6 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
onLogout(); onLogout();
}; };
const persistProfile = async (profileKey: "scifi" | "erotica" | "clara" | "renee", profileData: AuthorProfile) => {
if (!authorData) throw new Error("Keine Profildaten geladen.");
const result = await adminApi.saveProfile(profileKey, profileData, authorData.revision ?? 0);
setAuthorData((current) => current ? { ...current, [profileKey]: profileData, revision: result.revision } : current);
return result;
};
const persistLegalDocuments = async (legalDocuments: LegalDocument[]) => {
if (!authorData) throw new Error("Keine Profildaten geladen.");
const result = await adminApi.saveLegal(legalDocuments, authorData.revision ?? 0);
setAuthorData((current) => current ? { ...current, legalDocuments, revision: result.revision } : current);
return result;
};
const showNotice = (message: string, tone: "success" | "error" = "error") => setNotice({ message, tone });
useEffect(() => {
if (!notice) return;
const timeout = window.setTimeout(() => setNotice(null), 5000);
return () => window.clearTimeout(timeout);
}, [notice]);
// Profile save handler // Profile save handler
const handleSaveProfileInfo = async (profileKey: "scifi" | "erotica" | "clara" | "renee") => { const handleSaveProfileInfo = async (profileKey: "scifi" | "erotica" | "clara" | "renee") => {
if (!authorData) return; if (!authorData) return;
@ -151,10 +127,26 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
setLoading(true); setLoading(true);
try { try {
await persistProfile(profileKey, authorData[profileKey]); const res = await fetch("/api/admin/save-profile", {
showNotice("Profilinformationen erfolgreich gespeichert!", "success"); method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
profileKey,
profileData: authorData[profileKey]
}),
});
const resData = await res.json();
if (res.ok && resData.success) {
alert("Profilinformationen erfolgreich gespeichert!");
fetchAuthorData();
} else {
setError(resData.error || "Profile konnte nicht gespeichert werden.");
}
} catch (err) { } catch (err) {
setError(err instanceof AdminApiError ? err.message : "Fehler beim Senden der Profildaten."); setError("Fehler beim Senden der Profildaten.");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -206,14 +198,27 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
} }
const updatedProfile = { ...profile, projects: updatedProjects }; const updatedProfile = { ...profile, projects: updatedProjects };
const nextData = { ...authorData, [activeProfile]: updatedProfile };
// Save to server // Save to server
setLoading(true); setLoading(true);
try { try {
await persistProfile(activeProfile, updatedProfile); const res = await fetch("/api/admin/save-profile", {
setEditingProjectId(null); method: "POST",
setProjectForm({}); headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ profileKey: activeProfile, profileData: updatedProfile }),
});
if (res.ok) {
setAuthorData(nextData);
setEditingProjectId(null);
setProjectForm({});
} else {
alert("Fehler beim Speichern des Projekts.");
}
} catch (err) { } catch (err) {
showNotice(err instanceof AdminApiError ? err.message : "Netzwerkfehler."); alert("Netzwerkfehler.");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -224,11 +229,24 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
const updatedProjects = profile.projects.filter(p => p.id !== projId); const updatedProjects = profile.projects.filter(p => p.id !== projId);
const updatedProfile = { ...profile, projects: updatedProjects }; const updatedProfile = { ...profile, projects: updatedProjects };
const nextData = { ...authorData, [activeProfile]: updatedProfile };
setLoading(true); setLoading(true);
try { try {
await persistProfile(activeProfile, updatedProfile); const res = await fetch("/api/admin/save-profile", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ profileKey: activeProfile, profileData: updatedProfile }),
});
if (res.ok) {
setAuthorData(nextData);
} else {
alert("Fehler beim Löschen.");
}
} catch (err) { } catch (err) {
showNotice(err instanceof AdminApiError ? err.message : "Netzwerkfehler."); alert("Netzwerkfehler.");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -240,7 +258,6 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
const parsedGenres = genreInputRaw.split(",").map(g => g.trim()).filter(Boolean); const parsedGenres = genreInputRaw.split(",").map(g => g.trim()).filter(Boolean);
let updatedBooks = [...profile.books]; 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 current book is marked as spotlight, clear spotlight flag on all other books
if (bookForm.isSpotlight) { if (bookForm.isSpotlight) {
@ -256,11 +273,6 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
description: bookForm.description || "", description: bookForm.description || "",
publishedYear: bookForm.publishedYear || new Date().getFullYear(), publishedYear: bookForm.publishedYear || new Date().getFullYear(),
buyLink: bookForm.buyLink || "", buyLink: bookForm.buyLink || "",
ebookLink: bookForm.ebookLink || bookForm.buyLink || "",
paperbackLink: bookForm.paperbackLink || "",
samplePdfUrl: bookForm.samplePdfUrl || "",
seriesName: bookForm.seriesName || "",
seriesNumber: bookForm.seriesNumber || "",
spotifyPlaylistId: bookForm.spotifyPlaylistId || "", spotifyPlaylistId: bookForm.spotifyPlaylistId || "",
genres: parsedGenres, genres: parsedGenres,
isSpotlight: !!bookForm.isSpotlight, isSpotlight: !!bookForm.isSpotlight,
@ -280,18 +292,26 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
} }
const updatedProfile = { ...profile, books: updatedBooks }; const updatedProfile = { ...profile, books: updatedBooks };
const nextData = { ...authorData, [activeProfile]: updatedProfile };
setLoading(true); setLoading(true);
try { try {
await persistProfile(activeProfile, updatedProfile); const res = await fetch("/api/admin/save-profile", {
const savedSample = updatedBooks.find((book) => book.id === editingBookId || (editingBookId === "new" && book === updatedBooks.at(-1)))?.samplePdfUrl; method: "POST",
if (previousSample && previousSample !== savedSample) { headers: {
const name = previousSample.split("/").pop(); "Content-Type": "application/json",
if (name) adminApi.deleteSample(name).catch(() => undefined); },
body: JSON.stringify({ profileKey: activeProfile, profileData: updatedProfile }),
});
if (res.ok) {
setAuthorData(nextData);
setEditingBookId(null);
setBookForm({});
} else {
alert("Fehler beim Speichern des Buches.");
} }
setEditingBookId(null);
setBookForm({});
} catch (err) { } catch (err) {
showNotice(err instanceof AdminApiError ? err.message : "Netzwerkfehler."); alert("Netzwerkfehler.");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -300,16 +320,26 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
const handleDeleteBook = async (bookId: string) => { const handleDeleteBook = async (bookId: string) => {
if (!authorData || !profile || !confirm("Möchten Sie dieses Buch aus dem Bücherregal löschen?")) return; 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 updatedBooks = profile.books.filter(b => b.id !== bookId);
const updatedProfile = { ...profile, books: updatedBooks }; const updatedProfile = { ...profile, books: updatedBooks };
const nextData = { ...authorData, [activeProfile]: updatedProfile };
setLoading(true); setLoading(true);
try { try {
await persistProfile(activeProfile, updatedProfile); const res = await fetch("/api/admin/save-profile", {
const name = removedSample?.split("/").pop(); method: "POST",
if (name) adminApi.deleteSample(name).catch(() => undefined); headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ profileKey: activeProfile, profileData: updatedProfile }),
});
if (res.ok) {
setAuthorData(nextData);
} else {
alert("Fehler beim Löschen des Buches.");
}
} catch (err) { } catch (err) {
showNotice(err instanceof AdminApiError ? err.message : "Netzwerkfehler."); alert("Netzwerkfehler.");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -318,22 +348,36 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
// --- GEMINI SYNOPSIS GENERATOR --- // --- GEMINI SYNOPSIS GENERATOR ---
const handleGenerateAISynopsis = async () => { const handleGenerateAISynopsis = async () => {
if (!aiTitle.trim()) { if (!aiTitle.trim()) {
showNotice("Bitte geben Sie einen Buchtitel für den KI-Assistenten an."); alert("Bitte geben Sie einen Buchtitel für den KI-Assistenten an.");
return; return;
} }
setAiLoading(true); setAiLoading(true);
setAiResult(""); setAiResult("");
try { try {
const data = await adminApi.generateBlurb({ const res = await fetch("/api/admin/generate-blurb", {
title: aiTitle, method: "POST",
genre: aiGenre || (activeProfile === "scifi" ? "Science-Fiction" : "Literatur"), headers: {
ideas: aiIdeas, "Content-Type": "application/json",
tone: aiTone, },
body: JSON.stringify({
title: aiTitle,
genre: aiGenre || activeProfile === "scifi" ? "Science-Fiction" : "Sinnliche Literatur",
ideas: aiIdeas,
tone: aiTone
}),
}); });
setAiResult(data.text); const data = await res.json();
setBookForm({ ...bookForm, description: data.text }); if (res.ok && data.text) {
} catch (err) { setAiResult(data.text);
showNotice(err instanceof AdminApiError ? err.message : "Fehler bei der Verbindung zur KI-Schnittstelle."); setBookForm({
...bookForm,
description: data.text
});
} else {
alert(data.error || "Generierung fehlgeschlagen.");
}
} catch {
alert("Fehler bei der Verbindung zur KI-Schnittstelle.");
} finally { } finally {
setAiLoading(false); setAiLoading(false);
} }
@ -374,11 +418,22 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
setLoading(true); setLoading(true);
try { try {
await persistLegalDocuments(updatedDocs); const res = await fetch("/api/admin/save-legal", {
setEditingLegalId(null); method: "POST",
setLegalForm({}); headers: {
} catch (err) { "Content-Type": "application/json",
showNotice(err instanceof AdminApiError ? err.message : "Netzwerkfehler beim Speichern."); },
body: JSON.stringify({ legalDocuments: updatedDocs })
});
if (res.ok) {
setAuthorData({ ...authorData, legalDocuments: updatedDocs });
setEditingLegalId(null);
setLegalForm({});
} else {
alert("Fehler beim Speichern des Dokuments.");
}
} catch {
alert("Netzwerkfehler beim Speichern.");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -389,13 +444,24 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
const updatedDocs = (authorData.legalDocuments || []).filter(d => d.id !== docId); const updatedDocs = (authorData.legalDocuments || []).filter(d => d.id !== docId);
setLoading(true); setLoading(true);
try { try {
await persistLegalDocuments(updatedDocs); const res = await fetch("/api/admin/save-legal", {
if (editingLegalId === docId) { method: "POST",
setEditingLegalId(null); headers: {
setLegalForm({}); "Content-Type": "application/json",
},
body: JSON.stringify({ legalDocuments: updatedDocs })
});
if (res.ok) {
setAuthorData({ ...authorData, legalDocuments: updatedDocs });
if (editingLegalId === docId) {
setEditingLegalId(null);
setLegalForm({});
}
} else {
alert("Fehler beim Löschen des Dokuments.");
} }
} catch (err) { } catch {
showNotice(err instanceof AdminApiError ? err.message : "Netzwerkfehler beim Löschen."); alert("Netzwerkfehler beim Löschen.");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -444,15 +510,6 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
return ( return (
<div className="min-h-screen bg-slate-950 text-slate-200"> <div className="min-h-screen bg-slate-950 text-slate-200">
{notice && (
<div
role="status"
aria-live="polite"
className={`fixed right-4 top-4 z-[70] max-w-sm rounded-xl border px-4 py-3 text-sm shadow-2xl ${notice.tone === "success" ? "border-emerald-700 bg-emerald-950 text-emerald-100" : "border-rose-800 bg-rose-950 text-rose-100"}`}
>
{notice.message}
</div>
)}
{/* Top Navbar */} {/* Top Navbar */}
<nav className="bg-slate-900/80 border-b border-slate-800 sticky top-0 z-30 backdrop-blur-md"> <nav className="bg-slate-900/80 border-b border-slate-800 sticky top-0 z-30 backdrop-blur-md">
@ -564,13 +621,6 @@ 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"}`}
@ -674,7 +724,7 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
<p className="text-xs text-slate-500">Diese drei Kacheln erscheinen unter der Biografie. Sie können die Kategoriename (z.B. GENRE, TONALITÄT, FOKUS) und den zugehörigen Inhalt frei anpassen.</p> <p className="text-xs text-slate-500">Diese drei Kacheln erscheinen unter der Biografie. Sie können die Kategoriename (z.B. GENRE, TONALITÄT, FOKUS) und den zugehörigen Inhalt frei anpassen.</p>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Schlagwort 1 */} {/* Schlagwort 1 */}
<div className="p-3 bg-slate-950 border border-slate-800 rounded-xl space-y-2"> <div className="p-3 bg-slate-950 border border-slate-800 rounded-xl space-y-2">
<div className="text-xs font-semibold text-amber-400 uppercase flex items-center gap-1"> <div className="text-xs font-semibold text-amber-400 uppercase flex items-center gap-1">
@ -859,35 +909,6 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
</div> </div>
</div> </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 className="space-y-1">
<label className="text-xs font-mono text-slate-400 uppercase">Discord-Einladung / Community</label>
<input type="url" value={profile.discordUrl || ""} onChange={(event) => updateProfileField("discordUrl", event.target.value)} placeholder="https://discord.gg/…" 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>
</div> </div>
)} )}
@ -1438,7 +1459,6 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
setBookForm({ setBookForm({
title: "", subtitle: "", coverUrl: "", description: "", title: "", subtitle: "", coverUrl: "", description: "",
publishedYear: new Date().getFullYear(), buyLink: "", publishedYear: new Date().getFullYear(), buyLink: "",
ebookLink: "", paperbackLink: "", samplePdfUrl: "", seriesName: "", seriesNumber: "",
spotifyPlaylistId: "", genres: [], isSpotlight: false, spotlightBadge: "" spotifyPlaylistId: "", genres: [], isSpotlight: false, spotlightBadge: ""
}); });
setGenreInputRaw(""); setGenreInputRaw("");
@ -1574,14 +1594,6 @@ 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" 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 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"> <div className="space-y-1">
<label className="text-xs font-mono text-slate-400 uppercase font-bold text-white">Cover-Bild</label> <label className="text-xs font-mono text-slate-400 uppercase font-bold text-white">Cover-Bild</label>
<ImagePicker <ImagePicker
@ -1604,20 +1616,16 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
<div className="space-y-1"> <div className="space-y-1">
<label className="text-xs font-mono text-slate-400 uppercase flex items-center gap-1"> <label className="text-xs font-mono text-slate-400 uppercase flex items-center gap-1">
<Eye className="w-3.5 h-3.5" /> <Eye className="w-3.5 h-3.5" />
<span>KDP-Link: E-Book</span> <span>Buy-Link / Shop-Link (z.B. Amazon, Thalia)</span>
</label> </label>
<input <input
type="url" type="text"
value={bookForm.ebookLink || bookForm.buyLink || ""} value={bookForm.buyLink || ""}
onChange={(e) => setBookForm({ ...bookForm, ebookLink: e.target.value, buyLink: "" })} onChange={(e) => setBookForm({ ...bookForm, buyLink: e.target.value })}
placeholder="https://..." 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" 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 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"> <div className="space-y-1">
<label className="text-xs font-mono text-slate-400 uppercase flex items-center gap-1"> <label className="text-xs font-mono text-slate-400 uppercase flex items-center gap-1">
<Music className="w-3.5 h-3.5" /> <Music className="w-3.5 h-3.5" />
@ -1643,10 +1651,6 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
/> />
<p className="text-[10px] text-slate-500">Trennen Sie mehrere Genres einfach durch Kommas.</p> <p className="text-[10px] text-slate-500">Trennen Sie mehrere Genres einfach durch Kommas.</p>
</div> </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 */} {/* Buch-Klappentext Synopsis / Description */}
<div className="space-y-1 md:col-span-2"> <div className="space-y-1 md:col-span-2">
@ -1767,25 +1771,6 @@ 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,6 +1,5 @@
import React, { useState, useEffect, useRef } from "react"; import React, { useState, useEffect, useRef } from "react";
import { Upload, Image as ImageIcon, Link as LinkIcon, X, Check, Loader2, FolderOpen, AlertCircle, Trash2 } from "lucide-react"; import { Upload, Image as ImageIcon, Link as LinkIcon, X, Check, Loader2, FolderOpen, AlertCircle } from "lucide-react";
import { adminApi, AdminApiError } from "../api/adminApi";
interface ImagePickerProps { interface ImagePickerProps {
value: string; value: string;
@ -40,10 +39,18 @@ export default function ImagePicker({ value, onChange, label }: ImagePickerProps
setLoadingFiles(true); setLoadingFiles(true);
setError(""); setError("");
try { try {
const data = await adminApi.listUploads(); const response = await fetch("/api/admin/list-uploads");
setServerFiles(data.files || []); if (!response.ok) {
} catch (err) { throw new Error("Fehler beim Laden der Serverdateien");
setError(err instanceof AdminApiError ? err.message : "Verbindung zum Server fehlgeschlagen."); }
const data = await response.json();
if (data.success) {
setServerFiles(data.files || []);
} else {
setError(data.error || "Laden der Dateien fehlgeschlagen.");
}
} catch (err: any) {
setError("Verbindung zum Server fehlgeschlagen.");
console.error(err); console.error(err);
} finally { } finally {
setLoadingFiles(false); setLoadingFiles(false);
@ -71,11 +78,31 @@ export default function ImagePicker({ value, onChange, label }: ImagePickerProps
reader.onloadend = async () => { reader.onloadend = async () => {
const base64Data = reader.result as string; const base64Data = reader.result as string;
try { try {
const data = await adminApi.upload(file.name, base64Data); const response = await fetch("/api/admin/upload-file", {
onChange(data.url); method: "POST",
setIsOpen(false); headers: {
} catch (err) { "Content-Type": "application/json",
setError(err instanceof AdminApiError ? err.message : "Upload fehlgeschlagen."); },
body: JSON.stringify({
fileName: file.name,
base64Data: base64Data,
}),
});
if (!response.ok) {
const responseData = await response.json().catch(() => null);
throw new Error(responseData?.error || "Serverfehler beim Upload.");
}
const data = await response.json();
if (data.success) {
onChange(data.url);
setIsOpen(false);
} else {
setError(data.error || "Upload fehlgeschlagen.");
}
} catch (err: any) {
setError("Upload-Fehler: " + err.message);
} finally { } finally {
setUploading(false); setUploading(false);
} }
@ -129,18 +156,6 @@ export default function ImagePicker({ value, onChange, label }: ImagePickerProps
setIsOpen(false); setIsOpen(false);
}; };
const handleDeleteServerFile = async (file: ServerFile) => {
if (!confirm(`Bild „${file.name}“ wirklich dauerhaft löschen?`)) return;
setError("");
try {
await adminApi.deleteUpload(file.name);
setServerFiles((files) => files.filter((candidate) => candidate.name !== file.name));
if (value === file.url) onChange("");
} catch (err) {
setError(err instanceof AdminApiError ? err.message : "Die Datei konnte nicht gelöscht werden.");
}
};
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@ -315,22 +330,22 @@ export default function ImagePicker({ value, onChange, label }: ImagePickerProps
{serverFiles.map((file) => { {serverFiles.map((file) => {
const isSelected = value === file.url; const isSelected = value === file.url;
return ( return (
<div <button
key={file.name} key={file.name}
type="button"
onClick={() => handleSelectServerFile(file.url)}
className={`group relative aspect-square rounded-xl overflow-hidden border bg-slate-950 text-left transition-all ${ className={`group relative aspect-square rounded-xl overflow-hidden border bg-slate-950 text-left transition-all ${
isSelected isSelected
? "border-indigo-500 ring-2 ring-indigo-500/20" ? "border-indigo-500 ring-2 ring-indigo-500/20"
: "border-slate-800 hover:border-slate-700" : "border-slate-800 hover:border-slate-700"
}`} }`}
> >
<button type="button" onClick={() => handleSelectServerFile(file.url)} className="absolute inset-0 z-0 w-full h-full">
<img <img
src={file.url} src={file.url}
alt={file.name} alt={file.name}
className="w-full h-full object-cover transition-transform group-hover:scale-105" className="w-full h-full object-cover transition-transform group-hover:scale-105"
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
/> />
</button>
{/* Overlay filename on hover */} {/* Overlay filename on hover */}
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/40 to-transparent p-2 flex flex-col justify-end opacity-0 group-hover:opacity-100 transition-opacity"> <div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/40 to-transparent p-2 flex flex-col justify-end opacity-0 group-hover:opacity-100 transition-opacity">
<p className="text-[9px] text-slate-200 truncate font-mono">{file.name}</p> <p className="text-[9px] text-slate-200 truncate font-mono">{file.name}</p>
@ -341,16 +356,7 @@ export default function ImagePicker({ value, onChange, label }: ImagePickerProps
<Check className="w-3 h-3" /> <Check className="w-3 h-3" />
</div> </div>
)} )}
<button </button>
type="button"
onClick={() => handleDeleteServerFile(file)}
className="absolute top-1.5 left-1.5 z-20 rounded-md bg-black/75 p-1.5 text-slate-300 opacity-0 transition-opacity hover:bg-rose-900 hover:text-white group-hover:opacity-100 focus:opacity-100"
aria-label={`${file.name} löschen`}
title="Bild löschen"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
); );
})} })}
</div> </div>

View file

@ -1,12 +1,11 @@
import { useEffect, useState } from "react"; import { 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, Download, ChevronDown, ChevronUp } from "lucide-react"; import { BookOpen, Star, HelpCircle, Award, Compass, ArrowRight, ExternalLink, Moon } 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";
import SpotifyConsentPlayer from "./SpotifyConsentPlayer"; import SpotifyConsentPlayer from "./SpotifyConsentPlayer";
import AccessibleModal from "./AccessibleModal";
interface PortfolioPageProps { interface PortfolioPageProps {
data: AuthorProfile; data: AuthorProfile;
@ -35,8 +34,6 @@ export default function PortfolioPage({ data, legalDocuments = [], theme }: Port
const [selectedProject, setSelectedProject] = useState<Project | null>(null); const [selectedProject, setSelectedProject] = useState<Project | null>(null);
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 [showAllDownloads, setShowAllDownloads] = useState(false);
const relevantLegalDocs = legalDocuments; const relevantLegalDocs = legalDocuments;
@ -51,63 +48,6 @@ export default function PortfolioPage({ data, legalDocuments = [], theme }: Port
const url = safeExternalUrl(link.url); const url = safeExternalUrl(link.url);
return link.label.trim() && url ? [{ label: link.label.trim(), 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 availableDownloads = (data.downloads || []).filter((entry) => entry.pdfUrl || entry.epubUrl);
const visibleDownloads = showAllDownloads ? availableDownloads : availableDownloads.slice(0, 3);
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.");
}
};
const toggleDownloads = () => {
const isCollapsing = showAllDownloads;
setShowAllDownloads((current) => !current);
if (isCollapsing) window.requestAnimationFrame(() => document.getElementById("downloads")?.scrollIntoView({ behavior: "smooth", block: "start" }));
};
return ( return (
<div <div
@ -176,11 +116,6 @@ 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>
@ -279,7 +214,7 @@ export default function PortfolioPage({ data, legalDocuments = [], theme }: Port
{data.projects.map((project) => ( {data.projects.map((project) => (
<button <button
key={project.id} key={project.id}
onClick={() => openDetail("project", project.id)} onClick={() => setSelectedProject(project)}
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" 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` }} style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}25` }}
> >
@ -341,10 +276,10 @@ export default function PortfolioPage({ data, legalDocuments = [], theme }: Port
</div> </div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-6 justify-items-center"> <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-6 justify-items-center">
{displayedBooks.map((book) => ( {data.books.map((book) => (
<button <button
key={book.id} key={book.id}
onClick={() => openDetail("book", book.id)} onClick={() => setSelectedBook(book)}
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" 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` }} style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}30` }}
> >
@ -358,7 +293,6 @@ 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"> <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> <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> <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> </div>
</button> </button>
@ -366,38 +300,6 @@ 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` }}>
@ -452,46 +354,23 @@ export default function PortfolioPage({ data, legalDocuments = [], theme }: Port
</section> </section>
)} )}
{(data.contactEmail || data.instagramUrl || data.threadsUrl || data.discordUrl) && (
<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>
)}
{safeExternalUrl(data.discordUrl || "") && (
<a href={safeExternalUrl(data.discordUrl || "")!} 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` }}>
<MessageCircle className="h-4 w-4" /><span>Discord</span>
</a>
)}
</div>
</section>
)}
</main> </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 */} {/* Book details modular interface modal */}
{selectedBook && ( {selectedBook && (
<AccessibleModal titleId="book-detail-title" onClose={closeDetail}> <div className="fixed inset-0 z-50 overflow-y-auto" role="dialog" aria-modal="true">
{/* Overlay background */}
<div
className="fixed inset-0 bg-slate-950/80 backdrop-blur-md transition-opacity"
onClick={() => setSelectedBook(null)}
/>
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
<div className="relative transform overflow-hidden rounded-2xl bg-slate-900 border border-slate-800 text-left shadow-2xl transition-all sm:my-8 sm:w-full sm:max-w-4xl p-6 md:p-8 space-y-6">
{/* Close Button */} {/* Close Button */}
<button <button
onClick={closeDetail} onClick={() => setSelectedBook(null)}
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" 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] SCHLIESSEN [ESC]
@ -509,11 +388,17 @@ export default function PortfolioPage({ data, legalDocuments = [], theme }: Port
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
/> />
</div> </div>
<div className="mt-6 w-full max-w-[260px] space-y-2"> {selectedBook.buyLink && (
{(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>} <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>} href={selectedBook.buyLink}
{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>} target="_blank"
</div> 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> </div>
{/* Book Details */} {/* Book Details */}
@ -530,7 +415,7 @@ export default function PortfolioPage({ data, legalDocuments = [], theme }: Port
))} ))}
</div> </div>
<h3 id="book-detail-title" className="text-3xl font-extrabold text-white"> <h3 className="text-3xl font-extrabold text-white">
{selectedBook.title} {selectedBook.title}
</h3> </h3>
@ -539,11 +424,6 @@ export default function PortfolioPage({ data, legalDocuments = [], theme }: Port
{selectedBook.subtitle} {selectedBook.subtitle}
</h4> </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" /> <div className="h-px bg-indigo-950" />
@ -561,16 +441,26 @@ export default function PortfolioPage({ data, legalDocuments = [], theme }: Port
</div> </div>
</AccessibleModal> </div>
</div>
</div>
)} )}
{/* Project details modular modal */} {/* Project details modular modal */}
{selectedProject && ( {selectedProject && (
<AccessibleModal titleId="project-detail-title" onClose={closeDetail} maxWidthClass="sm:max-w-5xl"> <div className="fixed inset-0 z-50 overflow-y-auto" role="dialog" aria-modal="true">
{/* Overlay background */}
<div
className="fixed inset-0 bg-slate-950/80 backdrop-blur-md transition-opacity"
onClick={() => setSelectedProject(null)}
/>
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
<div className="relative transform overflow-hidden rounded-2xl bg-slate-900 border border-slate-800 text-left shadow-2xl transition-all sm:my-8 sm:w-full sm:max-w-5xl p-6 md:p-8 space-y-6">
{/* Close Button */} {/* Close Button */}
<button <button
onClick={closeDetail} onClick={() => setSelectedProject(null)}
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" 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] SCHLIESSEN [ESC]
@ -586,12 +476,10 @@ export default function PortfolioPage({ data, legalDocuments = [], theme }: Port
</span> </span>
</div> </div>
<h3 id="project-detail-title" className="text-3xl font-extrabold text-white"> <h3 className="text-3xl font-extrabold text-white">
{selectedProject.title} {selectedProject.title}
</h3> </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="h-px bg-indigo-950" />
<div className="space-y-2"> <div className="space-y-2">
@ -626,7 +514,9 @@ export default function PortfolioPage({ data, legalDocuments = [], theme }: Port
</div> </div>
</div> </div>
</AccessibleModal> </div>
</div>
</div>
)} )}
{/* Styled Footer */} {/* Styled Footer */}

View file

@ -101,29 +101,23 @@ export default function SpotlightSection({
</div> </div>
</div> </div>
{(book.ebookLink || book.buyLink) && ( {book.buyLink && (
<a <a
href={book.ebookLink || book.buyLink} href={book.buyLink}
target="_blank" target="_blank"
rel="noopener noreferrer" 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]" 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" }} style={{ backgroundColor: accentColor, color: "#000" }}
> >
<ShoppingBag className="w-4 h-4" /> <ShoppingBag className="w-4 h-4" />
<span>{isFuture ? "E-Book vorbestellen" : "E-Book"}</span> <span>{isFuture ? "Jetzt vorbestellen" : "Jetzt im Handel"}</span>
<ExternalLink className="w-3.5 h-3.5 ml-auto opacity-70" /> <ExternalLink className="w-3.5 h-3.5 ml-auto opacity-70" />
</a> </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> </div>
{/* Details column */} {/* Details column */}
<div className="md:col-span-8 space-y-5"> <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"> <div className="flex flex-wrap items-center gap-2">
<span <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" 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

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

@ -1,62 +0,0 @@
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

@ -1,68 +0,0 @@
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,11 +16,6 @@ export interface Book {
description: string; description: string;
publishedYear: number | string; // Accepts year (2026) or full date string (15.10.2026, Herbst 2026) publishedYear: number | string; // Accepts year (2026) or full date string (15.10.2026, Herbst 2026)
buyLink?: string; buyLink?: string;
ebookLink?: string;
paperbackLink?: string;
samplePdfUrl?: string;
seriesName?: string;
seriesNumber?: number | string;
spotifyPlaylistId?: string; // Just the playlist ID (e.g., "47R6ZAdk7Xf6M4i9mR0fP1") spotifyPlaylistId?: string; // Just the playlist ID (e.g., "47R6ZAdk7Xf6M4i9mR0fP1")
genres?: string[]; genres?: string[];
isSpotlight?: boolean; // Highlighted as spotlight / new release isSpotlight?: boolean; // Highlighted as spotlight / new release
@ -32,16 +27,6 @@ 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;
@ -53,13 +38,6 @@ export interface AuthorProfile {
customSectionTitle?: string; customSectionTitle?: string;
customSectionContent?: string; customSectionContent?: string;
customSectionLinks?: CustomSectionLink[]; customSectionLinks?: CustomSectionLink[];
contactEmail?: string;
instagramUrl?: string;
threadsUrl?: 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

@ -119,30 +119,15 @@ test("production hardening and public routing", async (t) => {
response = await fetch(`${baseUrl}/api/admin/author-data`, { headers: { cookie } }); response = await fetch(`${baseUrl}/api/admin/author-data`, { headers: { cookie } });
assert.equal(response.status, 200); assert.equal(response.status, 200);
const adminData = await response.json(); const adminData = await response.json();
adminData.erotica.contactEmail = "kontakt@example.test";
adminData.erotica.instagramUrl = "https://instagram.com/example-author";
adminData.erotica.discordUrl = "https://discord.gg/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 = [ adminData.erotica.customSectionLinks = [
{ label: "Zum befreundeten Pseudonym", url: "https://example-pseudonym.test/" }, { label: "Zum befreundeten Pseudonym", url: "https://example-pseudonym.test/" },
]; ];
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 },
body: JSON.stringify({ profileKey: "erotica", profileData: adminData.erotica, expectedRevision: adminData.revision }), body: JSON.stringify({ profileKey: "erotica", profileData: adminData.erotica }),
}); });
assert.equal(response.status, 200); assert.equal(response.status, 200);
const firstSave = await response.json();
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: adminData.revision }),
});
assert.equal(response.status, 409);
response = await fetch(`${baseUrl}/api/admin/save-profile`, { response = await fetch(`${baseUrl}/api/admin/save-profile`, {
method: "POST", method: "POST",
@ -150,7 +135,6 @@ test("production hardening and public routing", async (t) => {
body: JSON.stringify({ body: JSON.stringify({
profileKey: "erotica", profileKey: "erotica",
profileData: { ...adminData.erotica, customSectionLinks: [{ label: "Unsicher", url: "javascript:alert(1)" }] }, profileData: { ...adminData.erotica, customSectionLinks: [{ label: "Unsicher", url: "javascript:alert(1)" }] },
expectedRevision: firstSave.revision,
}), }),
}); });
assert.equal(response.status, 400); assert.equal(response.status, 400);
@ -166,10 +150,6 @@ test("production hardening and public routing", async (t) => {
assert.deepEqual(publicData.profile.customSectionLinks, [ assert.deepEqual(publicData.profile.customSectionLinks, [
{ label: "Zum befreundeten Pseudonym", url: "https://example-pseudonym.test/" }, { label: "Zum befreundeten Pseudonym", url: "https://example-pseudonym.test/" },
]); ]);
assert.equal(publicData.profile.contactEmail, "kontakt@example.test");
assert.equal(publicData.profile.discordUrl, "https://discord.gg/example-author");
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("Clara Finch"), false);
assert.equal(publicResponse.body.includes("Renee Heart"), false); assert.equal(publicResponse.body.includes("Renee Heart"), false);
assert.equal(publicResponse.body.includes("Daniel Hesse"), false); assert.equal(publicResponse.body.includes("Daniel Hesse"), false);
@ -199,109 +179,11 @@ test("production hardening and public routing", async (t) => {
response = await upload("wrong.jpg", validPng); response = await upload("wrong.jpg", validPng);
assert.equal(response.status, 415); 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/);
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.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`, {
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();
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 } });
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);
assert.equal(response.headers.get("x-content-type-options"), "nosniff"); assert.equal(response.headers.get("x-content-type-options"), "nosniff");
assert.match(response.headers.get("cache-control") || "", /immutable/); assert.match(response.headers.get("cache-control") || "", /immutable/);
adminData.erotica.avatarUrl = uploaded.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: sampleSave.revision }),
});
assert.equal(response.status, 200);
const referencedSave = await response.json();
response = await fetch(`${baseUrl}/api/admin/uploads/${path.basename(uploaded.url)}`, {
method: "DELETE",
headers: { origin: baseUrl, cookie },
});
assert.equal(response.status, 409);
adminData.erotica.avatarUrl = "";
adminData.erotica.books[0].samplePdfUrl = "";
adminData.erotica.downloads = [];
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: referencedSave.revision }),
});
assert.equal(response.status, 200);
response = await fetch(`${baseUrl}/api/admin/uploads/${path.basename(uploaded.url)}`, {
method: "DELETE",
headers: { origin: baseUrl, cookie },
});
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/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);
response = await fetch(`${baseUrl}/api/admin/session`, { headers: { cookie } }); response = await fetch(`${baseUrl}/api/admin/session`, { headers: { cookie } });
@ -321,12 +203,6 @@ test("production hardening and public routing", async (t) => {
response = await fetch(`${baseUrl}/`); response = await fetch(`${baseUrl}/`);
assert.equal(response.headers.get("x-frame-options"), "DENY"); assert.equal(response.headers.get("x-frame-options"), "DENY");
assert.ok(response.headers.get("content-security-policy")); 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 { } finally {
await stopServer(child); await stopServer(child);
await rm(dataDir, { recursive: true, force: true }); await rm(dataDir, { recursive: true, force: true });