diff --git a/README.md b/README.md index eb7e060..d2ea015 100644 --- a/README.md +++ b/README.md @@ -1,120 +1,200 @@ -# 📚 Dual Identity Autoren-Zentrale (Selbstgehostet) +# Autoren-Portfolio-Suite -Willkommen in Ihrer modernen, reaktiven Autoren-Website für das 21. Jahrhundert! Dieses System ermöglicht es Ihnen, mehrere eigenständige Autorenprofile mit individuellen Themen, Biografien, Schreibprojekten und Bücherregalen über eine einzige, passwortgeschützte Administrationsoberfläche zu pflegen. +Eine selbst gehostete Portfolio-Anwendung für mehrere voneinander getrennte Autorenprofile. Inhalte, Bücher, laufende Projekte, Designs, SEO-Daten und Rechtstexte werden über eine gemeinsame, passwortgeschützte Administrationsoberfläche gepflegt. ---- +Technisch besteht die Anwendung aus einem React-/Tailwind-Frontend und einem Express-Backend. Sie kann als einzelner Docker-Container betrieben werden und benötigt keine externe Datenbank. -## 🛠️ Warum kein klassischer LAMP-Stack? (Unser Vorschlag) +## Funktionsumfang -Bisher liefen klassische Portfolios oft auf einem standardmäßigen **LAMP-Stack** (Linux, Apache, MySQL, PHP). Für diese modernisierte Website schlagen wir einen **Node.JS + Docker-Stack** vor. Hier ist der Grund, warum das für Ihre Bedürfnisse die deutlich bessere Option ist: +- Mehrere unabhängig konfigurierte Autorenprofile mit eigenen Domains und Themes +- Biografie, Schlagworte, individuelle Texte und optionale Zusatzsektion +- Bücherregal mit Detailansicht, Cover, Kauflink und optionaler Spotify-Playlist +- Aktuelle Projekte mit Fortschritt, Markdown-Detailtext, Bild und optionaler Spotify-Playlist +- Lokaler Bild-Upload über den Adminbereich +- Verwaltung von Impressum, Datenschutzerklärung und weiteren Rechtstexten +- Optionale serverseitige Gemini-Unterstützung für Klappentexte +- Profilabhängige SEO-Metadaten, Open Graph, strukturierte Daten, Sitemap und `robots.txt` +- Health-Endpunkte, persistente JSON-Datenhaltung und automatische Migrationsbackups -1. **Vollständige Kapselung (Kein Server-Wildwuchs):** Bei einem LAMP-Stack müssen Sie Apache/Nginx, PHP-Laufzeiten und eine MySQL-Datenbank auf dem Host installieren und managen. Unser Docker-Stack bündelt das gesamte Frontend, das Express-Backend und die Speicherrelevanz in einem **einzigen, autarken Container**. -2. **Blitzschnelle, reaktive Benutzeroberfläche (SPA):** Das Frontend nutzt modernste Webtechnologie (React & Tailwind CSS). Seitenwechsel und Buchdetails laden augenblicklich im Browser des Nutzers, ohne das klassische, träge Neuladen von PHP-Seiten. -3. **Einfachheit bei Datensicherungen (Backups):** Anstatt komplexe MySQL-Dumps durchzuführen, speichert unsere Anwendung alle Buchtitel, Projekte, Cover und Biografien in einer schlanken JSON-Datenbankdatei (`/data/database.json`). Ein Backup lässt sich durch einfaches Kopieren dieser Datei anfertigen! -4. **KI-Unterstützung integriert:** Der integrierte **Gemini-Klappentext-Assistent** läuft nahtlos und sicher über das Node-Backend (ohne API-Schlüssel im Webbrowser zu exponieren). +## Trennung der Profile ---- +Die öffentliche Seite erhält ausschließlich die Daten des Profils, das zur aufgerufenen Domain gehört. Andere Profile, deren Domains und interne Pfadkonfigurationen werden von der öffentlichen API nicht ausgeliefert. -## 🌌 Die Trennung der Profile +Im Produktionsmodus gilt der Hostname als Trennlinie: -Das System ist von Grund auf so strukturiert, dass Besucher der Website **keine Verbindung** zwischen den gepflegten Identitäten herstellen müssen. Der Code enthält derzeit vier getrennt auflösbare Profile; Domains und Pfade können im Adminbereich gepflegt werden. Unter anderem: -- **Echte Identität (Daniel Hesse — Sci-Fi & Fantasy):** Erreichbar auf der Standard-Startseite (`/`). Das Design kombiniert dunkelblaue Weltraum-Energie, Monospace-Einflüsse und kosmische Magie-Akzente. -- **Pseudonym (Annie Slone — Sinnliche Literatur):** Erreichbar unter anderem über den Pfad `/sensual-moments`. Das Profil verwendet ein warmes, edles Thema mit klassischer und anspruchsvoller Serif-Asthetik. Es gibt keinerlei gegenseitige Verlinkungen! +- Konfigurierte Domains reagieren nur auf die exakte Domain und deren `www`-Variante. +- Ein URL-Pfad oder Query-Parameter kann nicht zu einem anderen Profil wechseln. +- Pfadbasierte Profilvorschauen stehen nur in der lokalen Entwicklungsumgebung zur Verfügung. +- Vollständige Profildaten sind ausschließlich über einen authentifizierten Admin-Endpunkt verfügbar. +- Gemeinsam gepflegte Rechtstexte werden bei der öffentlichen Ausgabe von strukturiert bekannten Kennungen anderer Profile bereinigt. Die gespeicherten Originaltexte bleiben unverändert. ---- +Der Reverse Proxy sollte zusätzlich nur die tatsächlich verwendeten Domains an den Container weiterleiten. -## 🚀 Inbetriebnahme als Docker-Stack (Empfohlen) +## Architektur -Es wird dringend empfohlen, die Website mithilfe von **Docker und Docker-Compose** zu betreiben. Dies garantiert, dass die Anwendung sofort und unabhängig von installierten Node.JS-Versionen auf Ihrem Server funktioniert. +Die öffentlichen Portfolioseiten verwenden eine gemeinsame, daten- und themegesteuerte React-Komponente. Dadurch liegen Layout, Buch- und Projektmodale, Rechtstexte sowie die Medienintegration nur einmal im Code vor. Profilbezogene Inhalte und Farben kommen aus der Datenbank; die Auswahl des Profils findet serverseitig statt. -### 1. Dateien vorbereiten -Stellen Sie sicher, dass sich folgende Dateien im gleichen Ordner auf Ihrem Server befinden: -- `Dockerfile` -- `docker-compose.yml` -- Der gesamte Code-Ordner +Wichtige Verzeichnisse und Dateien: -### 2. Konfiguration anpassen -Kopieren Sie `.env.example` nach `.env` und tragen Sie dort die Geheimnisse ein. Die `.env`-Datei wird nicht eingecheckt: -```dotenv -ADMIN_PASSWORD=IhrSicheresLieblingsPasswort123 -SESSION_SECRET=EineUnabhaengigeZufaelligeZeichenfolgeMitMindestens32Zeichen -GEMINI_API_KEY=Ihr_Gemini_API_Schluessel +```text +src/App.tsx Öffentliche/Admin-Routen und Datenabruf +src/components/PortfolioPage.tsx Gemeinsame öffentliche Portfolioseite +src/components/AdminPanel.tsx Administrationsarbeitsbereich +src/components/admin/ Ausgelagerte Admin-Komponenten +src/defaultData.ts Ausgangsdaten für eine neue Installation +server.ts API, Routing, Sessions, Uploads und SEO +data/database.json Persistente Inhaltsdaten +data/uploads/ Lokal hochgeladene Bilder +data/backups/ Automatische Migrationsbackups +tests/ Integrations- und Sicherheitstests ``` -Der Admin-Login erzeugt eine auf 24 Stunden begrenzte, serverseitige Sitzung in einem `HttpOnly`-, `Secure`- und `SameSite=Strict`-Cookie. Ein Container-Neustart beendet aktive Sitzungen. Das Passwort selbst wird nicht im Browser gespeichert. Ohne `ADMIN_PASSWORD` und `SESSION_SECRET` startet die Anwendung im Produktionsmodus bewusst nicht. +## Docker-Inbetriebnahme + +### 1. Konfiguration anlegen + +Kopiere `.env.example` nach `.env` und ersetze die Beispielwerte: + +```dotenv +ADMIN_PASSWORD=EinLangesZufaelligesAdminPasswort +SESSION_SECRET=EineUnabhaengigeZufaelligeZeichenfolgeMitMindestens32Zeichen + +# Optional: aktiviert den Klappentext-Assistenten +GEMINI_API_KEY= + +# Nur setzen, wenn genau ein vertrauenswürdiger Reverse Proxy vorgeschaltet ist +# TRUST_PROXY=1 +``` + +Für den Produktionsbetrieb gelten folgende Mindestanforderungen: + +- `ADMIN_PASSWORD`: gesetzt und mindestens 12 Zeichen lang +- `SESSION_SECRET`: gesetzt, unabhängig vom Passwort und mindestens 32 Zeichen lang + +Ohne gültige Werte startet der Server im Produktionsmodus bewusst nicht. Geheimnisse gehören ausschließlich in `.env` beziehungsweise in die Secret-Verwaltung der Betriebsumgebung und nicht in `docker-compose.yml` oder Git. + +### 2. Container starten -### 3. Container starten -Führen Sie im entsprechenden Verzeichnis folgenden Befehl aus: ```bash docker compose up -d --build ``` -Die Anwendung baut das Image und startet die Autoren-Zentrale im Hintergrund. Sie ist nun direkt auf Port **`3000`** Ihres Webservers erreichbar! -#### Reverse Proxy Tipp: -Sie können ganz hervorragend einen Reverse Proxy wie **Nginx Proxy Manager**, **Traefik** oder **Caddy** davorhängen, um SSL-Zertifikate (Let's Encrypt) zuzuweisen und Ihre Domain auf den Container-Port `3000` umzuleiten. +Die Anwendung ist anschließend am veröffentlichten Container-Port `3000` erreichbar. In einer öffentlichen Installation sollte davor ein Reverse Proxy wie Caddy, Traefik oder Nginx Proxy Manager TLS übernehmen und ausschließlich die vorgesehenen Domains weiterleiten. -Wenn genau ein vertrauenswürdiger Reverse Proxy vor dem Container sitzt und Port 3000 nicht direkt aus dem Internet erreichbar ist, setzen Sie zusätzlich `TRUST_PROXY=1` in `.env`. So verwendet das Login-Limit die ursprüngliche Client-IP. Bei direkter Veröffentlichung des Containerports darf diese Option nicht aktiviert werden. +Wenn genau ein vertrauenswürdiger Proxy vorgeschaltet und Port 3000 nicht direkt öffentlich erreichbar ist, kann `TRUST_PROXY=1` gesetzt werden. Das sorgt dafür, dass das Login-Limit die ursprüngliche Client-IP verwendet. Bei direkter Veröffentlichung des Ports darf diese Option nicht aktiviert werden. ---- +## Installation ohne Docker -## 💾 Manuelle Installation ohne Docker (Alternativ) - -Sollten Sie die Software direkt auf Ihrem Server (ohne Docker) starten wollen: - -1. **Voraussetzung:** Installieren Sie **Node.js** (v18 oder neuer) auf Ihrem Linux- oder Windows-Server. -2. **Abhängigkeiten installieren:** - ```bash - npm install - ``` -3. **Produktions-Build ausführen:** - ```bash - npm run build - ``` -4. **Umgebungsvariablen setzen:** - Erstellen Sie eine `.env`-Datei oder exportieren Sie diese im Terminal: - ```bash - export ADMIN_PASSWORD="IhrSicheresPasswort" - export SESSION_SECRET="EineUnabhaengigeZufaelligeZeichenfolgeMitMindestens32Zeichen" - export GEMINI_API_KEY="Ihr_Gemini_API_Schlüssel" - ``` -5. **Starten:** - ```bash - npm start - ``` - ---- - -## 🧱 Datensicherung / Backups - -Alle Ihre Inhalte (Biografien, Projektfortschritte und Bücherregal-Einträge) liegen als lesbares JSON in Ihrem Projektordner unter: -`./data/database.json` - -Da wir in der `docker-compose.yml` ein **lokales Volume** gemountet haben (`./data:/app/data`), wird diese Datei direkt auf die Festplatte Ihres Servers geschrieben. -- **Backup erstellen:** Sichern Sie einfach die Datei `./data/database.json`. -- **Wiederherstellen:** Platzieren Sie eine gesicherte `database.json` in den `./data/`-Ordner vor dem Starten des Containers. - -Beim Ergänzen neuer Datenfelder legt die Anwendung vor der Migration automatisch eine unveränderte Sicherung unter `./data/backups/` an. Schreibvorgänge erfolgen atomar und werden nacheinander verarbeitet, damit die Datei bei parallelen Änderungen nicht teilweise überschrieben wird. Das Docker-Volume ersetzt dennoch kein externes Backup des Hostsystems. - ---- - -## Betrieb & SEO - -- `/health/live` zeigt an, ob der Prozess läuft. -- `/health/ready` zeigt an, ob die Daten erfolgreich geladen wurden und die Anwendung bereit ist. -- Seitentitel, Beschreibung, Canonical URL, Open-Graph-Daten und strukturierte Personendaten werden passend zur aufgerufenen Autorendomain serverseitig ausgegeben. -- `/robots.txt` und `/sitemap.xml` werden ebenfalls profilabhängig erzeugt; der Adminbereich ist mit `noindex` gekennzeichnet. -- Optionale SEO-Felder können später je Profil gepflegt werden. Ohne sie werden die Angaben rückwärtskompatibel aus den vorhandenen Profildaten abgeleitet. - -### Bild-Uploads - -Der Adminbereich akzeptiert JPEG, PNG, WebP, GIF und AVIF bis maximal 8 MB. Der Server prüft den tatsächlichen Dateikopf unabhängig von Dateiname und Browserangabe, erzeugt einen zufälligen nicht überschreibbaren Namen und begrenzt Uploads auf 30 Versuche pro Sitzung und Stunde. SVG, HTML und andere aktive Dateiformate werden weder angenommen noch aus dem Uploadverzeichnis ausgeliefert. - -### Qualitätschecks +Voraussetzung ist eine unterstützte Node.js-Version (empfohlen: Node.js 20 oder neuer). ```bash -npm run typecheck # TypeScript ohne Ausgabe prüfen -npm test # Produktionsbuild und Integrationstests -npm run check # vollständiger Check aus TypeScript, Build und Tests +npm install +npm run build +NODE_ENV=production \ +ADMIN_PASSWORD="EinLangesZufaelligesAdminPasswort" \ +SESSION_SECRET="EineUnabhaengigeZufaelligeZeichenfolgeMitMindestens32Zeichen" \ +npm start ``` -Die Integrationstests verwenden ausschließlich temporäre Datenverzeichnisse und einen kurzlebigen lokalen Server. Sie prüfen Produktions-Secrets, Admin-Sitzung und Logout, Origin-Schutz, Login-Limit, exaktes Domain-Routing sowie gültige und manipulierte Bild-Uploads. +Optional können `GEMINI_API_KEY`, `TRUST_PROXY`, `PORT` und `DATA_DIR` ergänzt werden. Standardmäßig lauscht die Anwendung auf Port `3000` und verwendet `./data` als Datenverzeichnis. + +Für die lokale Entwicklung genügt: + +```bash +npm install +npm run dev +``` + +Die im Entwicklungsmodus vorhandenen Fallback-Secrets sind ausschließlich für lokale Entwicklung gedacht und werden in Produktion nicht akzeptiert. + +## Admin-Authentifizierung und Schutzmaßnahmen + +Das Admin-Passwort wird nur beim Login übertragen und weder als Bearer-Token zurückgegeben noch im Browser gespeichert. Nach erfolgreicher Anmeldung wird eine zufällige, serverseitig verwaltete Sitzung erzeugt. + +- Sitzungsdauer: 24 Stunden +- Cookie: `HttpOnly`, `SameSite=Strict`, in Produktion zusätzlich `Secure` +- Sitzungen liegen nur im Arbeitsspeicher und enden spätestens bei einem Server-/Container-Neustart +- Schreibende Admin-Anfragen werden auf gleiche Herkunft geprüft +- Fehlgeschlagene Logins werden pro Client-IP begrenzt +- Uploads und Gemini-Aufrufe besitzen zusätzliche Sitzungslimits +- Sicherheitsheader und eine Content Security Policy werden serverseitig gesetzt + +Ein vergessenes Admin-Passwort wird über die Betriebsumgebung geändert; es gibt keine öffentliche Passwort-zurücksetzen-Funktion. + +## Datenhaltung und Backups + +Alle Inhaltsdaten liegen in `data/database.json`. Docker bindet mit `./data:/app/data` das vollständige Datenverzeichnis auf dem Host ein. Damit bleiben Daten und Uploads beim Neubauen oder Ersetzen des Containers erhalten. + +Für ein vollständiges Backup sollte der gesamte Ordner `data/` gesichert werden: + +```text +data/database.json +data/uploads/ +data/backups/ +``` + +Schreibvorgänge auf die JSON-Datenbank erfolgen atomar und nacheinander. Vor schemaändernden Migrationen legt die Anwendung eine unveränderte Sicherung in `data/backups/` an. Diese lokalen Sicherungen ersetzen kein externes, regelmäßig getestetes Backup. + +## Bild-Uploads + +Der Adminbereich akzeptiert JPEG, PNG, WebP, GIF und AVIF mit maximal 8 MB pro Datei. + +Der Server: + +- prüft Dateiendung, angegebenen MIME-Typ und den tatsächlichen Dateikopf, +- lehnt SVG, HTML und andere aktive Formate ab, +- erzeugt zufällige, nicht überschreibbare Dateinamen, +- begrenzt Uploadversuche pro Admin-Sitzung und Stunde, +- liefert Uploads mit `nosniff` und langfristigen Cache-Headern aus. + +Die Bilder können für Avatare, Banner, Buchcover und aktuelle Projekte verwendet werden. Leere Bildfelder erzeugen keinen Request zu einem externen Standardbild. + +## 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. + +Weitere mögliche externe Verbindungen: + +- `GEMINI_API_KEY`: Der optionale Admin-Assistent sendet die eingegebenen Buchinformationen serverseitig an die Google-Gemini-API. +- Kauf- und sonstige Markdown-Links: Erst ein Klick führt zur jeweiligen externen Website. +- 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. + +## SEO und Betriebsendpunkte + +- `/health/live`: Prozess läuft +- `/health/ready`: Daten wurden geladen und der Server ist bereit +- `/robots.txt`: profilabhängige Crawling-Regeln +- `/sitemap.xml`: profilabhängige Sitemap +- `/admin`: mit `noindex` gekennzeichneter Administrationsbereich + +Seitentitel, Beschreibung, Canonical URL, Open-Graph-Daten und strukturierte Personendaten werden serverseitig für die aufgerufene Domain erzeugt. Optionale SEO-Felder können je Profil im Adminbereich gepflegt werden; fehlende Angaben werden aus bestehenden Profildaten abgeleitet. + +## Qualitätschecks + +```bash +npm run typecheck # TypeScript-Typprüfung +npm test # Produktionsbuild und Integrationstests +npm run check # Typprüfung, Build und Tests +``` + +Die Integrationstests verwenden ein temporäres Datenverzeichnis und einen kurzlebigen lokalen HTTP-Server. Geprüft werden unter anderem: + +- verpflichtende Produktions-Secrets, +- Admin-Sitzung und Logout, +- Origin-Schutz und Login-Limit, +- exaktes Domain-Routing und öffentliche Profilisolation, +- Schutz des vollständigen Admin-Datenendpunkts, +- gültige und manipulierte Bild-Uploads, +- wesentliche Sicherheitsheader. + +## Hinweise zur Aktualisierung + +Vor einem Update sollte der komplette `data/`-Ordner gesichert werden. Danach kann das Image neu gebaut werden: + +```bash +docker compose up -d --build +``` + +Vorhandene Inhaltsdaten werden nicht durch die im Quellcode enthaltenen Ausgangsdaten ersetzt. Neue optionale Felder werden migrationsfähig ergänzt; vor notwendigen Schemaänderungen wird automatisch ein Backup angelegt. diff --git a/server.ts b/server.ts index 6684965..e232c67 100644 --- a/server.ts +++ b/server.ts @@ -5,7 +5,7 @@ import { createHmac, randomBytes, timingSafeEqual } from "crypto"; import dotenv from "dotenv"; import { GoogleGenAI } from "@google/genai"; import { defaultAuthorData } from "./src/defaultData.js"; -import { AuthorData, AuthorProfile } from "./src/types.js"; +import { AuthorData, AuthorProfile, PortfolioTheme, PublicAuthorData } from "./src/types.js"; dotenv.config(); @@ -69,6 +69,12 @@ setInterval(() => { const profileKeys = ["scifi", "erotica", "clara", "renee"] as const; type ProfileKey = typeof profileKeys[number]; +const publicThemes: Record = { + scifi: "cosmic", + erotica: "velvet", + clara: "botanical", + renee: "romance", +}; function isAuthorData(value: unknown): value is AuthorData { if (!value || typeof value !== "object") return false; @@ -420,19 +426,24 @@ function profileForRequest(hostname: string, pathname: string): ProfileKey { scifi: ["hesse-sf.de", "danielhesse.de"], }; - for (const key of profileKeys) { - const configuredPaths = (dbCache[key].customPath || "").split(",").map((item) => item.trim().toLowerCase()).filter(Boolean); - for (const value of [...configuredPaths, ...standardPaths[key]]) { - const prefix = value.startsWith("/") ? value : `/${value}`; - if (pathname === prefix || pathname.startsWith(`${prefix}/`)) return key; - } - } for (const key of profileKeys) { const configuredDomains = (dbCache[key].customDomain || "").split(",").map(cleanDomain).filter(Boolean); for (const domain of [...configuredDomains, ...standardDomains[key]]) { if (matchesConfiguredDomain(hostname, domain)) return key; } } + + // Path previews are a local development convenience only. In production the + // hostname is the security boundary, so a path can never reveal another site. + if (process.env.NODE_ENV !== "production") { + for (const key of profileKeys) { + const configuredPaths = (dbCache[key].customPath || "").split(",").map((item) => item.trim().toLowerCase()).filter(Boolean); + for (const value of [...configuredPaths, ...standardPaths[key]]) { + const prefix = value.startsWith("/") ? value : `/${value}`; + if (pathname === prefix || pathname.startsWith(`${prefix}/`)) return key; + } + } + } return "scifi"; } @@ -451,6 +462,30 @@ function absoluteUrl(value: string | undefined, origin: string): string | undefi } } +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function isolateLegalContent(content: string, activeKey: ProfileKey): string { + let isolated = content; + for (const key of profileKeys) { + if (key === activeKey) continue; + const profile = dbCache[key]; + const identifiers = [ + profile.name, + profile.heroTitle, + ...(profile.customDomain || "").split(",").map((domain) => cleanDomain(domain)), + ...(profile.customPath || "").split(",").map((pathValue) => pathValue.trim()), + ...profile.books.map((book) => book.title), + ...profile.projects.map((project) => project.title), + ].filter((identifier) => identifier.length >= 4); + for (const identifier of identifiers) { + isolated = isolated.replace(new RegExp(escapeRegExp(identifier), "gi"), "Autorinnen- oder Autorenprofil"); + } + } + return isolated; +} + function seoMeta(req: express.Request, scriptNonce?: string): string { const key = profileForRequest(req.hostname, req.path); const profile = dbCache[key]; @@ -491,8 +526,26 @@ function seoMeta(req: express.Request, scriptNonce?: string): string { // --- API ROUTES --- -// 1. Get entire public author setup +// Return only the public data belonging to the requested hostname. app.get("/api/author-data", (req, res) => { + const key = profileForRequest(req.hostname, req.path); + const { customDomain: _customDomain, customPath: _customPath, ...publicProfile } = dbCache[key]; + const legalDocuments = (dbCache.legalDocuments || []) + .filter((document) => document.assignedProfiles.includes(key)) + .map((document) => ({ + ...document, + content: isolateLegalContent(document.content, key), + assignedProfiles: [], + })); + const response: PublicAuthorData = { + profile: publicProfile, + legalDocuments, + theme: publicThemes[key], + }; + res.json(response); +}); + +app.get("/api/admin/author-data", verifySession, (_req, res) => { res.json(dbCache); }); diff --git a/src/App.tsx b/src/App.tsx index 64357b6..e691859 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,193 +1,101 @@ -import { lazy, Suspense, useState, useEffect } from "react"; -import { AuthorData } from "./types"; +import { lazy, Suspense, useEffect, useState } from "react"; import { Lock, RefreshCw } from "lucide-react"; +import type { PublicAuthorData } from "./types"; -const SciFiPortfolio = lazy(() => import("./components/SciFiPortfolio")); -const EroticaPortfolio = lazy(() => import("./components/EroticaPortfolio")); -const ClaraPortfolio = lazy(() => import("./components/ClaraPortfolio")); -const ReneePortfolio = lazy(() => import("./components/ReneePortfolio")); +const PortfolioPage = lazy(() => import("./components/PortfolioPage")); const AdminPanel = lazy(() => import("./components/AdminPanel")); function LoadingScreen() { return (
-

Autoren-Portfolio wird geladen...

+

Portfolio wird geladen...

); } export default function App() { - const [authorData, setAuthorData] = useState(null); + const [publicData, setPublicData] = useState(null); const [loading, setLoading] = useState(true); const [currentPath, setCurrentPath] = useState(window.location.pathname); + const isAdmin = currentPath === "/admin" || currentPath.startsWith("/admin/"); - // Monitor location changes for custom routing useEffect(() => { - const handleLocationChange = () => { - setCurrentPath(window.location.pathname); - }; - - // Listen to popstate for navigation back/forward support + const handleLocationChange = () => setCurrentPath(window.location.pathname); window.addEventListener("popstate", handleLocationChange); - return () => { - window.removeEventListener("popstate", handleLocationChange); - }; + return () => window.removeEventListener("popstate", handleLocationChange); }, []); - // Fetch profiles configuration from server useEffect(() => { - fetch("/api/author-data") - .then((res) => res.json()) - .then((data) => { - setAuthorData(data); - setLoading(false); + if (isAdmin) { + setLoading(false); + return; + } + + const controller = new AbortController(); + setLoading(true); + fetch("/api/author-data", { signal: controller.signal }) + .then((res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json() as Promise; }) - .catch((err) => { - console.error("Fehler beim Abrufen der Autoren-Informationen:", err); - setLoading(false); - }); - }, []); + .then((data) => setPublicData(data)) + .catch((error) => { + if (error instanceof DOMException && error.name === "AbortError") return; + console.error("Fehler beim Abrufen des Portfolios:", error); + setPublicData(null); + }) + .finally(() => setLoading(false)); - // Ermittelt die aktive Suite basierend auf Pfad-Überschreibungen oder dem Domainnamen (Hostname) - const getActiveSuite = (): "erotica" | "clara" | "scifi" | "renee" => { - const hostname = window.location.hostname.toLowerCase(); - const profileKeys: ("erotica" | "clara" | "renee" | "scifi")[] = ["erotica", "clara", "renee", "scifi"]; + return () => controller.abort(); + }, [isAdmin]); - // 1. Pfad-basierte Routen (haben Vorrang, z.B. für Vorschau & Direktlinks) - for (const key of profileKeys) { - const p = authorData?.[key]; - const customPaths: string[] = p?.customPath - ? p.customPath.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean) - : []; - - // Statische Standard-Fallbacks - if (key === "erotica") customPaths.push("/sensual-moments", "/annie-slone", "/marc-velvet"); - if (key === "clara") customPaths.push("/clara-finch", "/clara"); - if (key === "renee") customPaths.push("/renee-heart", "/renee"); - if (key === "scifi") customPaths.push("/sci-fi", "/daniel-hesse"); - - for (const prefix of customPaths) { - if (prefix) { - const formattedPrefix = prefix.startsWith("/") ? prefix : "/" + prefix; - if (currentPath === formattedPrefix || currentPath.startsWith(formattedPrefix + "/")) { - return key; - } - } - } - } - - // 2. Dynamische Domain-basierte Routen (für Live-Domains) - for (const key of profileKeys) { - const p = authorData?.[key]; - const customDomains: string[] = p?.customDomain - ? p.customDomain.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean) - : []; - - // Statische Standard-Fallbacks - if (key === "erotica") customDomains.push("annieslone.de", "marcvelvet.de"); - if (key === "clara") customDomains.push("clarafinch.de", "clara-finch.de"); - if (key === "renee") customDomains.push("reneeheart.de", "renee-heart.de"); - if (key === "scifi") customDomains.push("hesse-sf.de", "danielhesse.de"); - - for (const domain of customDomains) { - // Entferne evtl. eingegebene Protokolle (http://, https://) oder führendes "www." - const cleanDomain = domain.replace(/^https?:\/\//, "").replace(/^www\./, "").trim(); - if (cleanDomain && (hostname === cleanDomain || hostname === `www.${cleanDomain}`)) { - return key; - } - } - } - - // Standard-Fallback (Daniel Hesse SciFi) - return "scifi"; - }; - - // Dynamisch den Dokumententitel (Browsertab-Titel) basierend auf Suite und Autorendaten anpassen useEffect(() => { - if (!authorData) { - document.title = "Lade Autoren-Portfolio..."; - return; - } - if (currentPath === "/admin" || currentPath.startsWith("/admin/")) { - document.title = "Admin-Zentrale | Autor-Suiten"; - return; - } + if (isAdmin) document.title = "Administration"; + else if (publicData) document.title = publicData.profile.seoTitle || publicData.profile.name; + else document.title = "Portfolio"; + }, [isAdmin, publicData]); - const activeSuite = getActiveSuite(); - if (activeSuite === "erotica") { - document.title = authorData.erotica.name || "Annie Slone"; - } else if (activeSuite === "clara") { - document.title = authorData.clara.name || "Clara Finch"; - } else if (activeSuite === "renee") { - document.title = authorData.renee.name || "Renee Heart"; - } else { - document.title = authorData.scifi.name || "Daniel Hesse"; - } - }, [currentPath, authorData]); - - const handleAdminLogout = () => { - // Relocate to landing site after administration work - window.history.pushState({}, "", "/"); - setCurrentPath("/"); + const navigateTo = (path: string) => { + window.history.pushState({}, "", path); + setCurrentPath(path); }; - if (loading) { - return ; + if (isAdmin) { + return ( + }> + navigateTo("/")} /> + + ); } - if (!authorData) { + if (loading) return ; + + if (!publicData) { return ( -
+
-

Verbindungsfehler

-

- Die Verbindung zum lokalen Datenspeicher-Server konnte nicht hergestellt werden. Bitte vergewissern Sie sich, dass Ihr Express API-Server ordnungsgemäß läuft. -

+

Verbindungsfehler

+

Das Portfolio konnte nicht geladen werden.

); } - // Purely route-based render isolation structure - // 1. Administration panel - if (currentPath === "/admin" || currentPath.startsWith("/admin/")) { - return }>; - } - - // Render active portfolio - const renderActivePortfolio = () => { - const activeSuite = getActiveSuite(); - const legalDocs = authorData.legalDocuments || []; - if (activeSuite === "erotica") { - return ; - } - if (activeSuite === "clara") { - return ; - } - if (activeSuite === "renee") { - return ; - } - return ; - }; - return ( }> - {renderActivePortfolio()} - - {/* Absolute floating administration bypass link hidden on the bottom left (hover to show) */} + diff --git a/src/components/AdminPanel.tsx b/src/components/AdminPanel.tsx index 14ddaec..ba4ea4b 100644 --- a/src/components/AdminPanel.tsx +++ b/src/components/AdminPanel.tsx @@ -2,12 +2,13 @@ import React, { useState, useEffect, FormEvent } from "react"; import Markdown from "react-markdown"; import { AuthorData, AuthorProfile, Book, Project, LegalDocument } from "../types"; import { - Lock, Save, Plus, Trash2, Edit2, Check, RefreshCw, Sparkles, + Save, Plus, Trash2, Edit2, Check, RefreshCw, Sparkles, BookOpen, Compass, Clipboard, Eye, Music, LogOut, ArrowLeftRight, Palette, Type, Image as ImageIcon, Globe, Tag, Scale, ShieldCheck, FileText, CheckSquare, Square, Copy, ArrowRight } from "lucide-react"; import ImagePicker from "./ImagePicker"; +import AdminLogin from "./admin/AdminLogin"; interface AdminPanelProps { onLogout: () => void; @@ -73,7 +74,7 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) { const fetchAuthorData = async () => { setLoading(true); try { - const res = await fetch("/api/author-data"); + const res = await fetch("/api/admin/author-data"); const data = await res.json(); setAuthorData(data); } catch (err) { @@ -485,48 +486,7 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) { }; if (!isLoggedIn) { - return ( -
-
-
-
- -
-

Autoren-Zentrale Admin

-

Authentifizierung erforderlich. Passwortgeschütztes Kontrollzentrum.

-
- -
-
- - setPassword(e.target.value)} - placeholder="Passwort eingeben..." - className="w-full px-4 py-3 bg-slate-950 border border-slate-800 rounded-xl text-white outline-none focus:border-cyan-400 transition-colors text-sm" - required - /> - Standard-Passwort im Self-Host ist `autor2026`, anpassbar via Env-Variable `ADMIN_PASSWORD`. -
- - {error && ( -
- {error} -
- )} - - -
-
-
- ); + return ; } if (!authorData || !profile) { @@ -561,25 +521,25 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) { onClick={() => { setActiveProfile("scifi"); setActiveTab("profile"); }} className={`px-3 py-1.5 rounded-lg text-xs font-semibold cursor-pointer transition-all ${activeProfile === "scifi" ? "bg-cyan-500 text-slate-950" : "text-slate-400 hover:text-white"}`} > - Sci-Fi (Daniel Hesse) + Sci-Fi ({authorData.scifi.name})
@@ -855,10 +815,10 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) { type="text" value={profile.customDomain || ""} onChange={(e) => updateProfileField("customDomain", e.target.value)} - placeholder="z.B. reneeheart.de, renee-heart.de" + placeholder="z.B. autorin.de, www.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 font-mono" /> -

Kommagetrennte Domainnamen. Bsp: reneeheart.de, renee-heart.de

+

Kommagetrennte Domainnamen. Bsp: autorin.de, autorin.com

@@ -1840,10 +1800,10 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
{authorData.legalDocuments.map((doc) => { const profileNamesMap = { - scifi: authorData.scifi?.name || "Daniel Hesse", - erotica: authorData.erotica?.name || "Annie Slone", - clara: authorData.clara?.name || "Clara Finch", - renee: authorData.renee?.name || "Renee Heart" + scifi: authorData.scifi.name, + erotica: authorData.erotica.name, + clara: authorData.clara.name, + renee: authorData.renee.name }; return ( @@ -2044,10 +2004,10 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
{[ - { key: "scifi" as const, name: authorData.scifi?.name || "Daniel Hesse", badge: "Sci-Fi" }, - { key: "erotica" as const, name: authorData.erotica?.name || "Annie Slone", badge: "Erotik" }, - { key: "clara" as const, name: authorData.clara?.name || "Clara Finch", badge: "Naturlyrik" }, - { key: "renee" as const, name: authorData.renee?.name || "Renee Heart", badge: "Romantik" } + { key: "scifi" as const, name: authorData.scifi.name, badge: "Sci-Fi" }, + { key: "erotica" as const, name: authorData.erotica.name, badge: "Erotik" }, + { key: "clara" as const, name: authorData.clara.name, badge: "Naturlyrik" }, + { key: "renee" as const, name: authorData.renee.name, badge: "Romantik" } ].map((item) => { const checked = legalForm.assignedProfiles?.includes(item.key); return ( @@ -2094,22 +2054,22 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) { type: "impressum", content: `### Angaben gemäß § 5 Digitale-Dienste-Gesetz (DDG) -**Daniel Hesse** -Musterstraße 12 -10115 Berlin +**Vor- und Nachname** +Straße und Hausnummer +PLZ Ort Deutschland ### Kontakt -**E-Mail:** kontakt@danielhesse.de -**Website:** https://danielhesse.de +**E-Mail:** kontakt@example.org +**Website:** https://example.org ### Redaktionell verantwortlich -Daniel Hesse -Musterstraße 12 -10115 Berlin +Vor- und Nachname +Straße und Hausnummer +PLZ Ort -### Künstlerische Pseudonyme & Urheberrecht -Die auf dieser Internetpräsenz präsentierten literarischen Werke und Inhalte unter den Autorennamen **Daniel Hesse**, **Annie Slone**, **Clara Finch** und **Renee Heart** sind urheberrechtlich geschützte künstlerische Veröffentlichungen. +### Urheberrecht +Die auf dieser Internetpräsenz präsentierten literarischen Werke und Inhalte sind urheberrechtlich geschützt. ### EU-Streitschlichtung Die Europäische Kommission stellt eine Plattform zur Online-Streitbeilegung (OS) bereit: https://ec.europa.eu/consumers/odr/ @@ -2139,10 +2099,10 @@ Die folgenden Hinweise geben einen einfachen Überblick darüber, was mit Ihren ### 2. Verantwortliche Stelle Verantwortlich für die Datenverarbeitung auf dieser Website ist: -**Daniel Hesse** -Musterstraße 12 -10115 Berlin -E-Mail: kontakt@danielhesse.de +**Vor- und Nachname** +Straße und Hausnummer +PLZ Ort +E-Mail: kontakt@example.org ### 3. Datenerfassung auf dieser Website diff --git a/src/components/ClaraPortfolio.tsx b/src/components/ClaraPortfolio.tsx deleted file mode 100644 index 433a252..0000000 --- a/src/components/ClaraPortfolio.tsx +++ /dev/null @@ -1,492 +0,0 @@ -import { useState } from "react"; -import Markdown from "react-markdown"; -import { Book, Project, AuthorProfile, LegalDocument } from "../types"; -import { Leaf, Feather, Sparkles, BookOpen, ExternalLink, ArrowRight } from "lucide-react"; -import SpotlightSection from "./SpotlightSection"; -import LegalModal from "./LegalModal"; -import ProjectDetailMedia from "./ProjectDetailMedia"; -import SpotifyConsentPlayer from "./SpotifyConsentPlayer"; - -interface ClaraPortfolioProps { - data: AuthorProfile; - legalDocuments?: LegalDocument[]; -} - -export default function ClaraPortfolio({ data, legalDocuments = [] }: ClaraPortfolioProps) { - const [selectedBook, setSelectedBook] = useState(null); - const [selectedProject, setSelectedProject] = useState(null); - const [legalModalOpen, setLegalModalOpen] = useState(false); - const [activeLegalId, setActiveLegalId] = useState(null); - - const relevantLegalDocs = legalDocuments.filter(doc => doc.assignedProfiles.includes("clara")); - - const accentColor = data.accentColor || "#8FA797"; - const secondaryColor = data.secondaryColor || "#B59EB1"; - const bgColor = data.backgroundColor || "#0c0a09"; - const cardBgColor = data.cardBgColor || "#1c1917"; - - const fontClass = data.fontFamily === "sans" ? "font-sans" : data.fontFamily === "mono" ? "font-mono" : "font-serif"; - - return ( -
- {/* Hero Banner Background if present */} - {data.heroBannerUrl && ( -
- -
-
- )} - - {/* Delicate organic gradients */} -
-
-
- - {/* Header / Hero */} -
-
-
- - {data.badgeText || "Lyrik & Naturphilosophie"} -
- -

- {data.heroTitle || data.name || "CLARA FINCH"} -

-

- {data.heroSubtitle || "LYRIK, ESSAYS & NATURPHILOSOPHIE"} -

- -
- - {/* Main Grid */} -
- - {/* About writer */} -
-
-
- {/* Outer soft natural glow */} -
-
- {data.name} -
-
-
-
-

- - {data.aboutTitle || "Das Atmen zwischen den Zeilen"} -

-
-

{data.bio}

-
- -
- {(data.bioTag1Label || data.bioTag1Value) && ( -
- -
-
{data.bioTag1Label || "Genre"}
-
{data.bioTag1Value || "Lyrik & Essays"}
-
-
- )} - {(data.bioTag2Label || data.bioTag2Value) && ( -
- -
-
{data.bioTag2Label || "Ästhetik"}
-
{data.bioTag2Value || "Natur & Poesie"}
-
-
- )} - {(data.bioTag3Label || data.bioTag3Value) && ( -
- -
-
{data.bioTag3Label || "Fokus"}
-
{data.bioTag3Value || "Naturkontemplation"}
-
-
- )} -
-
-
- - {/* Section 1.5: Spotlight / Neuerscheinung */} - {data.books?.find(b => b.isSpotlight) && ( - b.isSpotlight)!} - title={data.spotlightTitle} - subtitle={data.spotlightSubtitle} - accentColor={accentColor} - secondaryColor={secondaryColor} - cardBgColor={cardBgColor} - /> - )} - - {/* Dynamic ongoing work writing progress */} -
-
-
-

- - {data.projectsTitle || "Aus den Notizbüchern"} -

-

{data.projectsSubtitle || "Gedanken im Entwurf, Fragmente und Sammlungen, die bald das Licht der Welt erblicken."}

-
-
- -
- {data.projects.map((project) => ( - - ))} -
-
- - {/* Bookshelf display */} -
-
-

- - {data.booksTitle || "Gedruckte Werke"} -

-

{data.booksSubtitle || "Erschlossene Wortlandschaften. Wählen Sie einen Band, um tiefer hineinzuhören."}

-
- -
- {data.books.map((book) => ( - - ))} -
-
- - {/* Custom Highlight Section if present */} - {(data.customSectionTitle || data.customSectionContent) && ( -
- {data.customSectionTitle && ( -

- - {data.customSectionTitle} -

- )} - {data.customSectionContent && ( -
- )} -
- )} - -
- - {/* Book details modular modal */} - {selectedBook && ( -
-
setSelectedBook(null)} - /> - -
-
- - - -
- - {/* Book Cover */} - - - {/* Details text area */} -
-
-
- - Erschienen: {selectedBook.publishedYear} - - {selectedBook.genres?.map((genre) => ( - - {genre} - - ))} -
- -

- {selectedBook.title} -

- - {selectedBook.subtitle && ( -

- {selectedBook.subtitle} -

- )} - -
- -
- {selectedBook.description} -
-
- - {/* Privacy-friendly Spotify player */} - {selectedBook.spotifyPlaylistId && selectedBook.spotifyPlaylistId.trim() !== "" && ( - - )} - -
- -
- -
-
-
- )} - - {/* Project details modular modal */} - {selectedProject && ( -
-
setSelectedProject(null)} - /> - -
-
- - - -
-
- - Status: {selectedProject.status || "In Arbeit"} - - - Notizbucheintrag - -
- -

- {selectedProject.title} -

- -
- -
-
- {data.projectProgressLabel ? `${data.projectProgressLabel.toUpperCase()}:` : "ENTWURF-FORTSCHRITT:"} - {selectedProject.progress}% -
-
-
-
-
- -
- - -
- {data.projectExcerptBadge && ( -
- {data.projectExcerptBadge} -
- )} -

- {data.projectExcerptTitle || "Konzeptuelle Notizen & Auszüge"} -

-
- {selectedProject.description || "Für dieses Buchprojekt wurden noch keine lyrischen Gedanken freigegeben."} -
-
-
-
- -
-
-
- )} - - {/* Footer */} -
-
-

© {new Date().getFullYear()} {data.name}. Alle Rechte vorbehalten.

-

{data.footerText || "Betrachtungen, verwoben mit dem Rhythmus der wachsenden Natur."}

- - {relevantLegalDocs.length > 0 && ( -
- {relevantLegalDocs.map((doc) => ( - - ))} -
- )} -
-
- - {/* Legal Modal */} - setLegalModalOpen(false)} - documents={relevantLegalDocs} - initialDocId={activeLegalId} - accentColor={accentColor} - secondaryColor={secondaryColor} - fontFamily={data.fontFamily || "serif"} - /> -
- ); -} diff --git a/src/components/EroticaPortfolio.tsx b/src/components/EroticaPortfolio.tsx deleted file mode 100644 index 099fd55..0000000 --- a/src/components/EroticaPortfolio.tsx +++ /dev/null @@ -1,492 +0,0 @@ -import { useState } from "react"; -import Markdown from "react-markdown"; -import { Book, Project, AuthorProfile, LegalDocument } from "../types"; -import { Heart, Key, Sparkles, BookOpen, ExternalLink, ArrowRight } from "lucide-react"; -import SpotlightSection from "./SpotlightSection"; -import LegalModal from "./LegalModal"; -import ProjectDetailMedia from "./ProjectDetailMedia"; -import SpotifyConsentPlayer from "./SpotifyConsentPlayer"; - -interface EroticaPortfolioProps { - data: AuthorProfile; - legalDocuments?: LegalDocument[]; -} - -export default function EroticaPortfolio({ data, legalDocuments = [] }: EroticaPortfolioProps) { - const [selectedBook, setSelectedBook] = useState(null); - const [selectedProject, setSelectedProject] = useState(null); - const [legalModalOpen, setLegalModalOpen] = useState(false); - const [activeLegalId, setActiveLegalId] = useState(null); - - const relevantLegalDocs = legalDocuments.filter(doc => doc.assignedProfiles.includes("erotica")); - - const accentColor = data.accentColor || "#f97316"; - const secondaryColor = data.secondaryColor || "#f59e0b"; - const bgColor = data.backgroundColor || "#0a0a0a"; - const cardBgColor = data.cardBgColor || "#171717"; - - const fontClass = data.fontFamily === "sans" ? "font-sans" : data.fontFamily === "mono" ? "font-mono" : "font-serif"; - - return ( -
- {/* Hero Banner Background if present */} - {data.heroBannerUrl && ( -
- -
-
- )} - - {/* Decorative Warm background gradient and peach glow vibes */} -
-
-
- - {/* Header / Hero */} -
-
-
- - {data.badgeText || "Stilvolle Sinnlichkeit & Romantik"} -
- -

- {data.heroTitle || data.name || "M. S. VELVET"} -

-

- {data.heroSubtitle || "ELEGANT-SINNLICHE LITERATUR"} -

- -
- - {/* Main Grid */} -
- - {/* About writer */} -
-
-
- {/* Outer warm glow */} -
-
- {data.name} -
-
-
-
-

- - {data.aboutTitle || "Das Geheimnis hinter der Feder"} -

-
-

{data.bio}

-
- -
- {(data.bioTag1Label || data.bioTag1Value) && ( -
- -
-
{data.bioTag1Label || "Genre"}
-
{data.bioTag1Value || "Sinnliche Romance"}
-
-
- )} - {(data.bioTag2Label || data.bioTag2Value) && ( -
- -
-
{data.bioTag2Label || "Tonalität"}
-
{data.bioTag2Value || "Elegant & Knisternd"}
-
-
- )} - {(data.bioTag3Label || data.bioTag3Value) && ( -
- -
-
{data.bioTag3Label || "Identität"}
-
{data.bioTag3Value || "Diskret / Pseudonym"}
-
-
- )} -
-
-
- - {/* Section 1.5: Spotlight / Neuerscheinung */} - {data.books?.find(b => b.isSpotlight) && ( - b.isSpotlight)!} - title={data.spotlightTitle} - subtitle={data.spotlightSubtitle} - accentColor={accentColor} - secondaryColor={secondaryColor} - cardBgColor={cardBgColor} - /> - )} - - {/* Dynamic ongoing work writing progress */} -
-
-
-

- - {data.projectsTitle || "In der Entstehung..."} -

-

{data.projectsSubtitle || "Ein diskreter Blick hinter die Samtvorhänge meiner aktuellen Manuskripte."}

-
-
- -
- {data.projects.map((project) => ( - - ))} -
-
- - {/* Bookshelf display */} -
-
-

- - {data.booksTitle || "Das Samtregal"} -

-

{data.booksSubtitle || "Wählen Sie ein Buch aus, um tiefer in die Versuchung einzutauchen."}

-
- -
- {data.books.map((book) => ( - - ))} -
-
- - {/* Custom Highlight Section if present */} - {(data.customSectionTitle || data.customSectionContent) && ( -
- {data.customSectionTitle && ( -

- - {data.customSectionTitle} -

- )} - {data.customSectionContent && ( -
- )} -
- )} - -
- - {/* Book details modular modal */} - {selectedBook && ( -
-
setSelectedBook(null)} - /> - -
-
- - - -
- - {/* Book Cover */} - - - {/* Details text area */} -
-
-
- - Sinnlich seit: {selectedBook.publishedYear} - - {selectedBook.genres?.map((genre) => ( - - {genre} - - ))} -
- -

- {selectedBook.title} -

- - {selectedBook.subtitle && ( -

- {selectedBook.subtitle} -

- )} - -
- -
- {selectedBook.description} -
-
- - {/* Privacy-friendly Spotify player */} - {selectedBook.spotifyPlaylistId && selectedBook.spotifyPlaylistId.trim() !== "" && ( - - )} - -
- -
- -
-
-
- )} - - {/* Project details modular modal */} - {selectedProject && ( -
-
setSelectedProject(null)} - /> - -
-
- - - -
-
- - Status: {selectedProject.status || "In Arbeit"} - - - Intime Einblicke - -
- -

- {selectedProject.title} -

- -
- -
-
- {data.projectProgressLabel ? `${data.projectProgressLabel.toUpperCase()}:` : "REIFEGRAD DES WERKS:"} - {selectedProject.progress}% -
-
-
-
-
- -
- - -
- {data.projectExcerptBadge && ( -
- {data.projectExcerptBadge} -
- )} -

- {data.projectExcerptTitle || "Konzeptuelle Skizzen & Atmosphärische Auszüge"} -

-
- {selectedProject.description || "Für dieses diskrete Werk wurde noch kein detaillierter Auszug freigegeben."} -
-
-
-
- -
-
-
- )} - - {/* Footer */} -
-
-

© {new Date().getFullYear()} {data.name}. Alle Rechte vorbehalten.

-

{data.footerText || "Geschlossenes Refugium für feine, hochästhetische Phantasiereisen."}

- - {relevantLegalDocs.length > 0 && ( -
- {relevantLegalDocs.map((doc) => ( - - ))} -
- )} -
-
- - {/* Legal Modal */} - setLegalModalOpen(false)} - documents={relevantLegalDocs} - initialDocId={activeLegalId} - accentColor={accentColor} - secondaryColor={secondaryColor} - fontFamily={data.fontFamily || "serif"} - /> -
- ); -} diff --git a/src/components/SciFiPortfolio.tsx b/src/components/PortfolioPage.tsx similarity index 92% rename from src/components/SciFiPortfolio.tsx rename to src/components/PortfolioPage.tsx index 0ee6b11..90b366b 100644 --- a/src/components/SciFiPortfolio.tsx +++ b/src/components/PortfolioPage.tsx @@ -1,29 +1,38 @@ import { useState } from "react"; import Markdown from "react-markdown"; -import { Book, Project, AuthorProfile, LegalDocument } from "../types"; +import { Book, Project, AuthorProfile, LegalDocument, PortfolioTheme } from "../types"; import { BookOpen, Star, HelpCircle, Award, Compass, ArrowRight, ExternalLink, Moon } from "lucide-react"; import SpotlightSection from "./SpotlightSection"; import LegalModal from "./LegalModal"; import ProjectDetailMedia from "./ProjectDetailMedia"; import SpotifyConsentPlayer from "./SpotifyConsentPlayer"; -interface SciFiPortfolioProps { +interface PortfolioPageProps { data: AuthorProfile; legalDocuments?: LegalDocument[]; + theme: PortfolioTheme; } -export default function SciFiPortfolio({ data, legalDocuments = [] }: SciFiPortfolioProps) { +const themeDefaults: Record = { + cosmic: { accent: "#06b6d4", secondary: "#a855f7", background: "#020617", card: "#0f172a", badge: "Geschichten & Welten", subtitle: "AUTORINNEN- UND AUTORENPORTFOLIO" }, + velvet: { accent: "#f97316", secondary: "#f59e0b", background: "#0a0a0a", card: "#171717", badge: "Literatur & Leidenschaft", subtitle: "AUTORINNEN- UND AUTORENPORTFOLIO" }, + botanical: { accent: "#8FA797", secondary: "#B59EB1", background: "#0c0a09", card: "#1c1917", badge: "Poesie & Gedanken", subtitle: "AUTORINNEN- UND AUTORENPORTFOLIO" }, + romance: { accent: "#e11d48", secondary: "#f59e0b", background: "#10070b", card: "#1f1117", badge: "Geschichten & Gefühle", subtitle: "AUTORINNEN- UND AUTORENPORTFOLIO" }, +}; + +export default function PortfolioPage({ data, legalDocuments = [], theme }: PortfolioPageProps) { const [selectedBook, setSelectedBook] = useState(null); const [selectedProject, setSelectedProject] = useState(null); const [legalModalOpen, setLegalModalOpen] = useState(false); const [activeLegalId, setActiveLegalId] = useState(null); - const relevantLegalDocs = legalDocuments.filter(doc => doc.assignedProfiles.includes("scifi")); + const relevantLegalDocs = legalDocuments; - const accentColor = data.accentColor || "#06b6d4"; - const secondaryColor = data.secondaryColor || "#a855f7"; - const bgColor = data.backgroundColor || "#020617"; - const cardBgColor = data.cardBgColor || "#0f172a"; + const defaults = themeDefaults[theme]; + const accentColor = data.accentColor || defaults.accent; + const secondaryColor = data.secondaryColor || defaults.secondary; + const bgColor = data.backgroundColor || defaults.background; + const cardBgColor = data.cardBgColor || defaults.card; const fontClass = data.fontFamily === "serif" ? "font-serif" : data.fontFamily === "mono" ? "font-mono" : "font-sans"; @@ -60,14 +69,14 @@ export default function SciFiPortfolio({ data, legalDocuments = [] }: SciFiPortf style={{ borderColor: `${accentColor}40`, color: accentColor }} > - {data.badgeText || "Cyber-Mystisches Archive"} + {data.badgeText || defaults.badge}

- {data.heroTitle || data.name || "DANIEL HESSE"} + {data.heroTitle || data.name}

- {data.heroSubtitle || "WELTENWANDLER & STERNENZEICHNER"} + {data.heroSubtitle || defaults.subtitle}

{data.bioTag1Label || "Fokus"}
-
{data.bioTag1Value || "Sci-Fi & Fantasy"}
+
{data.bioTag1Value || "Literatur"}
)} @@ -144,7 +153,7 @@ export default function SciFiPortfolio({ data, legalDocuments = [] }: SciFiPortf
{data.bioTag2Label || "Welten"}
-
{data.bioTag2Value || "Techno-Magie"}
+
{data.bioTag2Value || "Geschichten"}
)} @@ -153,7 +162,7 @@ export default function SciFiPortfolio({ data, legalDocuments = [] }: SciFiPortf
{data.bioTag3Label || "Stil"}
-
{data.bioTag3Value || "Episch & Atmosphärisch"}
+
{data.bioTag3Value || "Atmosphärisch"}
)} @@ -184,7 +193,7 @@ export default function SciFiPortfolio({ data, legalDocuments = [] }: SciFiPortf

{data.projectsSubtitle || "Ein exklusiver Blick auf das, was sich gerade auf meinem Schreib-Amboss befindet."}

- Laufendes Telemetrie-Tracking + Aktuell in Arbeit
@@ -271,7 +280,7 @@ export default function SciFiPortfolio({ data, legalDocuments = [] }: SciFiPortf

{book.title}

-

Details auslesen

+

Details anzeigen

))} @@ -352,7 +361,7 @@ export default function SciFiPortfolio({ data, legalDocuments = [] }: SciFiPortf 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" > - Invasion starten (Kauf-Link) + Zum Buch )} @@ -429,7 +438,7 @@ export default function SciFiPortfolio({ data, legalDocuments = [] }: SciFiPortf Status: {selectedProject.status || "In Arbeit"} - Übertragung aktiv + Aktuelles Projekt
@@ -441,7 +450,7 @@ export default function SciFiPortfolio({ data, legalDocuments = [] }: SciFiPortf
- {data.projectProgressLabel ? `${data.projectProgressLabel.toUpperCase()}:` : "ORGANISCHER FORTSCHRITT:"} + {data.projectProgressLabel ? `${data.projectProgressLabel.toUpperCase()}:` : "FORTSCHRITT:"} {selectedProject.progress}%
@@ -480,7 +489,7 @@ export default function SciFiPortfolio({ data, legalDocuments = [] }: SciFiPortf