Compare commits

...

34 commits
dev ... main

Author SHA1 Message Date
Daniel Heße
a69b81e3d7 Added CI/CD workflow
Some checks failed
CI / ci (push) Has been cancelled
2026-08-21 13:12:53 +02:00
Dada1981
b0c6f8d009
Merge pull request #20 from Dada1981/codex/download-library
feat: add downloadable publication library
2026-08-18 16:07:55 +02:00
Daniel Heße
85768d76cd feat: add downloadable publication library 2026-08-18 16:07:15 +02:00
Dada1981
09c4b749f2
Merge pull request #19 from Dada1981/codex/author-card-feature-suite
feat: add Discord contact links
2026-08-16 18:59:20 +02:00
Daniel Heße
b9e9a8666e feat: add Discord contact links 2026-08-16 18:58:36 +02:00
Dada1981
8d3980d654
Merge pull request #18 from Dada1981/codex/author-card-feature-suite
feat: expand author profiles and book details
2026-08-15 20:19:36 +02:00
Daniel Heße
040854ff1b feat: expand author profiles and book details 2026-08-15 10:21:35 +02:00
Dada1981
76957c8284
Merge pull request #17 from Dada1981/codex/admin-quality-hardening
Codex/admin quality hardening
2026-08-15 09:59:58 +02:00
Daniel Heße
2f58d6cb67 refactor: harden admin editing workflows 2026-08-15 09:49:03 +02:00
Daniel Heße
18639de047 feat: add optional custom section links 2026-08-15 09:33:12 +02:00
Dada1981
67570d9b1f
Merge pull request #16 from Dada1981/codex/isolate-and-refactor-portfolios
refactor portfolios and isolate public profiles
2026-08-15 09:07:09 +02:00
Daniel Heße
db31146199 refactor portfolios and isolate public profiles 2026-08-15 09:05:20 +02:00
Dada1981
b152e5bf4b
Merge pull request #15 from Dada1981/codex/persistence-seo-hardening
test: harden uploads and cover critical flows
2026-08-15 08:46:52 +02:00
Daniel Heße
950846dd11 test: harden uploads and cover critical flows 2026-08-15 08:45:07 +02:00
Dada1981
463f98d078
Merge pull request #14 from Dada1981/codex/persistence-seo-hardening
feat: secure admin authentication with sessions
2026-08-15 00:39:50 +02:00
Daniel Heße
d85d282067 feat: secure admin authentication with sessions 2026-08-15 00:36:01 +02:00
Dada1981
cf3acf0906
Merge pull request #13 from Dada1981/codex/persistence-seo-hardening
feat: require consent for Spotify embeds
2026-08-15 00:34:38 +02:00
Daniel Heße
3abaaba869 feat: require consent for Spotify embeds 2026-08-14 23:40:58 +02:00
Dada1981
9a37a6e937
Merge pull request #12 from Dada1981/codex/persistence-seo-hardening
feat: improve responsive project detail layout
2026-08-14 23:06:35 +02:00
Daniel Heße
91953bf162 feat: improve responsive project detail layout 2026-08-14 23:05:11 +02:00
Dada1981
9412b39919
Merge pull request #11 from Dada1981/codex/persistence-seo-hardening
feat: harden persistence and enrich portfolio projects
2026-08-14 22:46:59 +02:00
Daniel Heße
69392c850a feat: harden persistence and enrich portfolio projects 2026-08-14 22:43:40 +02:00
Daniel Heße
7b639941b3 Added legal notes function 2026-08-14 22:10:22 +02:00
Daniel Heße
99395d3091 ... 2026-08-11 23:11:41 +02:00
Dada1981
985d2424de
Merge pull request #10 from Dada1981/dev
Added spotlight section
2026-07-26 22:13:35 +02:00
Dada1981
e7d0a43c1d
Merge pull request #9 from Dada1981/dev
Modularization done
2026-07-26 21:36:20 +02:00
Dada1981
97a0264c18
Merge pull request #8 from Dada1981/ai-changes
Switched to domain based routing
2026-07-20 23:26:49 +02:00
Dada1981
ecf8867b37
Merge pull request #7 from Dada1981/ai-changes
Added image picker and upload function
2026-07-20 22:17:12 +02:00
Dada1981
e46e59a2a7
Merge pull request #6 from Dada1981/ai-changes
Added image picker and upload function
2026-07-20 22:12:16 +02:00
Dada1981
0b6955a6fc
Merge pull request #5 from Dada1981/ai-changes
Changed folder structure
2026-07-20 21:48:07 +02:00
Dada1981
a8a971ee6b
Merge pull request #4 from Dada1981/ai-changes
Added pictures to /public
2026-07-20 21:29:31 +02:00
Dada1981
5fdaac844b
Merge pull request #3 from Dada1981/ai-changes
AI changes regarding markdown lists
2026-07-20 15:01:07 +02:00
Dada1981
4265487e22
Merge pull request #2 from Dada1981/ai-changes
Selektiv die AI-Änderungen übernommen
2026-07-20 14:33:04 +02:00
Dada1981
ab7a1d2c83
Merge pull request #1 from Dada1981/ai-changes
AI Studio Änderungen integriert
2026-07-20 12:50:03 +02:00
30 changed files with 9924 additions and 2645 deletions

View file

@ -3,6 +3,19 @@
# Users configure this via the Secrets panel in the AI Studio UI.
GEMINI_API_KEY="MY_GEMINI_API_KEY"
# Required in production (at least 12 characters).
ADMIN_PASSWORD="CHANGE_ME_TO_A_LONG_RANDOM_PASSWORD"
# Required in production (at least 32 random characters). This is separate
# from the password and signs the short-lived admin session cookie.
SESSION_SECRET="CHANGE_ME_TO_AN_INDEPENDENT_LONG_RANDOM_SECRET"
# Set to 1 when exactly one trusted reverse proxy sits in front of Express.
# TRUST_PROXY="1"
# Optional location for database.json, uploads and migration backups.
# DATA_DIR="./data"
# APP_URL: The URL where this applet is hosted.
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
# Used for self-referential links, OAuth callbacks, and API endpoints.

34
.forgejo/workflows/ci.yml Normal file
View file

@ -0,0 +1,34 @@
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 package*.json ./
# Install all dependencies
RUN npm install
# Install exactly the dependency versions recorded in package-lock.json
RUN npm ci
# Copy full application codebase
COPY . .
@ -23,8 +23,8 @@ WORKDIR /app
# Copy configuration files
COPY package*.json ./
# Install only production dependencies (Express, dotenv, @google/genai)
RUN npm install --omit=dev
# Install exactly the locked production dependencies
RUN npm ci --omit=dev
# Copy compiled build resources from builder stage
COPY --from=builder /app/dist ./dist
@ -33,7 +33,6 @@ COPY --from=builder /app/dist ./dist
EXPOSE 3000
ENV NODE_ENV=production
ENV ADMIN_PASSWORD=autor2026
# Execute standalone node production entrypoint
CMD ["node", "dist/server.cjs"]

285
README.md
View file

@ -1,92 +1,247 @@
# 📚 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, zwei völlig 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 mit bis zu drei CTA-Buttons
- Bücherregal mit Detailansicht, Cover, Kauflink und optionaler Spotify-Playlist
- Buchreihen mit Serienname und automatisch sortierter Bandnummer
- getrennte KDP-Links für E-Book und Taschenbuch
- optionaler, lokal gehosteter PDF-Leseproben-Download pro Buch
- optionaler Kurzgeschichten- und Download-Bereich mit PDF und ePUB
- Aktuelle Projekte mit Fortschritt, Markdown-Detailtext, Bild und optionaler Spotify-Playlist
- Lokaler Bild-Upload über den Adminbereich
- Verwaltung von Impressum, Datenschutzerklärung und weiteren Rechtstexten
- Optionale serverseitige Gemini-Unterstützung für Klappentexte
- Profilabhängige SEO-Metadaten, Open Graph, strukturierte Daten, Sitemap und `robots.txt`
- Health-Endpunkte, persistente JSON-Datenhaltung und automatische Migrationsbackups
- optionale Kontaktsektion je Profil für E-Mail, Instagram, Threads und Discord
- adressierbare und teilbare Buch-/Projektansichten
- neutrale, nicht indexierbare 404-Seite ohne Hinweise auf andere Profile
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, robusten 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 Ihren beiden Identitäten herstellen können:
- **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 (M. S. Velvet — Sinnliche Literatur):** Erreichbar **ausschließlich** über den Pfad `/sensual-moments`. Sie verwendet ein warmes, edles "Champagner-Gold & Samt-Bordeaux"-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.
- Querverlinkungen entstehen nur durch ausdrücklich gepflegte Buttons oder Inhalte des jeweiligen Profils; es gibt keine automatische Verbindung zu anderen Profilen.
---
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
Öffnen Sie die `docker-compose.yml` auf Ihrem Server und passen Sie folgende Umgebungsvariablen an:
```yaml
environment:
- NODE_ENV=production
# Das Passwort für Ihren Administrationsbereich (/admin):
- ADMIN_PASSWORD=IhrSicheresLieblingsPasswort123
# (Optional) Für den 21. Jahrhundert Schreibassistenten (Gemini 3.5 Flash)
- 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/downloads/ Lokal hochgeladene PDF-Leseproben
data/backups/ Automatische Migrationsbackups
tests/ Integrations- und Sicherheitstests
```
### 3. Container starten
Führen Sie im entsprechenden Verzeichnis folgenden Befehl aus:
## 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
```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 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.
## 💾 Manuelle Installation ohne Docker (Alternativ)
## Installation ohne Docker
Sollten Sie die Software direkt auf Ihrem Server (ohne Docker) starten wollen:
Voraussetzung ist eine unterstützte Node.js-Version (empfohlen: Node.js 20 oder neuer).
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 GEMINI_API_KEY="Ihr_Gemini_API_Schlüssel"
```
5. **Starten:**
```bash
npm start
```
```bash
npm install
npm run build
NODE_ENV=production \
ADMIN_PASSWORD="EinLangesZufaelligesAdminPasswort" \
SESSION_SECRET="EineUnabhaengigeZufaelligeZeichenfolgeMitMindestens32Zeichen" \
npm start
```
---
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.
## 🧱 Datensicherung / Backups
Für die lokale Entwicklung genügt:
Alle Ihre wertvollen Inhalte (Biografien, Projektfortschritte und alle Bücherregal-Einträge) liegen verschlüsselt strukturiert in Ihrem Projektordner unter:
`./data/database.json`
```bash
npm install
npm run dev
```
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.
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
- Abgelaufene Sitzungen führen im Adminbereich kontrolliert zurück zur Anmeldung
- 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
- 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.
## 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/downloads/
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.
- 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.
## Optionale Buttons im Zusatzabschnitt
Im zusätzlichen Textabschnitt eines Profils können bis zu drei CTA-Buttons gepflegt werden. Jeder Button benötigt eine Beschriftung und eine vollständige `http://`- oder `https://`-Adresse. Unvollständige Einträge werden nicht angezeigt, andere URL-Schemata werden serverseitig abgelehnt.
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-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.
- 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.
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,
- Revisionskonflikte und serverseitige Inhaltsvalidierung,
- gültige und manipulierte Bild-Uploads,
- Löschung unbenutzter sowie Schutz referenzierter Uploads,
- validierte PDF-/ePUB-Uploads und Schutz referenzierter Download-Dateien,
- 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.

View file

@ -13,7 +13,9 @@ services:
- ./data:/app/data
environment:
- NODE_ENV=production
# Ändern Sie das Passwort für Ihren Administrationsbereich:
- ADMIN_PASSWORD=IhrSicheresPasswort2026
- TRUST_PROXY=${TRUST_PROXY:-}
# Werte werden aus der nicht eingecheckten .env-Datei gelesen.
- ADMIN_PASSWORD=${ADMIN_PASSWORD:?ADMIN_PASSWORD muss in .env gesetzt sein}
- SESSION_SECRET=${SESSION_SECRET:?SESSION_SECRET muss in .env gesetzt sein}
# (Optional) Für den 21. Jahrhundert KI-Klappentextassistenten (Gemini 3.5 Flash)
- GEMINI_API_KEY=Ihr_Gemini_API_Schluessel
- GEMINI_API_KEY=${GEMINI_API_KEY:-}

View file

@ -1,13 +1,13 @@
<!doctype html>
<html lang="en">
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>My Google AI Studio App</title>
<!-- SEO_META -->
<title>Autoren-Portfolio</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

5607
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -8,7 +8,10 @@
"build": "vite build && esbuild server.ts --bundle --platform=node --format=cjs --packages=external --sourcemap --outfile=dist/server.cjs",
"start": "node dist/server.cjs",
"clean": "rm -rf dist",
"lint": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"lint": "npm run typecheck",
"test": "npm run build && node --test tests/*.test.mjs",
"check": "npm run typecheck && npm test"
},
"dependencies": {
"@google/genai": "^2.4.0",

1046
server.ts

File diff suppressed because it is too large Load diff

View file

@ -1,189 +1,117 @@
import { useState, useEffect } from "react";
import { AuthorData } from "./types";
import SciFiPortfolio from "./components/SciFiPortfolio";
import EroticaPortfolio from "./components/EroticaPortfolio";
import ClaraPortfolio from "./components/ClaraPortfolio";
import ReneePortfolio from "./components/ReneePortfolio";
import AdminPanel from "./components/AdminPanel";
import { Lock, Eye, Sparkles, RefreshCw, Leaf } from "lucide-react";
import { lazy, Suspense, useEffect, useState } from "react";
import { Lock, RefreshCw } from "lucide-react";
import type { PublicAuthorData } from "./types";
export default function App() {
const [authorData, setAuthorData] = useState<AuthorData | null>(null);
const [loading, setLoading] = useState(true);
const [currentPath, setCurrentPath] = useState(window.location.pathname);
const PortfolioPage = lazy(() => import("./components/PortfolioPage"));
const AdminPanel = lazy(() => import("./components/AdminPanel"));
// Monitor location changes for custom routing
useEffect(() => {
const handleLocationChange = () => {
setCurrentPath(window.location.pathname);
};
// Listen to popstate for navigation back/forward support
window.addEventListener("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);
})
.catch((err) => {
console.error("Fehler beim Abrufen der Autoren-Informationen:", err);
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"];
// 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.includes(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;
}
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("/");
};
if (loading) {
function LoadingScreen() {
return (
<div className="min-h-screen bg-slate-950 text-slate-100 flex flex-col items-center justify-center space-y-4">
<RefreshCw className="w-8 h-8 animate-spin text-cyan-400" />
<p className="font-mono text-xs text-slate-500 uppercase tracking-widest">Kryptografisches Terminal bootet...</p>
<p className="font-mono text-xs text-slate-500 uppercase tracking-widest">Portfolio wird geladen...</p>
</div>
);
}
export default function App() {
const [publicData, setPublicData] = useState<PublicAuthorData | null>(null);
const [loading, setLoading] = useState(true);
const [currentPath, setCurrentPath] = useState(window.location.pathname);
const isAdmin = currentPath === "/admin" || currentPath.startsWith("/admin/");
useEffect(() => {
const handleLocationChange = () => setCurrentPath(window.location.pathname);
window.addEventListener("popstate", handleLocationChange);
return () => window.removeEventListener("popstate", handleLocationChange);
}, []);
useEffect(() => {
if (isAdmin) {
setLoading(false);
return;
}
if (!authorData) {
return (
<div className="min-h-screen bg-slate-950 text-slate-100 flex flex-col items-center justify-center p-4">
<div className="max-w-md p-6 bg-slate-900 border border-slate-800 rounded-xl text-center space-y-3">
<h2 className="text-lg font-bold text-rose-450 text-rose-400">Verbindungsfehler</h2>
<p className="text-xs text-slate-400 leading-relaxed">
Die Verbindung zum lokalen Datenspeicher-Server konnte nicht hergestellt werden. Bitte vergewissern Sie sich, dass Ihr Express API-Server ordnungsgemäß läuft.
</p>
</div>
</div>
);
}
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<PublicAuthorData>;
})
.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));
// Purely route-based render isolation structure
// 1. Administration panel
if (currentPath === "/admin" || currentPath.startsWith("/admin/")) {
return <AdminPanel onLogout={handleAdminLogout} />;
}
return () => controller.abort();
}, [isAdmin]);
// Render active portfolio
const renderActivePortfolio = () => {
const activeSuite = getActiveSuite();
if (activeSuite === "erotica") {
return <EroticaPortfolio data={authorData.erotica} />;
}
if (activeSuite === "clara") {
return <ClaraPortfolio data={authorData.clara} />;
}
if (activeSuite === "renee") {
return <ReneePortfolio data={authorData.renee} />;
}
return <SciFiPortfolio data={authorData.scifi} />;
useEffect(() => {
if (isAdmin) document.title = "Administration";
else if (publicData) document.title = publicData.profile.seoTitle || publicData.profile.name;
else document.title = "Portfolio";
}, [isAdmin, publicData]);
const navigateTo = (path: string) => {
window.history.pushState({}, "", path);
setCurrentPath(path);
};
if (isAdmin) {
return (
<>
{renderActivePortfolio()}
<Suspense fallback={<LoadingScreen />}>
<AdminPanel onLogout={() => navigateTo("/")} />
</Suspense>
);
}
{/* Absolute floating administration bypass link hidden on the bottom left (hover to show) */}
if (loading) return <LoadingScreen />;
if (!publicData) {
return (
<div className="min-h-screen bg-slate-950 text-slate-100 flex items-center justify-center p-4">
<div className="max-w-md p-6 bg-slate-900 border border-slate-800 rounded-xl text-center space-y-3">
<h2 className="text-lg font-bold text-rose-400">Verbindungsfehler</h2>
<p className="text-xs text-slate-400 leading-relaxed">Das Portfolio konnte nicht geladen werden.</p>
</div>
</div>
);
}
const isLocalPreview = ["localhost", "127.0.0.1"].includes(window.location.hostname);
if (currentPath !== "/" && !isLocalPreview) {
return (
<div className="min-h-screen bg-slate-950 text-slate-100 flex items-center justify-center p-4">
<div className="max-w-md rounded-2xl border border-slate-800 bg-slate-900 p-8 text-center space-y-4">
<p className="text-xs font-mono uppercase tracking-widest text-slate-500">404</p>
<h1 className="text-2xl font-bold">Seite nicht gefunden</h1>
<p className="text-sm text-slate-400">Die angeforderte Seite ist nicht verfügbar.</p>
<a href="/" className="inline-flex rounded-xl bg-slate-100 px-4 py-2 text-sm font-semibold text-slate-950">Zur Startseite</a>
</div>
</div>
);
}
return (
<Suspense fallback={<LoadingScreen />}>
<PortfolioPage data={publicData.profile} legalDocuments={publicData.legalDocuments} theme={publicData.theme} />
<div className="fixed bottom-3 left-3 opacity-0 hover:opacity-100 focus-within:opacity-100 transition-opacity duration-300 z-40 bg-neutral-950/90 border border-neutral-800 p-2 rounded-xl flex items-center text-xs font-mono shadow-xl backdrop-blur-sm">
<a
href="/admin"
onClick={(e) => {
e.preventDefault();
window.history.pushState({}, "", "/admin");
setCurrentPath("/admin");
onClick={(event) => {
event.preventDefault();
navigateTo("/admin");
}}
className="text-stone-500 hover:text-cyan-400 font-bold flex items-center gap-1.5 transition-colors px-1"
>
<Lock className="w-3 h-3" />
<span>Admin-Zentrale</span>
<span>Administration</span>
</a>
</div>
</>
</Suspense>
);
}

49
src/api/adminApi.ts Normal file
View file

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

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

File diff suppressed because it is too large Load diff

View file

@ -1,464 +0,0 @@
import { useState } from "react";
import Markdown from "react-markdown";
import { Book, Project, AuthorProfile } from "../types";
import { Leaf, Feather, Star, Sparkles, BookOpen, ExternalLink, ArrowRight } from "lucide-react";
import SpotlightSection from "./SpotlightSection";
interface ClaraPortfolioProps {
data: AuthorProfile;
}
export default function ClaraPortfolio({ data }: ClaraPortfolioProps) {
const [selectedBook, setSelectedBook] = useState<Book | null>(null);
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
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 (
<div
className={`min-h-screen text-stone-200 ${fontClass} selection:bg-[#B39EAF]/30 selection:text-white overflow-x-hidden relative`}
style={{ backgroundColor: bgColor }}
>
{/* Hero Banner Background if present */}
{data.heroBannerUrl && (
<div className="absolute top-0 inset-x-0 h-96 overflow-hidden pointer-events-none -z-5 opacity-20">
<img src={data.heroBannerUrl} alt="" className="w-full h-full object-cover blur-sm" />
<div className="absolute inset-0 bg-gradient-to-b from-transparent to-stone-950" style={{ backgroundImage: `linear-gradient(to bottom, transparent, ${bgColor})` }} />
</div>
)}
{/* Delicate organic gradients */}
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-stone-900/10 via-transparent to-transparent -z-10 pointer-events-none" />
<div className="absolute top-1/4 left-1/4 w-96 h-96 rounded-full blur-3xl pointer-events-none opacity-15" style={{ backgroundColor: accentColor }} />
<div className="absolute bottom-1/3 right-1/3 w-96 h-96 rounded-full blur-3xl pointer-events-none opacity-15" style={{ backgroundColor: secondaryColor }} />
{/* Header / Hero */}
<header className="relative py-24 px-4 max-w-7xl mx-auto border-b border-stone-800/80">
<div className="text-center max-w-4xl mx-auto space-y-6">
<div
className="inline-flex items-center gap-2 px-4 py-1.5 bg-stone-900/80 border rounded-full text-xs tracking-wider mb-2 shadow-sm"
style={{ borderColor: `${accentColor}40`, color: accentColor }}
>
<Leaf className="w-3.5 h-3.5 animate-pulse" style={{ color: accentColor }} />
<span className="font-serif italic text-stone-300">{data.badgeText || "Lyrik & Naturphilosophie"}</span>
</div>
<h1 className="text-4xl md:text-6xl font-serif tracking-widest text-stone-100 uppercase font-light drop-shadow-sm">
{data.heroTitle || data.name || "CLARA FINCH"}
</h1>
<p className="font-sans text-xs md:text-sm tracking-[0.4em] uppercase" style={{ color: secondaryColor }}>
{data.heroSubtitle || "LYRIK, ESSAYS & NATURPHILOSOPHIE"}
</p>
<div className="h-[1px] w-36 mx-auto mt-6" style={{ background: `linear-gradient(to right, transparent, ${accentColor}, ${secondaryColor}, transparent)` }} />
{/* Quick Section Navigation */}
<nav className="mt-8 flex flex-wrap justify-center gap-3 text-xs font-sans">
<a
href="#ueber-mich"
className="px-4 py-2 rounded-full border border-stone-800 bg-stone-900/60 hover:border-stone-500 transition-all text-stone-300 uppercase tracking-wider"
>
🌿 {data.aboutTitle || "Über mich"}
</a>
<a
href="#projekte"
className="px-4 py-2 rounded-full border border-stone-800 bg-stone-900/60 hover:border-stone-500 transition-all text-stone-300 uppercase tracking-wider"
>
✍️ {data.projectsTitle || "Notizbücher"}
</a>
<a
href="#buecherregal"
className="px-4 py-2 rounded-full border border-stone-800 bg-stone-900/60 hover:border-stone-500 transition-all text-stone-300 uppercase tracking-wider"
>
📖 {data.booksTitle || "Gedruckte Werke"}
</a>
</nav>
</div>
</header>
{/* Main Grid */}
<main className="max-w-7xl mx-auto px-4 py-16 space-y-24">
{/* About writer */}
<section id="ueber-mich" className="grid grid-cols-1 md:grid-cols-12 gap-12 items-center">
<div className="md:col-span-4 flex justify-center">
<div className="relative group">
{/* Outer soft natural glow */}
<div className="absolute -inset-1.5 rounded-2xl blur opacity-20 group-hover:opacity-40 transition duration-1000 group-hover:duration-200" style={{ background: `linear-gradient(to right, ${accentColor}, ${secondaryColor})` }} />
<div className="relative w-64 h-64 md:w-80 md:h-80 rounded-2xl overflow-hidden border border-stone-800" style={{ backgroundColor: cardBgColor }}>
<img
src={data.avatarUrl || "https://images.unsplash.com/photo-1544005313-94ddf0286df2?auto=format&fit=crop&q=80&w=400"}
alt={data.name}
className="w-full h-full object-cover grayscale brightness-95 group-hover:scale-105 group-hover:grayscale-0 transition-all duration-1000"
referrerPolicy="no-referrer"
/>
</div>
</div>
</div>
<div className="md:col-span-8 space-y-6">
<h2 className="text-2xl md:text-3xl font-serif text-stone-100 flex items-center gap-3">
<Feather className="w-6 h-6" style={{ color: accentColor }} />
<span>{data.aboutTitle || "Das Atmen zwischen den Zeilen"}</span>
</h2>
<div className="prose prose-invert prose-stone max-w-none text-stone-300 leading-relaxed font-sans space-y-4">
<p className="whitespace-pre-wrap italic">{data.bio}</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 pt-4 text-xs">
{(data.bioTag1Label || data.bioTag1Value) && (
<div className="p-3 border rounded-xl flex items-center gap-3" style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}20` }}>
<Leaf className="w-5 h-5 shrink-0" style={{ color: accentColor }} />
<div>
<div className="text-stone-500 uppercase tracking-wider text-[10px]">{data.bioTag1Label || "Genre"}</div>
<div className="text-stone-200 font-semibold">{data.bioTag1Value || "Lyrik & Essays"}</div>
</div>
</div>
)}
{(data.bioTag2Label || data.bioTag2Value) && (
<div className="p-3 border rounded-xl flex items-center gap-3" style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}20` }}>
<Sparkles className="w-5 h-5 shrink-0" style={{ color: secondaryColor }} />
<div>
<div className="text-stone-500 uppercase tracking-wider text-[10px]">{data.bioTag2Label || "Ästhetik"}</div>
<div className="text-stone-200 font-semibold">{data.bioTag2Value || "Natur & Poesie"}</div>
</div>
</div>
)}
{(data.bioTag3Label || data.bioTag3Value) && (
<div className="p-3 border rounded-xl flex items-center gap-3 col-span-2 md:col-span-1" style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}20` }}>
<Feather className="w-5 h-5 shrink-0" style={{ color: accentColor }} />
<div>
<div className="text-stone-500 uppercase tracking-wider text-[10px]">{data.bioTag3Label || "Fokus"}</div>
<div className="text-stone-200 font-semibold">{data.bioTag3Value || "Naturkontemplation"}</div>
</div>
</div>
)}
</div>
</div>
</section>
{/* Section 1.5: Spotlight / Neuerscheinung */}
{data.books?.find(b => b.isSpotlight) && (
<SpotlightSection
book={data.books.find(b => b.isSpotlight)!}
title={data.spotlightTitle}
subtitle={data.spotlightSubtitle}
accentColor={accentColor}
secondaryColor={secondaryColor}
cardBgColor={cardBgColor}
/>
)}
{/* Dynamic ongoing work writing progress */}
<section id="projekte" className="space-y-8">
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4 border-b border-stone-800 pb-4">
<div>
<h2 className="text-2xl md:text-3xl font-serif text-stone-100 flex items-center gap-3">
<Feather className="w-6 h-6" style={{ color: accentColor }} />
<span>{data.projectsTitle || "Aus den Notizbüchern"}</span>
</h2>
<p className="text-stone-400 text-sm mt-1">{data.projectsSubtitle || "Gedanken im Entwurf, Fragmente und Sammlungen, die bald das Licht der Welt erblicken."}</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{data.projects.map((project) => (
<button
key={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-md"
style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}20` }}
>
<div className="space-y-4 w-full">
<div className="flex items-center justify-between">
<span className="text-[10px] uppercase font-sans px-2.5 py-1 rounded border tracking-wider" style={{ color: accentColor, backgroundColor: `${accentColor}15`, borderColor: `${accentColor}30` }}>
{project.status || "In Arbeit"}
</span>
<span className="text-xs text-stone-400 italic">
Reifegrad: {project.progress}%
</span>
</div>
<h3 className="text-lg font-serif text-stone-100 group-hover:text-stone-300 transition-colors">
{project.title}
</h3>
<div className="text-stone-300 text-sm italic line-clamp-3 markdown-body [&_p]:inline [&_p]:m-0">
<Markdown
components={{
a: ({ node, ...props }) => (
<a
target="_blank"
rel="noopener noreferrer"
className="underline text-[#8FA797] hover:text-stone-200 font-medium"
onClick={(e) => e.stopPropagation()}
{...props}
/>
)
}}
>
{project.description}
</Markdown>
</div>
<p className="font-sans text-[11px] mt-2 group-hover:translate-x-1 transition-transform flex items-center gap-1 italic" style={{ color: accentColor }}>
Details einsehen <ArrowRight className="w-3 h-3" />
</p>
</div>
<div className="mt-6 space-y-1.5 w-full">
<div className="h-1 w-full bg-stone-900 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-1000 ease-out shadow-sm"
style={{ width: `${project.progress}%`, background: `linear-gradient(to right, ${accentColor}, ${secondaryColor})` }}
/>
</div>
</div>
</button>
))}
</div>
</section>
{/* Bookshelf display */}
<section id="buecherregal" className="space-y-8">
<div className="border-b border-stone-800 pb-4">
<h2 className="text-2xl md:text-3xl font-serif text-stone-100 flex items-center gap-3">
<BookOpen className="w-6 h-6" style={{ color: accentColor }} />
<span>{data.booksTitle || "Gedruckte Werke"}</span>
</h2>
<p className="text-stone-400 text-sm mt-1">{data.booksSubtitle || "Erschlossene Wortlandschaften. Wählen Sie einen Band, um tiefer hineinzuhören."}</p>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-6 justify-items-center">
{data.books.map((book) => (
<button
key={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 transition-all duration-300 hover:shadow-xl cursor-pointer"
style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}30` }}
>
<div className="absolute inset-0 bg-stone-900/10 opacity-0 group-hover:opacity-100 transition-opacity z-10 pointer-events-none" />
<img
src={book.coverUrl || "https://images.unsplash.com/photo-1501504905252-473c47e087f8?auto=format&fit=crop&q=80&w=300"}
alt={book.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700 z-0"
referrerPolicy="no-referrer"
/>
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-stone-950 via-stone-950/90 to-transparent p-4 opacity-80 group-hover:opacity-100 transition-all duration-350 z-20">
<p className="text-stone-100 text-xs font-serif truncate">{book.title}</p>
<p className="text-[10px] font-sans mt-0.5 italic" style={{ color: accentColor }}>Verweilen <ArrowRight className="inline-block w-2.5 h-2.5 ml-0.5" /></p>
</div>
</button>
))}
</div>
</section>
{/* Custom Highlight Section if present */}
{(data.customSectionTitle || data.customSectionContent) && (
<section id="garten-notiz" className="p-8 rounded-2xl border relative overflow-hidden max-w-4xl mx-auto shadow-xl" style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}30` }}>
{data.customSectionTitle && (
<h2 className="text-2xl font-serif text-stone-100 mb-4 flex items-center gap-2">
<Sparkles className="w-5 h-5" style={{ color: accentColor }} />
<span>{data.customSectionTitle}</span>
</h2>
)}
{data.customSectionContent && (
<div className="text-stone-300 text-sm md:text-base leading-relaxed italic markdown-body [&_p]:mb-4 [&_p:last-child]:mb-0">
<Markdown
components={{
a: ({ node, ...props }) => (
<a
target="_blank"
rel="noopener noreferrer"
className="underline text-[#8FA797] hover:text-stone-200 font-medium"
{...props}
/>
)
}}
>
{data.customSectionContent}
</Markdown>
</div>
)}
</section>
)}
</main>
{/* Book details modular modal */}
{selectedBook && (
<div className="fixed inset-0 z-50 overflow-y-auto" role="dialog" aria-modal="true">
<div
className="fixed inset-0 bg-stone-950/85 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-stone-950 border border-stone-900 text-left shadow-2xl transition-all sm:my-8 sm:w-full sm:max-w-4xl p-6 md:p-8 space-y-6">
<button
onClick={() => setSelectedBook(null)}
className="absolute top-4 right-4 text-stone-400 hover:text-stone-100 hover:bg-[#4A5D50]/80 px-2.5 py-1 rounded-md text-xs font-sans border border-stone-800 transition-all cursor-pointer"
>
SCHLIEẞEN
</button>
<div className="grid grid-cols-1 md:grid-cols-12 gap-8 pt-4">
{/* Book Cover */}
<div className="col-span-1 md:col-span-4 flex flex-col items-center">
<div className="rounded-xl overflow-hidden shadow-2xl border border-[#4A5D50]/30 max-w-[260px] aspect-[2/3] w-full">
<img
src={selectedBook.coverUrl}
alt={selectedBook.title}
className="w-full h-full object-cover"
referrerPolicy="no-referrer"
/>
</div>
{selectedBook.buyLink && (
<a
href={selectedBook.buyLink}
target="_blank"
rel="noopener noreferrer"
className="mt-6 w-full max-w-[260px] py-3 text-center bg-gradient-to-r from-[#8FA797] to-[#B59EB1] hover:from-[#9fbca9] hover:to-[#cbb5c7] text-stone-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(143,167,151,0.2)] text-sm"
>
<span>Werk erwerben</span>
<ExternalLink className="w-4 h-4" />
</a>
)}
</div>
{/* Details text area */}
<div className="col-span-1 md:col-span-8 flex flex-col justify-between space-y-6">
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<span className="text-[10px] font-sans text-[#8FA797] bg-[#4A5D50]/20 px-2.5 py-0.5 rounded border border-[#8FA797]/30">
Erschienen: {selectedBook.publishedYear}
</span>
{selectedBook.genres?.map((genre) => (
<span key={genre} className="text-[10px] font-sans text-[#B59EB1] bg-[#B59EB1]/10 px-2.5 py-0.5 rounded border border-[#B59EB1]/20">
{genre}
</span>
))}
</div>
<h3 className="text-3xl font-serif text-stone-100">
{selectedBook.title}
</h3>
{selectedBook.subtitle && (
<h4 className="text-sm font-sans italic text-[#B59EB1]">
{selectedBook.subtitle}
</h4>
)}
<div className="h-[1px] bg-[#4A5D50]/30" />
<div className="text-stone-300 text-sm leading-relaxed italic markdown-body [&_p]:mb-4 [&_p:last-child]:mb-0">
<Markdown>{selectedBook.description}</Markdown>
</div>
</div>
{/* Embedded Spotify player (Only if playlist is non-empty) */}
{selectedBook.spotifyPlaylistId && selectedBook.spotifyPlaylistId.trim() !== "" && (
<div className="space-y-2">
<p className="text-xs font-sans text-[#8FA797] flex items-center gap-2 italic">
<Leaf className="w-3.5 h-3.5 text-[#8FA797]" />
<span>Klanglandschaft (Begleitende Playlist)</span>
</p>
<div className="rounded-xl overflow-hidden bg-stone-900 border border-stone-800">
<iframe
src={`https://open.spotify.com/embed/playlist/${selectedBook.spotifyPlaylistId}?utm_source=generator&theme=0`}
width="100%"
height="152"
frameBorder="0"
allowFullScreen={false}
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
loading="lazy"
/>
</div>
</div>
)}
</div>
</div>
</div>
</div>
</div>
)}
{/* Project details modular modal */}
{selectedProject && (
<div className="fixed inset-0 z-50 overflow-y-auto" role="dialog" aria-modal="true">
<div
className="fixed inset-0 bg-stone-950/85 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-stone-950 border border-stone-900 text-left shadow-2xl transition-all sm:my-8 sm:w-full sm:max-w-2xl p-6 md:p-8 space-y-6">
<button
onClick={() => setSelectedProject(null)}
className="absolute top-4 right-4 text-stone-400 hover:text-stone-100 hover:bg-[#4A5D50]/80 px-2.5 py-1 rounded-md text-xs font-sans border border-stone-800 transition-all cursor-pointer"
>
SCHLIEẞEN
</button>
<div className="space-y-6 pt-4">
<div className="flex flex-wrap items-center gap-2">
<span className="text-[10px] font-sans text-[#8FA797] bg-[#4A5D50]/20 px-2.5 py-0.5 rounded border border-[#8FA797]/30">
Status: {selectedProject.status || "In Arbeit"}
</span>
<span className="text-[10px] font-sans text-[#B59EB1] bg-[#B59EB1]/10 px-2.5 py-0.5 rounded border border-[#B59EB1]/20">
Notizbucheintrag
</span>
</div>
<h3 className="text-3xl font-serif text-stone-100">
{selectedProject.title}
</h3>
<div className="h-[1px] bg-[#4A5D50]/30" />
<div className="space-y-2">
<div className="flex justify-between items-center text-xs font-sans text-stone-400">
<span>ENTWURF-FORTSCHRITT:</span>
<span className="text-[#8FA797] font-bold">{selectedProject.progress}%</span>
</div>
<div className="h-2 w-full bg-stone-900 rounded-full overflow-hidden border border-stone-800">
<div
className="h-full bg-gradient-to-r from-[#4A5D50] via-[#8FA797] to-[#B59EB1] rounded-full transition-all duration-1000 ease-out shadow-[0_0_8px_rgba(143,167,151,0.25)]"
style={{ width: `${selectedProject.progress}%` }}
/>
</div>
</div>
<div className="bg-[#4A5D50]/5 p-5 rounded-xl border border-[#4A5D50]/20 relative overflow-hidden">
<div className="absolute top-0 right-0 p-2 text-stone-700 text-[10px] font-mono select-none">
FRAGMENT_LOG_CLARA
</div>
<h4 className="text-xs font-sans text-[#8FA797] uppercase tracking-wider mb-3 italic">Konzeptuelle Notizen & Auszüge</h4>
<div className="text-stone-200 text-sm leading-relaxed italic markdown-body [&_p]:mb-4 [&_p:last-child]:mb-0">
<Markdown>{selectedProject.description || "Für dieses Buchprojekt wurden noch keine lyrischen Gedanken freigegeben."}</Markdown>
</div>
</div>
</div>
</div>
</div>
</div>
)}
{/* Footer */}
<footer className="border-t border-stone-800 py-12 px-4 text-center text-xs text-stone-500 italic" style={{ backgroundColor: bgColor }}>
<div className="max-w-7xl mx-auto space-y-2">
<p>© {new Date().getFullYear()} {data.name}. Alle Rechte vorbehalten.</p>
<p className="text-stone-500">{data.footerText || "Betrachtungen, verwoben mit dem Rhythmus der wachsenden Natur."}</p>
</div>
</footer>
</div>
);
}

View file

@ -1,464 +0,0 @@
import { useState } from "react";
import Markdown from "react-markdown";
import { Book, Project, AuthorProfile } from "../types";
import { Heart, Key, Star, Sparkles, BookOpen, ExternalLink, ArrowRight } from "lucide-react";
import SpotlightSection from "./SpotlightSection";
interface EroticaPortfolioProps {
data: AuthorProfile;
}
export default function EroticaPortfolio({ data }: EroticaPortfolioProps) {
const [selectedBook, setSelectedBook] = useState<Book | null>(null);
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
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 (
<div
className={`min-h-screen text-neutral-200 ${fontClass} selection:bg-orange-500 selection:text-black overflow-x-hidden relative`}
style={{ backgroundColor: bgColor }}
>
{/* Hero Banner Background if present */}
{data.heroBannerUrl && (
<div className="absolute top-0 inset-x-0 h-96 overflow-hidden pointer-events-none -z-5 opacity-20">
<img src={data.heroBannerUrl} alt="" className="w-full h-full object-cover blur-sm" />
<div className="absolute inset-0 bg-gradient-to-b from-transparent to-neutral-950" style={{ backgroundImage: `linear-gradient(to bottom, transparent, ${bgColor})` }} />
</div>
)}
{/* Decorative Warm background gradient and peach glow vibes */}
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-amber-950/20 via-transparent to-transparent -z-10 pointer-events-none" />
<div className="absolute top-1/4 left-1/3 w-96 h-96 rounded-full blur-3xl pointer-events-none opacity-20" style={{ backgroundColor: accentColor }} />
<div className="absolute bottom-1/3 right-1/4 w-96 h-96 rounded-full blur-3xl pointer-events-none opacity-20" style={{ backgroundColor: secondaryColor }} />
{/* Header / Hero */}
<header className="relative py-24 px-4 max-w-7xl mx-auto border-b border-neutral-800/80">
<div className="text-center max-w-4xl mx-auto space-y-6">
<div
className="inline-flex items-center gap-2 px-4 py-1.5 bg-neutral-900/80 border rounded-full text-xs tracking-wider mb-2 shadow-sm"
style={{ borderColor: `${accentColor}40`, color: accentColor }}
>
<Heart className="w-3.5 h-3.5 fill-current animate-pulse" style={{ color: accentColor }} />
<span>{data.badgeText || "Stilvolle Sinnlichkeit & Romantik"}</span>
</div>
<h1 className="text-4xl md:text-6xl tracking-wide text-amber-100 uppercase font-light drop-shadow-sm font-serif">
{data.heroTitle || data.name || "M. S. VELVET"}
</h1>
<p className="font-sans text-xs md:text-sm tracking-[0.3em] uppercase" style={{ color: accentColor }}>
{data.heroSubtitle || "ELEGANT-SINNLICHE LITERATUR"}
</p>
<div className="h-[1px] w-36 mx-auto mt-6" style={{ background: `linear-gradient(to right, transparent, ${accentColor}, transparent)` }} />
{/* Quick Section Navigation */}
<nav className="mt-8 flex flex-wrap justify-center gap-3 text-xs font-sans">
<a
href="#ueber-mich"
className="px-4 py-2 rounded-full border border-neutral-800 bg-neutral-900/60 hover:border-orange-400 hover:text-orange-300 transition-all text-neutral-300 uppercase tracking-wider"
>
🍑 {data.aboutTitle || "Über mich"}
</a>
<a
href="#projekte"
className="px-4 py-2 rounded-full border border-neutral-800 bg-neutral-900/60 hover:border-orange-400 hover:text-orange-300 transition-all text-neutral-300 uppercase tracking-wider"
>
✍️ {data.projectsTitle || "In der Entstehung"}
</a>
<a
href="#buecherregal"
className="px-4 py-2 rounded-full border border-neutral-800 bg-neutral-900/60 hover:border-orange-400 hover:text-orange-300 transition-all text-neutral-300 uppercase tracking-wider"
>
📖 {data.booksTitle || "Das Samtregal"}
</a>
</nav>
</div>
</header>
{/* Main Grid */}
<main className="max-w-7xl mx-auto px-4 py-16 space-y-24">
{/* About writer */}
<section id="ueber-mich" className="grid grid-cols-1 md:grid-cols-12 gap-12 items-center">
<div className="md:col-span-4 flex justify-center">
<div className="relative group">
{/* Outer warm glow */}
<div className="absolute -inset-1.5 rounded-2xl blur opacity-25 group-hover:opacity-60 transition duration-1000 group-hover:duration-200" style={{ background: `linear-gradient(to right, ${accentColor}, ${secondaryColor})` }} />
<div className="relative w-64 h-64 md:w-80 md:h-80 rounded-2xl overflow-hidden border border-neutral-800" style={{ backgroundColor: cardBgColor }}>
<img
src={data.avatarUrl || "https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?auto=format&fit=crop&q=80&w=400"}
alt={data.name}
className="w-full h-full object-cover grayscale brightness-90 group-hover:scale-105 group-hover:grayscale-0 transition-all duration-1000"
referrerPolicy="no-referrer"
/>
</div>
</div>
</div>
<div className="md:col-span-8 space-y-6">
<h2 className="text-2xl md:text-3xl font-serif text-amber-100 flex items-center gap-3">
<Sparkles className="w-6 h-6" style={{ color: accentColor }} />
<span>{data.aboutTitle || "Das Geheimnis hinter der Feder"}</span>
</h2>
<div className="prose prose-invert prose-stone max-w-none text-neutral-300 leading-relaxed font-sans space-y-4">
<p className="whitespace-pre-wrap italic">{data.bio}</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 pt-4 text-xs">
{(data.bioTag1Label || data.bioTag1Value) && (
<div className="p-3 border rounded-xl flex items-center gap-3" style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}20` }}>
<Heart className="w-5 h-5 fill-current shrink-0" style={{ color: accentColor }} />
<div>
<div className="text-neutral-500 uppercase tracking-wider text-[10px]">{data.bioTag1Label || "Genre"}</div>
<div className="text-neutral-200 font-semibold">{data.bioTag1Value || "Sinnliche Romance"}</div>
</div>
</div>
)}
{(data.bioTag2Label || data.bioTag2Value) && (
<div className="p-3 border rounded-xl flex items-center gap-3" style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}20` }}>
<Sparkles className="w-5 h-5 shrink-0" style={{ color: secondaryColor }} />
<div>
<div className="text-neutral-500 uppercase tracking-wider text-[10px]">{data.bioTag2Label || "Tonalität"}</div>
<div className="text-neutral-200 font-semibold">{data.bioTag2Value || "Elegant & Knisternd"}</div>
</div>
</div>
)}
{(data.bioTag3Label || data.bioTag3Value) && (
<div className="p-3 border rounded-xl flex items-center gap-3 col-span-2 md:col-span-1" style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}20` }}>
<Key className="w-5 h-5 shrink-0" style={{ color: accentColor }} />
<div>
<div className="text-neutral-500 uppercase tracking-wider text-[10px]">{data.bioTag3Label || "Identität"}</div>
<div className="text-neutral-200 font-semibold">{data.bioTag3Value || "Diskret / Pseudonym"}</div>
</div>
</div>
)}
</div>
</div>
</section>
{/* Section 1.5: Spotlight / Neuerscheinung */}
{data.books?.find(b => b.isSpotlight) && (
<SpotlightSection
book={data.books.find(b => b.isSpotlight)!}
title={data.spotlightTitle}
subtitle={data.spotlightSubtitle}
accentColor={accentColor}
secondaryColor={secondaryColor}
cardBgColor={cardBgColor}
/>
)}
{/* Dynamic ongoing work writing progress */}
<section id="projekte" className="space-y-8">
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4 border-b border-neutral-800 pb-4">
<div>
<h2 className="text-2xl md:text-3xl font-serif text-amber-100 flex items-center gap-3">
<Heart className="w-6 h-6" style={{ color: accentColor }} />
<span>{data.projectsTitle || "In der Entstehung..."}</span>
</h2>
<p className="text-neutral-400 text-sm mt-1">{data.projectsSubtitle || "Ein diskreter Blick hinter die Samtvorhänge meiner aktuellen Manuskripte."}</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{data.projects.map((project) => (
<button
key={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"
style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}25` }}
>
<div className="space-y-4 w-full">
<div className="flex items-center justify-between">
<span className="text-[10px] uppercase font-sans px-2.5 py-1 rounded border tracking-wider" style={{ color: accentColor, backgroundColor: `${accentColor}15`, borderColor: `${accentColor}30` }}>
{project.status || "In Arbeit"}
</span>
<span className="text-xs text-neutral-400 italic">
Verlangen erfüllt: {project.progress}%
</span>
</div>
<h3 className="text-lg font-serif text-amber-100 group-hover:text-orange-400 transition-colors">
{project.title}
</h3>
<div className="text-neutral-300 text-sm italic line-clamp-3 markdown-body [&_p]:inline [&_p]:m-0">
<Markdown
components={{
a: ({ node, ...props }) => (
<a
target="_blank"
rel="noopener noreferrer"
className="underline text-orange-400 hover:text-amber-300 font-medium"
onClick={(e) => e.stopPropagation()}
{...props}
/>
)
}}
>
{project.description}
</Markdown>
</div>
<p className="font-sans text-[11px] mt-2 group-hover:translate-x-1 transition-transform flex items-center gap-1 italic" style={{ color: accentColor }}>
Details einsehen <ArrowRight className="w-3 h-3" />
</p>
</div>
<div className="mt-6 space-y-1.5 w-full">
<div className="h-1 w-full bg-neutral-900 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-1000 ease-out shadow-sm"
style={{ width: `${project.progress}%`, background: `linear-gradient(to right, ${accentColor}, ${secondaryColor})` }}
/>
</div>
</div>
</button>
))}
</div>
</section>
{/* Bookshelf display */}
<section id="buecherregal" className="space-y-8">
<div className="border-b border-neutral-800 pb-4">
<h2 className="text-2xl md:text-3xl font-serif text-amber-100 flex items-center gap-3">
<BookOpen className="w-6 h-6" style={{ color: secondaryColor }} />
<span>{data.booksTitle || "Das Samtregal"}</span>
</h2>
<p className="text-neutral-400 text-sm mt-1">{data.booksSubtitle || "Wählen Sie ein Buch aus, um tiefer in die Versuchung einzutauchen."}</p>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-6 justify-items-center">
{data.books.map((book) => (
<button
key={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 transition-all duration-300 hover:shadow-xl cursor-pointer"
style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}30` }}
>
<div className="absolute inset-0 bg-orange-950/10 opacity-0 group-hover:opacity-100 transition-opacity z-10 pointer-events-none" />
<img
src={book.coverUrl || "https://images.unsplash.com/photo-1517841905240-472988babdf9?auto=format&fit=crop&q=80&w=300"}
alt={book.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700 z-0"
referrerPolicy="no-referrer"
/>
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-neutral-950 via-neutral-950/90 to-transparent p-4 opacity-80 group-hover:opacity-100 transition-all duration-350 z-20">
<p className="text-amber-100 text-xs font-serif truncate">{book.title}</p>
<p className="text-[10px] font-sans mt-0.5 italic" style={{ color: accentColor }}>Eintauchen <ArrowRight className="inline-block w-2.5 h-2.5 ml-0.5" /></p>
</div>
</button>
))}
</div>
</section>
{/* Custom Highlight Section if present */}
{(data.customSectionTitle || data.customSectionContent) && (
<section id="exklusiv-notiz" className="p-8 rounded-2xl border relative overflow-hidden max-w-4xl mx-auto shadow-xl" style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}30` }}>
{data.customSectionTitle && (
<h2 className="text-2xl font-serif text-amber-100 mb-4 flex items-center gap-2">
<Sparkles className="w-5 h-5" style={{ color: accentColor }} />
<span>{data.customSectionTitle}</span>
</h2>
)}
{data.customSectionContent && (
<div className="text-neutral-300 text-sm md:text-base leading-relaxed italic markdown-body [&_p]:mb-4 [&_p:last-child]:mb-0">
<Markdown
components={{
a: ({ node, ...props }) => (
<a
target="_blank"
rel="noopener noreferrer"
className="underline text-orange-400 hover:text-amber-300 font-medium"
{...props}
/>
)
}}
>
{data.customSectionContent}
</Markdown>
</div>
)}
</section>
)}
</main>
{/* Book details modular modal */}
{selectedBook && (
<div className="fixed inset-0 z-50 overflow-y-auto" role="dialog" aria-modal="true">
<div
className="fixed inset-0 bg-neutral-950/85 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-neutral-950 border border-neutral-900 text-left shadow-2xl transition-all sm:my-8 sm:w-full sm:max-w-4xl p-6 md:p-8 space-y-6">
<button
onClick={() => setSelectedBook(null)}
className="absolute top-4 right-4 text-neutral-400 hover:text-amber-100 hover:bg-orange-950/80 px-2.5 py-1 rounded-md text-xs font-sans border border-neutral-800 transition-all cursor-pointer"
>
SCHLIEẞEN
</button>
<div className="grid grid-cols-1 md:grid-cols-12 gap-8 pt-4">
{/* Book Cover */}
<div className="col-span-1 md:col-span-4 flex flex-col items-center">
<div className="rounded-xl overflow-hidden shadow-2xl border border-orange-950/50 max-w-[260px] aspect-[2/3] w-full">
<img
src={selectedBook.coverUrl}
alt={selectedBook.title}
className="w-full h-full object-cover"
referrerPolicy="no-referrer"
/>
</div>
{selectedBook.buyLink && (
<a
href={selectedBook.buyLink}
target="_blank"
rel="noopener noreferrer"
className="mt-6 w-full max-w-[260px] py-3 text-center bg-gradient-to-r from-orange-400 to-amber-500 hover:from-orange-300 hover:to-amber-400 text-neutral-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(249,115,22,0.25)] text-sm"
>
<span>Buch erwerben</span>
<ExternalLink className="w-4 h-4" />
</a>
)}
</div>
{/* Details text area */}
<div className="col-span-1 md:col-span-8 flex flex-col justify-between space-y-6">
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<span className="text-[10px] font-sans text-orange-300 bg-orange-950/30 px-2.5 py-0.5 rounded border border-orange-900/30">
Sinnlich seit: {selectedBook.publishedYear}
</span>
{selectedBook.genres?.map((genre) => (
<span key={genre} className="text-[10px] font-sans text-amber-300 bg-amber-950/30 px-2.5 py-0.5 rounded border border-amber-900/30">
{genre}
</span>
))}
</div>
<h3 className="text-3xl font-serif text-amber-100">
{selectedBook.title}
</h3>
{selectedBook.subtitle && (
<h4 className="text-sm font-sans italic text-orange-400">
{selectedBook.subtitle}
</h4>
)}
<div className="h-px bg-orange-950/40" />
<div className="text-neutral-300 text-sm leading-relaxed italic markdown-body [&_p]:mb-4 [&_p:last-child]:mb-0">
<Markdown>{selectedBook.description}</Markdown>
</div>
</div>
{/* Embedded Spotify player */}
{selectedBook.spotifyPlaylistId && selectedBook.spotifyPlaylistId.trim() !== "" && (
<div className="space-y-2">
<p className="text-xs font-sans text-orange-400 flex items-center gap-2 italic">
<Heart className="w-3.5 h-3.5 fill-orange-400 text-orange-400" />
<span>Sinnliche Begleitmusik (Spotify Playlist)</span>
</p>
<div className="rounded-xl overflow-hidden bg-neutral-900 border border-neutral-900">
<iframe
src={`https://open.spotify.com/embed/playlist/${selectedBook.spotifyPlaylistId}?utm_source=generator&theme=0`}
width="100%"
height="152"
frameBorder="0"
allowFullScreen={false}
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
loading="lazy"
/>
</div>
</div>
)}
</div>
</div>
</div>
</div>
</div>
)}
{/* Project details modular modal */}
{selectedProject && (
<div className="fixed inset-0 z-50 overflow-y-auto" role="dialog" aria-modal="true">
<div
className="fixed inset-0 bg-neutral-950/85 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-neutral-950 border border-neutral-900 text-left shadow-2xl transition-all sm:my-8 sm:w-full sm:max-w-2xl p-6 md:p-8 space-y-6">
<button
onClick={() => setSelectedProject(null)}
className="absolute top-4 right-4 text-neutral-400 hover:text-amber-100 hover:bg-orange-950/80 px-2.5 py-1 rounded-md text-xs font-sans border border-neutral-800 transition-all cursor-pointer"
>
SCHLIEẞEN
</button>
<div className="space-y-6 pt-4">
<div className="flex flex-wrap items-center gap-2">
<span className="text-[10px] font-sans text-orange-300 bg-orange-950/30 px-2.5 py-0.5 rounded border border-orange-900/30">
Status: {selectedProject.status || "In Arbeit"}
</span>
<span className="text-[10px] font-sans text-amber-300 bg-amber-950/30 px-2.5 py-0.5 rounded border border-amber-900/30">
Intime Einblicke
</span>
</div>
<h3 className="text-3xl font-serif text-amber-100">
{selectedProject.title}
</h3>
<div className="h-px bg-orange-950/40" />
<div className="space-y-2">
<div className="flex justify-between items-center text-xs font-sans text-neutral-450 text-neutral-400">
<span>REIFEGRAD DES WERKS:</span>
<span className="text-orange-400 font-bold">{selectedProject.progress}%</span>
</div>
<div className="h-2 w-full bg-neutral-900 rounded-full overflow-hidden border border-neutral-800">
<div
className="h-full bg-gradient-to-r from-orange-500 via-amber-450 via-amber-400 to-yellow-300 rounded-full transition-all duration-1000 ease-out shadow-[0_0_8px_rgba(249,115,22,0.35)]"
style={{ width: `${selectedProject.progress}%` }}
/>
</div>
</div>
<div className="bg-orange-950/10 p-5 rounded-xl border border-orange-950/20 relative overflow-hidden">
<div className="absolute top-0 right-0 p-2 text-neutral-800 text-[10px] font-mono select-none">
FRAGMENT_REPORT_09
</div>
<h4 className="text-xs font-sans text-orange-400 uppercase tracking-wider mb-3 italic">Konzeptuelle Skizzen & Atmosphärische Auszüge</h4>
<div className="text-neutral-200 text-sm leading-relaxed italic markdown-body [&_p]:mb-4 [&_p:last-child]:mb-0">
<Markdown>{selectedProject.description || "Für dieses diskrete Werk wurde noch kein detaillierter Auszug freigegeben."}</Markdown>
</div>
</div>
</div>
</div>
</div>
</div>
)}
{/* Footer */}
<footer className="border-t border-neutral-800 py-12 px-4 text-center text-xs text-neutral-500 italic" style={{ backgroundColor: bgColor }}>
<div className="max-w-7xl mx-auto space-y-2">
<p>© {new Date().getFullYear()} {data.name}. Alle Rechte vorbehalten.</p>
<p className="text-neutral-500">{data.footerText || "Geschlossenes Refugium für feine, hochästhetische Phantasiereisen."}</p>
</div>
</footer>
</div>
);
}

View file

@ -1,10 +1,10 @@
import React, { useState, useEffect, useRef } from "react";
import { Upload, Image as ImageIcon, Link as LinkIcon, X, Check, Loader2, FolderOpen, AlertCircle } from "lucide-react";
import { Upload, Image as ImageIcon, Link as LinkIcon, X, Check, Loader2, FolderOpen, AlertCircle, Trash2 } from "lucide-react";
import { adminApi, AdminApiError } from "../api/adminApi";
interface ImagePickerProps {
value: string;
onChange: (url: string) => void;
token: string;
label: string;
}
@ -13,7 +13,7 @@ interface ServerFile {
url: string;
}
export default function ImagePicker({ value, onChange, token, label }: ImagePickerProps) {
export default function ImagePicker({ value, onChange, label }: ImagePickerProps) {
const [isOpen, setIsOpen] = useState(false);
const [activeTab, setActiveTab] = useState<"upload" | "server" | "url">("upload");
const [manualUrl, setManualUrl] = useState(value || "");
@ -40,22 +40,10 @@ export default function ImagePicker({ value, onChange, token, label }: ImagePick
setLoadingFiles(true);
setError("");
try {
const response = await fetch("/api/admin/list-uploads", {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw new Error("Fehler beim Laden der Serverdateien");
}
const data = await response.json();
if (data.success) {
const data = await adminApi.listUploads();
setServerFiles(data.files || []);
} else {
setError(data.error || "Laden der Dateien fehlgeschlagen.");
}
} catch (err: any) {
setError("Verbindung zum Server fehlgeschlagen.");
} catch (err) {
setError(err instanceof AdminApiError ? err.message : "Verbindung zum Server fehlgeschlagen.");
console.error(err);
} finally {
setLoadingFiles(false);
@ -64,8 +52,13 @@ export default function ImagePicker({ value, onChange, token, label }: ImagePick
const handleFileUpload = async (file: File) => {
if (!file) return;
if (!file.type.startsWith("image/")) {
setError("Bitte wählen Sie nur Bilddateien (PNG, JPG, WebP, etc.) aus.");
const allowedTypes = new Set(["image/jpeg", "image/png", "image/webp", "image/gif", "image/avif"]);
if (!allowedTypes.has(file.type)) {
setError("Erlaubt sind ausschließlich JPEG-, PNG-, WebP-, GIF- oder AVIF-Bilder.");
return;
}
if (file.size > 8 * 1024 * 1024) {
setError("Das Bild darf maximal 8 MB groß sein.");
return;
}
@ -78,31 +71,11 @@ export default function ImagePicker({ value, onChange, token, label }: ImagePick
reader.onloadend = async () => {
const base64Data = reader.result as string;
try {
const response = await fetch("/api/admin/upload-file", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
fileName: file.name,
base64Data: base64Data,
}),
});
if (!response.ok) {
throw new Error("Serverfehler beim Upload.");
}
const data = await response.json();
if (data.success) {
const data = await adminApi.upload(file.name, base64Data);
onChange(data.url);
setIsOpen(false);
} else {
setError(data.error || "Upload fehlgeschlagen.");
}
} catch (err: any) {
setError("Upload-Fehler: " + err.message);
} catch (err) {
setError(err instanceof AdminApiError ? err.message : "Upload fehlgeschlagen.");
} finally {
setUploading(false);
}
@ -156,6 +129,18 @@ export default function ImagePicker({ value, onChange, token, label }: ImagePick
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 (
<div className="space-y-2">
<div className="flex items-center gap-3">
@ -288,7 +273,7 @@ export default function ImagePicker({ value, onChange, token, label }: ImagePick
<input
ref={fileInputRef}
type="file"
accept="image/*"
accept="image/jpeg,image/png,image/webp,image/gif,image/avif"
onChange={handleFileChange}
className="hidden"
/>
@ -304,7 +289,7 @@ export default function ImagePicker({ value, onChange, token, label }: ImagePick
<Upload className="w-6 h-6" />
</div>
<p className="text-xs font-bold text-white">Bilddatei hierher ziehen oder anklicken</p>
<p className="text-[11px] text-slate-550 text-slate-500">Unterstützt JPG, PNG, GIF, WebP (max. 10MB)</p>
<p className="text-[11px] text-slate-550 text-slate-500">JPG, PNG, GIF, WebP oder AVIF (max. 8 MB)</p>
</div>
)}
</div>
@ -330,22 +315,22 @@ export default function ImagePicker({ value, onChange, token, label }: ImagePick
{serverFiles.map((file) => {
const isSelected = value === file.url;
return (
<button
<div
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 ${
isSelected
? "border-indigo-500 ring-2 ring-indigo-500/20"
: "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
src={file.url}
alt={file.name}
className="w-full h-full object-cover transition-transform group-hover:scale-105"
referrerPolicy="no-referrer"
/>
</button>
{/* 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">
<p className="text-[9px] text-slate-200 truncate font-mono">{file.name}</p>
@ -356,7 +341,16 @@ export default function ImagePicker({ value, onChange, token, label }: ImagePick
<Check className="w-3 h-3" />
</div>
)}
<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>

View file

@ -0,0 +1,199 @@
import React, { useState, useEffect } from "react";
import Markdown from "react-markdown";
import { LegalDocument } from "../types";
import { X, ShieldCheck, FileText, Scale, Printer } from "lucide-react";
interface LegalModalProps {
isOpen: boolean;
onClose: () => void;
documents: LegalDocument[];
initialDocId?: string | null;
accentColor?: string;
secondaryColor?: string;
fontFamily?: "sans" | "serif" | "mono";
}
export default function LegalModal({
isOpen,
onClose,
documents,
initialDocId,
accentColor = "#38bdf8",
secondaryColor = "#818cf8",
fontFamily = "sans"
}: LegalModalProps) {
const [selectedDocId, setSelectedDocId] = useState<string>("");
useEffect(() => {
if (initialDocId && documents.some(d => d.id === initialDocId)) {
setSelectedDocId(initialDocId);
} else if (documents.length > 0) {
setSelectedDocId(documents[0].id);
}
}, [initialDocId, documents, isOpen]);
// Handle ESC key to close
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && isOpen) {
onClose();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen || documents.length === 0) return null;
const currentDoc = documents.find(d => d.id === selectedDocId) || documents[0];
const handlePrint = () => {
window.print();
};
const getDocIcon = (type: string) => {
switch (type) {
case "impressum":
return <Scale className="w-4 h-4" />;
case "privacy":
return <ShieldCheck className="w-4 h-4" />;
default:
return <FileText className="w-4 h-4" />;
}
};
return (
<div className="fixed inset-0 z-50 overflow-y-auto" role="dialog" aria-modal="true">
{/* Backdrop */}
<div
className="fixed inset-0 bg-slate-950/85 backdrop-blur-md transition-opacity"
onClick={onClose}
/>
<div className="flex min-h-full items-center justify-center p-3 sm:p-4 text-left">
<div className={`relative transform overflow-hidden rounded-2xl bg-slate-900 border border-slate-800 text-left shadow-2xl transition-all w-full max-w-3xl my-6 p-6 sm:p-8 space-y-6 font-${fontFamily}`}>
{/* Header Bar */}
<div className="flex items-center justify-between border-b border-slate-800 pb-4">
<div className="flex items-center gap-2 text-white">
<span style={{ color: accentColor }}>
{getDocIcon(currentDoc?.type || "custom")}
</span>
<h2 className="text-lg sm:text-xl font-bold tracking-tight">
{currentDoc?.title || "Rechtliche Hinweise"}
</h2>
</div>
<div className="flex items-center gap-2">
<button
onClick={handlePrint}
title="Dokument drucken"
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 border border-slate-800 transition-colors cursor-pointer"
>
<Printer className="w-4 h-4" />
</button>
<button
onClick={onClose}
className="px-2.5 py-1 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg text-xs font-mono border border-slate-800 transition-colors cursor-pointer flex items-center gap-1"
>
<X className="w-3.5 h-3.5" />
<span>[ESC]</span>
</button>
</div>
</div>
{/* Document Tabs Switcher if more than 1 document */}
{documents.length > 1 && (
<div className="flex flex-wrap gap-1.5 p-1 bg-slate-950/80 rounded-xl border border-slate-800">
{documents.map((doc) => {
const isActive = doc.id === currentDoc.id;
return (
<button
key={doc.id}
onClick={() => setSelectedDocId(doc.id)}
className={`px-3.5 py-1.5 rounded-lg text-xs font-medium transition-all flex items-center gap-2 cursor-pointer ${
isActive
? "bg-slate-800 text-white shadow-sm font-semibold"
: "text-slate-400 hover:text-slate-200 hover:bg-slate-900"
}`}
style={isActive ? { borderLeft: `3px solid ${accentColor}` } : {}}
>
{getDocIcon(doc.type)}
<span>{doc.title}</span>
</button>
);
})}
</div>
)}
{/* Document Content View */}
<div className="bg-slate-950/50 rounded-xl p-5 sm:p-7 border border-slate-800/80 max-h-[60vh] overflow-y-auto leading-relaxed text-slate-300 text-sm markdown-body space-y-4">
{currentDoc?.content ? (
<Markdown
components={{
h1: ({ node, ...props }) => (
<h1 className="text-xl sm:text-2xl font-bold text-white mb-4 pb-2 border-b border-slate-800" {...props} />
),
h2: ({ node, ...props }) => (
<h2 className="text-lg sm:text-xl font-bold text-white mt-6 mb-3" {...props} />
),
h3: ({ node, ...props }) => (
<h3 className="text-base font-semibold text-slate-100 mt-5 mb-2" {...props} />
),
p: ({ node, ...props }) => (
<p className="mb-3 text-slate-300 text-sm leading-relaxed" {...props} />
),
ul: ({ node, ...props }) => (
<ul className="list-disc pl-5 mb-4 space-y-1.5 text-slate-300 text-sm" {...props} />
),
ol: ({ node, ...props }) => (
<ol className="list-decimal pl-5 mb-4 space-y-1.5 text-slate-300 text-sm" {...props} />
),
li: ({ node, ...props }) => (
<li className="leading-relaxed" {...props} />
),
a: ({ node, ...props }) => (
<a
target="_blank"
rel="noopener noreferrer"
className="underline font-medium hover:opacity-80 transition-opacity"
style={{ color: accentColor }}
{...props}
/>
),
strong: ({ node, ...props }) => (
<strong className="font-semibold text-white" {...props} />
),
blockquote: ({ node, ...props }) => (
<blockquote className="border-l-4 pl-4 py-1 italic text-slate-400 border-slate-700 bg-slate-900/40 rounded-r my-3" {...props} />
)
}}
>
{currentDoc.content}
</Markdown>
) : (
<p className="text-slate-500 italic">Kein Inhalt hinterlegt.</p>
)}
</div>
{/* Footer inside modal */}
<div className="flex items-center justify-between text-xs text-slate-500 font-mono pt-2 border-t border-slate-800/80">
{currentDoc?.updatedAt ? (
<span>Stand: {currentDoc.updatedAt}</span>
) : (
<span>Rechtliche Information</span>
)}
<button
onClick={onClose}
className="px-4 py-1.5 bg-slate-800 hover:bg-slate-700 text-white rounded-lg text-xs font-semibold transition-all cursor-pointer"
>
Schließen
</button>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,669 @@
import { useEffect, useState } from "react";
import Markdown from "react-markdown";
import { Book, Project, AuthorProfile, LegalDocument, PortfolioTheme } from "../types";
import { BookOpen, Star, HelpCircle, Award, Compass, ArrowRight, ExternalLink, Moon, Mail, Instagram, AtSign, Share2, FileText, MessageCircle, Download, ChevronDown, ChevronUp } from "lucide-react";
import SpotlightSection from "./SpotlightSection";
import LegalModal from "./LegalModal";
import ProjectDetailMedia from "./ProjectDetailMedia";
import SpotifyConsentPlayer from "./SpotifyConsentPlayer";
import AccessibleModal from "./AccessibleModal";
interface PortfolioPageProps {
data: AuthorProfile;
legalDocuments?: LegalDocument[];
theme: PortfolioTheme;
}
function safeExternalUrl(value: string): string | null {
try {
const url = new URL(value);
return url.protocol === "https:" || url.protocol === "http:" ? url.toString() : null;
} catch {
return null;
}
}
const themeDefaults: Record<PortfolioTheme, { accent: string; secondary: string; background: string; card: string; badge: string; subtitle: string }> = {
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<Book | null>(null);
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
const [legalModalOpen, setLegalModalOpen] = useState(false);
const [activeLegalId, setActiveLegalId] = useState<string | null>(null);
const [shareNotice, setShareNotice] = useState("");
const [showAllDownloads, setShowAllDownloads] = useState(false);
const relevantLegalDocs = legalDocuments;
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";
const customSectionLinks = (data.customSectionLinks || []).slice(0, 3).flatMap((link) => {
const url = safeExternalUrl(link.url);
return link.label.trim() && url ? [{ label: link.label.trim(), url }] : [];
});
const displayedBooks = data.books.map((book, index) => ({ book, index })).sort((left, right) => {
if (!left.book.seriesName && !right.book.seriesName) return left.index - right.index;
if (!left.book.seriesName) return 1;
if (!right.book.seriesName) return -1;
const seriesComparison = left.book.seriesName.localeCompare(right.book.seriesName, "de");
return seriesComparison || Number(left.book.seriesNumber || 0) - Number(right.book.seriesNumber || 0) || left.index - right.index;
}).map(({ book }) => book);
const 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 (
<div
className={`min-h-screen text-slate-100 ${fontClass} selection:bg-cyan-500 selection:text-white overflow-x-hidden relative`}
style={{ backgroundColor: bgColor }}
>
{/* Hero Banner Background if present */}
{data.heroBannerUrl && (
<div className="absolute top-0 inset-x-0 h-96 overflow-hidden pointer-events-none -z-5 opacity-25">
<img src={data.heroBannerUrl} alt="" className="w-full h-full object-cover blur-sm" />
<div className="absolute inset-0 bg-gradient-to-b from-transparent to-slate-950" style={{ backgroundImage: `linear-gradient(to bottom, transparent, ${bgColor})` }} />
</div>
)}
{/* Decorative starry background with magical aura */}
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-indigo-950/40 via-transparent to-transparent -z-10 pointer-events-none" />
<div
className="absolute top-1/4 left-1/4 w-96 h-96 rounded-full blur-3xl pointer-events-none opacity-20"
style={{ backgroundColor: accentColor }}
/>
<div
className="absolute top-2/3 right-1/4 w-96 h-96 rounded-full blur-3xl pointer-events-none opacity-20"
style={{ backgroundColor: secondaryColor }}
/>
{/* Hero Section */}
<header className="relative py-24 px-4 max-w-7xl mx-auto border-b border-slate-800/80">
<div className="text-center max-w-4xl mx-auto">
{/* Subtle magical/futuristic accent */}
<div
className="inline-flex items-center gap-2 px-3.5 py-1 bg-slate-900/80 border rounded-full text-xs font-mono mb-6 tracking-widest uppercase shadow-sm"
style={{ borderColor: `${accentColor}40`, color: accentColor }}
>
<Star className="w-3.5 h-3.5 animate-pulse" style={{ color: accentColor }} />
<span>{data.badgeText || defaults.badge}</span>
</div>
<h1 className="text-5xl md:text-7xl tracking-tight text-white mb-4 uppercase drop-shadow-md font-bold">
{data.heroTitle || data.name}
</h1>
<p className="font-mono text-xs md:text-sm tracking-widest uppercase mb-8" style={{ color: accentColor }}>
{data.heroSubtitle || defaults.subtitle}
</p>
<div
className="h-0.5 w-24 mx-auto rounded-full"
style={{ background: `linear-gradient(to right, ${accentColor}, ${secondaryColor})` }}
/>
{/* Quick Section Navigation */}
<nav className="mt-8 flex flex-wrap justify-center gap-3 text-xs font-mono">
<a
href="#ueber-mich"
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.aboutTitle || "Über mich"}
</a>
<a
href="#projekte"
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.projectsTitle || "Aktuelle Projekte"}
</a>
<a
href="#buecherregal"
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.booksTitle || "Bücherregal"}
</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>
</div>
</header>
{/* Main Container */}
<main className="max-w-7xl mx-auto px-4 py-16 space-y-24">
{/* Section 1: Über mich */}
<section id="ueber-mich" className="grid grid-cols-1 md:grid-cols-12 gap-12 items-center">
<div className="md:col-span-4 flex justify-center">
<div className="relative group">
{/* Outer neon ring */}
<div
className="absolute -inset-1.5 rounded-2xl blur opacity-30 group-hover:opacity-70 transition duration-1000 group-hover:duration-200"
style={{ background: `linear-gradient(to right, ${accentColor}, ${secondaryColor})` }}
/>
<div className="relative w-64 h-64 md:w-80 md:h-80 rounded-2xl overflow-hidden border border-slate-800" style={{ backgroundColor: cardBgColor }}>
<img
src={data.avatarUrl || undefined}
alt={data.name}
className="w-full h-full object-cover grayscale brightness-90 contrast-110 group-hover:scale-105 group-hover:grayscale-0 transition-all duration-700"
referrerPolicy="no-referrer"
/>
</div>
</div>
</div>
<div className="md:col-span-8 space-y-6">
<h2 className="text-3xl font-bold tracking-tight text-white flex items-center gap-3">
<Compass className="w-7 h-7" style={{ color: accentColor }} />
<span>{data.aboutTitle || "Über den Autor"}</span>
</h2>
<div className="prose prose-invert max-w-none text-slate-300 leading-relaxed space-y-4">
<p className="whitespace-pre-wrap">{data.bio}</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 pt-4 font-mono text-xs">
{(data.bioTag1Label || data.bioTag1Value) && (
<div className="p-3 border rounded-lg flex items-center gap-3" style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}20` }}>
<BookOpen className="w-5 h-5 shrink-0" style={{ color: secondaryColor }} />
<div>
<div className="text-slate-500 uppercase tracking-widest">{data.bioTag1Label || "Fokus"}</div>
<div className="text-white font-semibold">{data.bioTag1Value || "Literatur"}</div>
</div>
</div>
)}
{(data.bioTag2Label || data.bioTag2Value) && (
<div className="p-3 border rounded-lg flex items-center gap-3" style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}20` }}>
<Star className="w-5 h-5 shrink-0" style={{ color: accentColor }} />
<div>
<div className="text-slate-500 uppercase tracking-widest">{data.bioTag2Label || "Welten"}</div>
<div className="text-white font-semibold">{data.bioTag2Value || "Geschichten"}</div>
</div>
</div>
)}
{(data.bioTag3Label || data.bioTag3Value) && (
<div className="p-3 border rounded-lg flex items-center gap-3 col-span-2 md:col-span-1" style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}20` }}>
<Award className="w-5 h-5 shrink-0" style={{ color: secondaryColor }} />
<div>
<div className="text-slate-500 uppercase tracking-widest">{data.bioTag3Label || "Stil"}</div>
<div className="text-white font-semibold">{data.bioTag3Value || "Atmosphärisch"}</div>
</div>
</div>
)}
</div>
</div>
</section>
{/* Section 1.5: Spotlight / Neuerscheinung */}
{data.books?.find(b => b.isSpotlight) && (
<SpotlightSection
book={data.books.find(b => b.isSpotlight)!}
title={data.spotlightTitle}
subtitle={data.spotlightSubtitle}
accentColor={accentColor}
secondaryColor={secondaryColor}
cardBgColor={cardBgColor}
/>
)}
{/* Section 2: Aktuelle Projekte */}
<section id="projekte" className="space-y-8">
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4 border-b border-slate-800 pb-4">
<div>
<h2 className="text-3xl font-bold tracking-tight text-white flex items-center gap-3">
<Star className="w-7 h-7" style={{ color: accentColor }} />
<span>{data.projectsTitle || "Aktuelle Projekte"}</span>
</h2>
<p className="text-slate-400 text-sm mt-1">{data.projectsSubtitle || "Ein exklusiver Blick auf das, was sich gerade auf meinem Schreib-Amboss befindet."}</p>
</div>
<div className="text-xs font-mono px-3 py-1 rounded border" style={{ color: accentColor, backgroundColor: `${accentColor}10`, borderColor: `${accentColor}30` }}>
Aktuell in Arbeit
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{data.projects.map((project) => (
<button
key={project.id}
onClick={() => openDetail("project", project.id)}
className="p-6 border rounded-xl transition-all duration-300 flex flex-col text-left w-full justify-between group cursor-pointer hover:scale-[1.01] focus:outline-none shadow-lg"
style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}25` }}
>
<div className="space-y-4 w-full">
<div className="flex items-center justify-between">
<span className="text-xs font-mono px-2.5 py-1 rounded border uppercase tracking-wider" style={{ color: accentColor, backgroundColor: `${accentColor}15`, borderColor: `${accentColor}30` }}>
{project.status || "In Arbeit"}
</span>
<span className="text-xs text-slate-400 font-mono">
Progression: {project.progress}%
</span>
</div>
<h3 className="text-xl font-bold text-white group-hover:text-cyan-300 transition-colors">
{project.title}
</h3>
<div className="text-slate-300 text-sm leading-relaxed line-clamp-3 markdown-body [&_p]:inline [&_p]:m-0">
<Markdown
components={{
a: ({ node, ...props }) => (
<a
target="_blank"
rel="noopener noreferrer"
className="underline text-cyan-400 hover:text-cyan-300 font-medium"
onClick={(e) => e.stopPropagation()}
{...props}
/>
)
}}
>
{project.description}
</Markdown>
</div>
<p className="font-mono text-[11px] mt-2 group-hover:translate-x-1 transition-transform flex items-center gap-1" style={{ color: accentColor }}>
Details anzeigen <ArrowRight className="w-3 h-3" />
</p>
</div>
{/* Custom glowing progress bar */}
<div className="mt-6 space-y-1.5 w-full">
<div className="h-1.5 w-full bg-slate-950/80 rounded-full overflow-hidden border border-slate-800">
<div
className="h-full rounded-full transition-all duration-1000 ease-out shadow-sm"
style={{ width: `${project.progress}%`, background: `linear-gradient(to right, ${accentColor}, ${secondaryColor})` }}
/>
</div>
</div>
</button>
))}
</div>
</section>
{/* Section 3: Bücherregal */}
<section id="buecherregal" className="space-y-8">
<div className="border-b border-slate-800 pb-4">
<h2 className="text-3xl font-bold tracking-tight text-white flex items-center gap-3">
<BookOpen className="w-7 h-7" style={{ color: secondaryColor }} />
<span>{data.booksTitle || "Das Bücherregal"}</span>
</h2>
<p className="text-slate-400 text-sm mt-1">{data.booksSubtitle || "Veröffentlichte Meilensteine. Klicken oder Tippen Sie auf ein Cover, um Geheimnisse zu enthüllen."}</p>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-6 justify-items-center">
{displayedBooks.map((book) => (
<button
key={book.id}
onClick={() => openDetail("book", book.id)}
className="group relative focus:outline-none text-left w-full max-w-[210px] aspect-[2/3] rounded-xl overflow-hidden border hover:border-cyan-400 transition-all duration-300 hover:shadow-xl cursor-pointer"
style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}30` }}
>
<div className="absolute inset-0 bg-cyan-900/10 opacity-0 group-hover:opacity-100 transition-opacity z-10 pointer-events-none" />
<img
src={book.coverUrl || undefined}
alt={book.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500 z-0"
referrerPolicy="no-referrer"
/>
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-slate-950 via-slate-950/90 to-transparent p-4 opacity-80 group-hover:opacity-100 transition-all duration-350 z-20">
<p className="text-white text-xs font-bold truncate">{book.title}</p>
{book.seriesName && <p className="text-[9px] text-slate-300 truncate">{book.seriesName}{book.seriesNumber !== undefined && book.seriesNumber !== "" ? ` · Band ${book.seriesNumber}` : ""}</p>}
<p className="text-[10px] font-mono mt-0.5" style={{ color: accentColor }}>Details anzeigen <ArrowRight className="inline-block w-2.5 h-2.5 ml-0.5" /></p>
</div>
</button>
))}
</div>
</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 */}
{(data.customSectionTitle || data.customSectionContent || customSectionLinks.length > 0) && (
<section className="p-8 rounded-2xl border relative overflow-hidden" style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}30` }}>
<div className="absolute top-0 right-0 p-3 opacity-10 font-mono text-xs uppercase" style={{ color: accentColor }}>
CUSTOM_INFO_MODULE
</div>
{data.customSectionTitle && (
<h2 className="text-2xl font-bold text-white mb-4 flex items-center gap-2">
<Star className="w-5 h-5" style={{ color: accentColor }} />
<span>{data.customSectionTitle}</span>
</h2>
)}
{data.customSectionContent && (
<div className="text-slate-300 text-sm leading-relaxed markdown-body [&_p]:mb-4 [&_p:last-child]:mb-0">
<Markdown
components={{
a: ({ node, ...props }) => (
<a
target="_blank"
rel="noopener noreferrer"
className="underline text-cyan-400 hover:text-cyan-300 font-medium"
{...props}
/>
)
}}
>
{data.customSectionContent}
</Markdown>
</div>
)}
{customSectionLinks.length > 0 && (
<div className="mt-6 flex flex-col sm:flex-row sm:flex-wrap gap-3">
{customSectionLinks.map((link) => (
<a
key={`${link.label}-${link.url}`}
href={link.url}
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-all hover:-translate-y-0.5 hover:shadow-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-950"
style={{
borderColor: `${accentColor}70`,
background: `linear-gradient(135deg, ${accentColor}35, ${secondaryColor}25)`,
boxShadow: `0 8px 24px ${accentColor}15`,
}}
>
<span>{link.label}</span>
<ExternalLink className="h-4 w-4" aria-hidden="true" />
</a>
))}
</div>
)}
</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>
{shareNotice && <div role="status" aria-live="polite" className="fixed bottom-5 left-1/2 z-[70] -translate-x-1/2 rounded-xl border border-slate-700 bg-slate-900 px-4 py-2 text-sm text-white shadow-xl">{shareNotice}</div>}
{/* Book details modular interface modal */}
{selectedBook && (
<AccessibleModal titleId="book-detail-title" onClose={closeDetail}>
{/* Close Button */}
<button
onClick={closeDetail}
className="absolute top-4 right-4 text-slate-400 hover:text-white hover:bg-slate-800/80 px-2.5 py-1 rounded-md text-xs font-mono border border-slate-800 transition-all cursor-pointer"
>
SCHLIESSEN [ESC]
</button>
<div className="grid grid-cols-1 md:grid-cols-12 gap-8 pt-4">
{/* Book Cover */}
<div className="col-span-1 md:col-span-4 flex flex-col items-center">
<div className="rounded-xl overflow-hidden shadow-2xl border border-indigo-900/30 max-w-[260px] aspect-[2/3] w-full">
<img
src={selectedBook.coverUrl || undefined}
alt={selectedBook.title}
className="w-full h-full object-cover"
referrerPolicy="no-referrer"
/>
</div>
<div className="mt-6 w-full max-w-[260px] space-y-2">
{(selectedBook.ebookLink || selectedBook.buyLink) && <a href={selectedBook.ebookLink || selectedBook.buyLink} target="_blank" rel="noopener noreferrer" className="w-full py-3 text-center bg-gradient-to-r from-cyan-500 to-indigo-600 text-slate-950 font-bold rounded-xl flex items-center justify-center gap-2 text-sm"><span>E-Book</span><ExternalLink className="w-4 h-4" /></a>}
{selectedBook.paperbackLink && <a href={selectedBook.paperbackLink} target="_blank" rel="noopener noreferrer" className="w-full py-3 text-center border border-cyan-700 bg-slate-950 text-white font-bold rounded-xl flex items-center justify-center gap-2 text-sm"><span>Taschenbuch</span><ExternalLink className="w-4 h-4" /></a>}
{selectedBook.samplePdfUrl && <a href={selectedBook.samplePdfUrl} className="w-full py-3 text-center border border-slate-700 bg-slate-800 text-white font-bold rounded-xl flex items-center justify-center gap-2 text-sm"><FileText className="w-4 h-4" /><span>Leseprobe (PDF)</span></a>}
</div>
</div>
{/* Book Details */}
<div className="col-span-1 md:col-span-8 flex flex-col justify-between space-y-6">
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-mono text-cyan-400 bg-cyan-950/40 px-2.5 py-0.5 rounded border border-cyan-900/40">
Erschienen: {selectedBook.publishedYear}
</span>
{selectedBook.genres?.map((genre) => (
<span key={genre} className="text-[11px] font-mono text-purple-400 bg-purple-950/40 px-2.5 py-0.5 rounded border border-purple-900/40">
{genre}
</span>
))}
</div>
<h3 id="book-detail-title" className="text-3xl font-extrabold text-white">
{selectedBook.title}
</h3>
{selectedBook.subtitle && (
<h4 className="text-sm font-mono text-indigo-400 uppercase tracking-wider">
{selectedBook.subtitle}
</h4>
)}
{selectedBook.seriesName && (
<p className="text-sm font-semibold" style={{ color: accentColor }}>{selectedBook.seriesName}{selectedBook.seriesNumber !== undefined && selectedBook.seriesNumber !== "" ? ` · Band ${selectedBook.seriesNumber}` : ""}</p>
)}
<button type="button" onClick={() => shareDetail(selectedBook.title)} className="inline-flex w-fit items-center gap-2 rounded-lg border border-slate-700 px-3 py-2 text-xs text-slate-300 hover:bg-slate-800"><Share2 className="h-4 w-4" /><span>Teilen / Link kopieren</span></button>
<div className="h-px bg-indigo-950" />
<div className="text-slate-300 text-sm leading-relaxed markdown-body [&_p]:mb-4 [&_p:last-child]:mb-0">
<Markdown>{selectedBook.description}</Markdown>
</div>
</div>
{/* Privacy-friendly Spotify player */}
{selectedBook.spotifyPlaylistId && selectedBook.spotifyPlaylistId.trim() !== "" && (
<SpotifyConsentPlayer playlist={selectedBook.spotifyPlaylistId} title={`Spotify-Playlist zu ${selectedBook.title}`} accentColor={accentColor} />
)}
</div>
</div>
</AccessibleModal>
)}
{/* Project details modular modal */}
{selectedProject && (
<AccessibleModal titleId="project-detail-title" onClose={closeDetail} maxWidthClass="sm:max-w-5xl">
{/* Close Button */}
<button
onClick={closeDetail}
className="absolute top-4 right-4 text-slate-400 hover:text-white hover:bg-slate-800/80 px-2.5 py-1 rounded-md text-xs font-mono border border-slate-800 transition-all cursor-pointer"
>
SCHLIESSEN [ESC]
</button>
<div className="space-y-6 pt-4">
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-mono text-cyan-400 bg-cyan-950/40 px-2.5 py-0.5 rounded border border-cyan-900/40">
Status: {selectedProject.status || "In Arbeit"}
</span>
<span className="text-xs font-mono text-purple-400 bg-purple-950/40 px-2.5 py-0.5 rounded border border-purple-900/40">
Aktuelles Projekt
</span>
</div>
<h3 id="project-detail-title" className="text-3xl font-extrabold text-white">
{selectedProject.title}
</h3>
<button type="button" onClick={() => shareDetail(selectedProject.title)} className="inline-flex w-fit items-center gap-2 rounded-lg border border-slate-700 px-3 py-2 text-xs text-slate-300 hover:bg-slate-800"><Share2 className="h-4 w-4" /><span>Teilen / Link kopieren</span></button>
<div className="h-px bg-indigo-950" />
<div className="space-y-2">
<div className="flex justify-between items-center text-xs font-mono text-slate-400">
<span>{data.projectProgressLabel ? `${data.projectProgressLabel.toUpperCase()}:` : "FORTSCHRITT:"}</span>
<span className="font-bold" style={{ color: accentColor }}>{selectedProject.progress}%</span>
</div>
<div className="h-2 w-full bg-slate-950 rounded-full overflow-hidden border border-slate-800">
<div
className="h-full rounded-full transition-all duration-1000 ease-out shadow-md"
style={{ width: `${selectedProject.progress}%`, background: `linear-gradient(to right, ${accentColor}, ${secondaryColor})` }}
/>
</div>
</div>
<div className={`grid gap-6 ${(selectedProject.imageUrl || selectedProject.spotifyPlaylistId) ? "lg:grid-cols-[minmax(0,1fr)_minmax(0,1.15fr)] lg:items-start" : ""}`}>
<ProjectDetailMedia project={selectedProject} accentColor={accentColor} />
<div className="bg-slate-950/60 p-5 rounded-xl border border-slate-800/60 relative overflow-hidden">
{data.projectExcerptBadge && (
<div className="absolute top-0 right-0 p-2 text-slate-700 text-[10px] font-mono select-none">
{data.projectExcerptBadge}
</div>
)}
<h4 className="text-xs font-mono text-slate-500 uppercase tracking-widest mb-3">
{data.projectExcerptTitle || "Projekt-Exzerpt & Fortschrittsnotizen"}
</h4>
<div className="text-slate-300 text-sm leading-relaxed markdown-body [&_p]:mb-4 [&_p:last-child]:mb-0">
<Markdown>{selectedProject.description || "Für dieses Schreibprojekt wurden noch keine weiteren Details abgelegt."}</Markdown>
</div>
</div>
</div>
</div>
</AccessibleModal>
)}
{/* Styled Footer */}
<footer className="border-t border-slate-800 py-12 px-4 text-center text-xs text-slate-500 font-mono" style={{ backgroundColor: bgColor }}>
<div className="max-w-7xl mx-auto space-y-4">
<p>© {new Date().getFullYear()} {data.name}. Alle Rechte vorbehalten.</p>
<p className="text-slate-500">{data.footerText || "Geschichten, Projekte und Veröffentlichungen."}</p>
{relevantLegalDocs.length > 0 && (
<div className="flex flex-wrap items-center justify-center gap-6 pt-3 border-t border-slate-800/60 max-w-md mx-auto">
{relevantLegalDocs.map((doc) => (
<button
key={doc.id}
onClick={() => {
setActiveLegalId(doc.id);
setLegalModalOpen(true);
}}
className="text-slate-400 hover:text-cyan-400 transition-colors underline underline-offset-4 cursor-pointer"
>
{doc.title}
</button>
))}
</div>
)}
</div>
</footer>
{/* Legal Modal */}
<LegalModal
isOpen={legalModalOpen}
onClose={() => setLegalModalOpen(false)}
documents={relevantLegalDocs}
initialDocId={activeLegalId}
accentColor={accentColor}
secondaryColor={secondaryColor}
fontFamily={data.fontFamily || "sans"}
/>
</div>
);
}

View file

@ -0,0 +1,35 @@
import { Project } from "../types";
import SpotifyConsentPlayer from "./SpotifyConsentPlayer";
interface ProjectDetailMediaProps {
project: Project;
accentColor: string;
}
export default function ProjectDetailMedia({ project, accentColor }: ProjectDetailMediaProps) {
if (!project.imageUrl && !project.spotifyPlaylistId) return null;
return (
<div className="space-y-5">
{project.imageUrl && (
<div className="overflow-hidden rounded-xl border border-white/10 bg-black/20 shadow-lg">
<img
src={project.imageUrl}
alt={`Motiv zum Projekt ${project.title}`}
className="block w-full h-auto"
loading="lazy"
referrerPolicy="no-referrer"
/>
</div>
)}
{project.spotifyPlaylistId && (
<SpotifyConsentPlayer
playlist={project.spotifyPlaylistId}
title={`Spotify-Playlist zu ${project.title}`}
accentColor={accentColor}
/>
)}
</div>
);
}

View file

@ -1,452 +0,0 @@
import React, { useState } from "react";
import Markdown from "react-markdown";
import { AuthorProfile, Book, Project } from "../types";
import {
Heart, Sparkles, BookOpen, ExternalLink, X, ArrowRight, Sun, Music
} from "lucide-react";
import SpotlightSection from "./SpotlightSection";
interface ReneePortfolioProps {
data: AuthorProfile;
}
export default function ReneePortfolio({ data }: ReneePortfolioProps) {
const [selectedBook, setSelectedBook] = useState<Book | null>(null);
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
const accentColor = data.accentColor || "#eab308";
const secondaryColor = data.secondaryColor || "#ec4899";
const bgColor = data.backgroundColor || "#0f172a";
const cardBgColor = data.cardBgColor || "#1e293b";
const fontClass = data.fontFamily === "serif" ? "font-serif" : data.fontFamily === "mono" ? "font-mono" : "font-sans";
return (
<div
className={`min-h-screen text-slate-100 ${fontClass} selection:bg-pink-500 selection:text-white overflow-x-hidden relative`}
style={{ backgroundColor: bgColor }}
>
{/* Hero Banner Background if present */}
{data.heroBannerUrl && (
<div className="absolute top-0 inset-x-0 h-96 overflow-hidden pointer-events-none -z-5 opacity-25">
<img src={data.heroBannerUrl} alt="" className="w-full h-full object-cover blur-sm" />
<div className="absolute inset-0 bg-gradient-to-b from-transparent to-slate-900" style={{ backgroundImage: `linear-gradient(to bottom, transparent, ${bgColor})` }} />
</div>
)}
{/* Decorative Yellow & Pink Warm Glows */}
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-yellow-500/10 via-transparent to-transparent -z-10 pointer-events-none" />
<div className="absolute top-1/4 left-1/4 w-96 h-96 rounded-full blur-3xl pointer-events-none opacity-20" style={{ backgroundColor: accentColor }} />
<div className="absolute bottom-1/3 right-1/3 w-96 h-96 rounded-full blur-3xl pointer-events-none opacity-20" style={{ backgroundColor: secondaryColor }} />
{/* Header / Hero */}
<header className="relative py-24 px-4 max-w-7xl mx-auto border-b border-slate-800">
<div className="text-center max-w-4xl mx-auto space-y-6">
<div
className="inline-flex items-center gap-2 px-4 py-1.5 bg-slate-800/80 border rounded-full text-xs tracking-wider mb-2 shadow-sm"
style={{ borderColor: `${accentColor}40`, color: accentColor }}
>
<Heart className="w-3.5 h-3.5 fill-current animate-pulse" style={{ color: secondaryColor }} />
<span>{data.badgeText || "Herzklopfen & Gefühlsstark"}</span>
</div>
<h1 className="text-4xl md:text-6xl tracking-wide text-white uppercase font-bold drop-shadow-sm">
{data.heroTitle || data.name || "RENEE HEART"}
</h1>
<p className="font-sans text-xs md:text-sm tracking-[0.3em] uppercase font-semibold" style={{ color: secondaryColor }}>
{data.heroSubtitle || "HERZENSGESCHICHTEN & EMOTIONALE ROMANE"}
</p>
<div className="h-[2px] w-36 mx-auto mt-6 rounded-full" style={{ background: `linear-gradient(to right, transparent, ${accentColor}, ${secondaryColor}, transparent)` }} />
{/* Quick Section Navigation */}
<nav className="mt-8 flex flex-wrap justify-center gap-3 text-xs font-sans">
<a
href="#ueber-mich"
className="px-4 py-2 rounded-full border border-slate-700 bg-slate-800/60 hover:border-yellow-400 hover:text-yellow-300 transition-all text-slate-200 uppercase tracking-wider font-medium"
>
💖 {data.aboutTitle || "Über Renee Heart"}
</a>
<a
href="#projekte"
className="px-4 py-2 rounded-full border border-slate-700 bg-slate-800/60 hover:border-yellow-400 hover:text-yellow-300 transition-all text-slate-200 uppercase tracking-wider font-medium"
>
✍️ {data.projectsTitle || "Aus der Schreibstube"}
</a>
<a
href="#buecherregal"
className="px-4 py-2 rounded-full border border-slate-700 bg-slate-800/60 hover:border-yellow-400 hover:text-yellow-300 transition-all text-slate-200 uppercase tracking-wider font-medium"
>
📖 {data.booksTitle || "Gefühlvolle Romane"}
</a>
</nav>
</div>
</header>
{/* Main Content Area */}
<main className="max-w-6xl mx-auto px-4 py-16 space-y-24">
{/* Author Bio Section */}
<section id="ueber-mich" className="grid grid-cols-1 md:grid-cols-12 gap-12 items-center">
<div className="md:col-span-4 flex justify-center">
<div className="relative group">
{/* Outer yellow/pink glow */}
<div className="absolute -inset-1.5 rounded-2xl blur opacity-30 group-hover:opacity-70 transition duration-1000 group-hover:duration-200" style={{ background: `linear-gradient(to right, ${accentColor}, ${secondaryColor})` }} />
<div className="relative w-64 h-64 md:w-80 md:h-80 rounded-2xl overflow-hidden border border-slate-700 shadow-xl" style={{ backgroundColor: cardBgColor }}>
<img
src={data.avatarUrl || "https://images.unsplash.com/photo-1534528741775-53994a69daeb?auto=format&fit=crop&q=80&w=400"}
alt={data.name}
className="w-full h-full object-cover group-hover:scale-105 transition duration-500"
/>
</div>
</div>
</div>
<div className="md:col-span-8 space-y-6">
<h2 className="text-2xl md:text-3xl font-bold text-white flex items-center gap-3">
<Sparkles className="w-6 h-6" style={{ color: accentColor }} />
<span>{data.aboutTitle || "Über Renee Heart"}</span>
</h2>
<div className="prose prose-invert max-w-none text-slate-300 leading-relaxed space-y-4">
<p className="whitespace-pre-wrap">{data.bio}</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 pt-4 text-xs">
{(data.bioTag1Label || data.bioTag1Value) && (
<div className="p-3 border rounded-xl flex items-center gap-3 shadow-sm" style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}30` }}>
<Heart className="w-5 h-5 fill-current shrink-0" style={{ color: secondaryColor }} />
<div>
<div className="text-slate-400 uppercase tracking-wider text-[10px]">{data.bioTag1Label || "Genre"}</div>
<div className="text-slate-200 font-semibold">{data.bioTag1Value || "Emotionale Romance"}</div>
</div>
</div>
)}
{(data.bioTag2Label || data.bioTag2Value) && (
<div className="p-3 border rounded-xl flex items-center gap-3 shadow-sm" style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}30` }}>
<Sun className="w-5 h-5 shrink-0" style={{ color: accentColor }} />
<div>
<div className="text-slate-400 uppercase tracking-wider text-[10px]">{data.bioTag2Label || "Tonalität"}</div>
<div className="text-slate-200 font-semibold">{data.bioTag2Value || "Herzlich & Hoffnungsvoll"}</div>
</div>
</div>
)}
{(data.bioTag3Label || data.bioTag3Value) && (
<div className="p-3 border rounded-xl flex items-center gap-3 col-span-2 md:col-span-1 shadow-sm" style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}30` }}>
<Sparkles className="w-5 h-5 shrink-0" style={{ color: secondaryColor }} />
<div>
<div className="text-slate-400 uppercase tracking-wider text-[10px]">{data.bioTag3Label || "Fokus"}</div>
<div className="text-slate-200 font-semibold">{data.bioTag3Value || "Gefühl & Tiefe"}</div>
</div>
</div>
)}
</div>
</div>
</section>
{/* Section 1.5: Spotlight / Neuerscheinung */}
{data.books?.find(b => b.isSpotlight) && (
<SpotlightSection
book={data.books.find(b => b.isSpotlight)!}
title={data.spotlightTitle}
subtitle={data.spotlightSubtitle}
accentColor={accentColor}
secondaryColor={secondaryColor}
cardBgColor={cardBgColor}
/>
)}
{/* Dynamic ongoing work writing progress */}
<section id="projekte" className="space-y-8">
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4 border-b border-slate-800 pb-4">
<div>
<h2 className="text-2xl md:text-3xl font-bold text-white flex items-center gap-3">
<Heart className="w-6 h-6" style={{ color: secondaryColor }} />
<span>{data.projectsTitle || "Aus der Schreibstube"}</span>
</h2>
<p className="text-slate-400 text-sm mt-1">{data.projectsSubtitle || "Ein Blick auf meine aktuellen Geschichten in Entstehung – voller Hoffnung und Herzklopfen."}</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{data.projects.map((project) => (
<button
key={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"
style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}25` }}
>
<div className="space-y-4 w-full">
<div className="flex items-center justify-between">
<span className="text-[10px] uppercase font-sans font-bold px-2.5 py-1 rounded border tracking-wider" style={{ color: accentColor, backgroundColor: `${accentColor}15`, borderColor: `${accentColor}30` }}>
{project.status || "In Arbeit"}
</span>
<span className="text-xs text-slate-400 italic">
{project.progress}% fertiggestellt
</span>
</div>
<h3 className="text-lg font-bold text-white group-hover:text-yellow-300 transition-colors">
{project.title}
</h3>
<div className="text-slate-300 text-sm line-clamp-3 markdown-body [&_p]:inline [&_p]:m-0">
<Markdown
components={{
a: ({ node, ...props }) => (
<a
target="_blank"
rel="noopener noreferrer"
className="underline text-yellow-400 hover:text-pink-400 font-medium"
onClick={(e) => e.stopPropagation()}
{...props}
/>
)
}}
>
{project.description}
</Markdown>
</div>
<p className="font-sans text-[11px] mt-2 group-hover:translate-x-1 transition-transform flex items-center gap-1 font-semibold" style={{ color: secondaryColor }}>
Details einsehen <ArrowRight className="w-3 h-3" />
</p>
</div>
<div className="mt-6 space-y-1.5 w-full">
<div className="h-1.5 w-full bg-slate-900 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-1000 ease-out shadow-sm"
style={{ width: `${project.progress}%`, background: `linear-gradient(to right, ${accentColor}, ${secondaryColor})` }}
/>
</div>
</div>
</button>
))}
</div>
</section>
{/* Bookshelf display */}
<section id="buecherregal" className="space-y-8">
<div className="border-b border-slate-800 pb-4">
<h2 className="text-2xl md:text-3xl font-bold text-white flex items-center gap-3">
<BookOpen className="w-6 h-6" style={{ color: accentColor }} />
<span>{data.booksTitle || "Gefühlvolle Romane"}</span>
</h2>
<p className="text-slate-400 text-sm mt-1">{data.booksSubtitle || "Geschichten zum Mitfühlen, Träumen und Genießen."}</p>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-6 justify-items-center">
{data.books.map((book) => (
<button
key={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 transition-all duration-300 hover:shadow-2xl cursor-pointer"
style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}30` }}
>
<div className="absolute inset-0 bg-yellow-500/10 opacity-0 group-hover:opacity-100 transition-opacity z-10 pointer-events-none" />
<img
src={book.coverUrl || "https://images.unsplash.com/photo-1512820790803-83ca734da794?auto=format&fit=crop&q=80&w=300"}
alt={book.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
referrerPolicy="no-referrer"
/>
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-slate-950 via-slate-950/90 to-transparent p-4 opacity-90 group-hover:opacity-100 transition-all duration-300 z-20">
<p className="text-white text-xs font-bold truncate">{book.title}</p>
<p className="text-[10px] font-sans mt-0.5 font-semibold flex items-center gap-1" style={{ color: secondaryColor }}>
Entdecken <ArrowRight className="inline-block w-2.5 h-2.5" />
</p>
</div>
</button>
))}
</div>
</section>
{/* Custom Highlight Section if present */}
{(data.customSectionTitle || data.customSectionContent) && (
<section id="herzenspost" className="p-8 rounded-2xl border relative overflow-hidden max-w-4xl mx-auto shadow-xl" style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}30` }}>
{data.customSectionTitle && (
<h2 className="text-2xl font-bold text-white mb-4 flex items-center gap-2">
<Heart className="w-5 h-5" style={{ color: secondaryColor }} />
<span>{data.customSectionTitle}</span>
</h2>
)}
{data.customSectionContent && (
<div className="text-slate-300 text-sm md:text-base leading-relaxed markdown-body [&_p]:mb-4 [&_p:last-child]:mb-0">
<Markdown
components={{
a: ({ node, ...props }) => (
<a
target="_blank"
rel="noopener noreferrer"
className="underline text-yellow-400 hover:text-pink-400 font-medium"
{...props}
/>
)
}}
>
{data.customSectionContent}
</Markdown>
</div>
)}
</section>
)}
</main>
{/* Book Details Modal */}
{selectedBook && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md animate-fade-in">
<div
className="relative w-full max-w-2xl border rounded-2xl p-6 md:p-8 space-y-6 max-h-[90vh] overflow-y-auto shadow-2xl"
style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}40` }}
>
<button
onClick={() => setSelectedBook(null)}
className="absolute top-4 right-4 p-2 text-slate-400 hover:text-white bg-slate-800/80 rounded-full transition-colors cursor-pointer"
>
<X className="w-5 h-5" />
</button>
<div className="grid grid-cols-1 md:grid-cols-12 gap-6 items-start">
<div className="md:col-span-5 aspect-[2/3] rounded-xl overflow-hidden border border-slate-700 shadow-md">
<img
src={selectedBook.coverUrl}
alt={selectedBook.title}
className="w-full h-full object-cover"
referrerPolicy="no-referrer"
/>
</div>
<div className="md:col-span-7 space-y-4">
<div>
<span className="text-xs uppercase tracking-wider font-bold" style={{ color: accentColor }}>Erscheinungsjahr {selectedBook.publishedYear}</span>
<h3 className="text-2xl font-bold text-white">{selectedBook.title}</h3>
{selectedBook.subtitle && (
<p className="text-sm text-slate-400 italic">{selectedBook.subtitle}</p>
)}
</div>
{selectedBook.genres && selectedBook.genres.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{selectedBook.genres.map((g, idx) => (
<span key={idx} className="px-2.5 py-0.5 rounded-full bg-slate-800 text-[10px] text-slate-300 border border-slate-700">
{g}
</span>
))}
</div>
)}
<div className="text-slate-300 text-sm leading-relaxed markdown-body [&_p]:mb-4 [&_p:last-child]:mb-0">
<Markdown
components={{
a: ({ node, ...props }) => (
<a
target="_blank"
rel="noopener noreferrer"
className="underline text-yellow-400 hover:text-pink-400 font-medium"
{...props}
/>
)
}}
>
{selectedBook.description}
</Markdown>
</div>
{/* Spotify Playlist player if configured */}
{selectedBook.spotifyPlaylistId && (
<div className="pt-2">
<div className="flex items-center gap-1.5 text-xs text-slate-400 mb-2">
<Music className="w-3.5 h-3.5 text-pink-400" />
<span>Klangwelt zum Buch auf Spotify</span>
</div>
<iframe
style={{ borderRadius: "12px" }}
src={`https://open.spotify.com/embed/playlist/${selectedBook.spotifyPlaylistId}?utm_source=generator&theme=0`}
width="100%"
height="152"
frameBorder="0"
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
loading="lazy"
/>
</div>
)}
{selectedBook.buyLink && (
<div className="pt-2">
<a
href={selectedBook.buyLink}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl font-bold text-slate-950 transition-all text-xs cursor-pointer shadow-md"
style={{ background: `linear-gradient(to right, ${accentColor}, ${secondaryColor})` }}
>
<span>Buch kaufen / Lesen</span>
<ExternalLink className="w-4 h-4" />
</a>
</div>
)}
</div>
</div>
</div>
</div>
)}
{/* Project Details Modal */}
{selectedProject && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md animate-fade-in">
<div
className="relative w-full max-w-lg border rounded-2xl p-6 md:p-8 space-y-6 shadow-2xl"
style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}40` }}
>
<button
onClick={() => setSelectedProject(null)}
className="absolute top-4 right-4 p-2 text-slate-400 hover:text-white bg-slate-800/80 rounded-full transition-colors cursor-pointer"
>
<X className="w-5 h-5" />
</button>
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-xs uppercase font-bold px-2.5 py-1 rounded border tracking-wider" style={{ color: accentColor, backgroundColor: `${accentColor}15`, borderColor: `${accentColor}30` }}>
Status: {selectedProject.status}
</span>
<span className="text-xs text-slate-400 italic">
Fortschritt: {selectedProject.progress}%
</span>
</div>
<h3 className="text-2xl font-bold text-white">{selectedProject.title}</h3>
<div className="h-2 w-full bg-slate-900 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-500"
style={{ width: `${selectedProject.progress}%`, background: `linear-gradient(to right, ${accentColor}, ${secondaryColor})` }}
/>
</div>
<div className="text-slate-300 text-sm leading-relaxed markdown-body [&_p]:mb-4 [&_p:last-child]:mb-0">
<Markdown
components={{
a: ({ node, ...props }) => (
<a
target="_blank"
rel="noopener noreferrer"
className="underline text-yellow-400 hover:text-pink-400 font-medium"
{...props}
/>
)
}}
>
{selectedProject.description || "Für dieses Schreibprojekt wurden noch keine weiteren Details abgelegt."}
</Markdown>
</div>
</div>
</div>
</div>
)}
{/* Footer */}
<footer className="border-t border-slate-800 py-12 px-4 text-center text-xs text-slate-500 font-sans" style={{ backgroundColor: bgColor }}>
<div className="max-w-7xl mx-auto space-y-2">
<p>© {new Date().getFullYear()} {data.name}. Alle Rechte vorbehalten.</p>
<p className="text-slate-500">{data.footerText || "Geschrieben mit viel Gefühl und Herzblut."}</p>
</div>
</footer>
</div>
);
}

View file

@ -1,489 +0,0 @@
import { useState } from "react";
import Markdown from "react-markdown";
import { Book, Project, AuthorProfile } from "../types";
import { BookOpen, Star, HelpCircle, Award, Compass, ArrowRight, ExternalLink, Moon } from "lucide-react";
import SpotlightSection from "./SpotlightSection";
interface SciFiPortfolioProps {
data: AuthorProfile;
}
export default function SciFiPortfolio({ data }: SciFiPortfolioProps) {
const [selectedBook, setSelectedBook] = useState<Book | null>(null);
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
const accentColor = data.accentColor || "#06b6d4";
const secondaryColor = data.secondaryColor || "#a855f7";
const bgColor = data.backgroundColor || "#020617";
const cardBgColor = data.cardBgColor || "#0f172a";
const fontClass = data.fontFamily === "serif" ? "font-serif" : data.fontFamily === "mono" ? "font-mono" : "font-sans";
return (
<div
className={`min-h-screen text-slate-100 ${fontClass} selection:bg-cyan-500 selection:text-white overflow-x-hidden relative`}
style={{ backgroundColor: bgColor }}
>
{/* Hero Banner Background if present */}
{data.heroBannerUrl && (
<div className="absolute top-0 inset-x-0 h-96 overflow-hidden pointer-events-none -z-5 opacity-25">
<img src={data.heroBannerUrl} alt="" className="w-full h-full object-cover blur-sm" />
<div className="absolute inset-0 bg-gradient-to-b from-transparent to-slate-950" style={{ backgroundImage: `linear-gradient(to bottom, transparent, ${bgColor})` }} />
</div>
)}
{/* Decorative starry background with magical aura */}
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-indigo-950/40 via-transparent to-transparent -z-10 pointer-events-none" />
<div
className="absolute top-1/4 left-1/4 w-96 h-96 rounded-full blur-3xl pointer-events-none opacity-20"
style={{ backgroundColor: accentColor }}
/>
<div
className="absolute top-2/3 right-1/4 w-96 h-96 rounded-full blur-3xl pointer-events-none opacity-20"
style={{ backgroundColor: secondaryColor }}
/>
{/* Hero Section */}
<header className="relative py-24 px-4 max-w-7xl mx-auto border-b border-slate-800/80">
<div className="text-center max-w-4xl mx-auto">
{/* Subtle magical/futuristic accent */}
<div
className="inline-flex items-center gap-2 px-3.5 py-1 bg-slate-900/80 border rounded-full text-xs font-mono mb-6 tracking-widest uppercase shadow-sm"
style={{ borderColor: `${accentColor}40`, color: accentColor }}
>
<Star className="w-3.5 h-3.5 animate-pulse" style={{ color: accentColor }} />
<span>{data.badgeText || "Cyber-Mystisches Archive"}</span>
</div>
<h1 className="text-5xl md:text-7xl tracking-tight text-white mb-4 uppercase drop-shadow-md font-bold">
{data.heroTitle || data.name || "DANIEL HESSE"}
</h1>
<p className="font-mono text-xs md:text-sm tracking-widest uppercase mb-8" style={{ color: accentColor }}>
{data.heroSubtitle || "WELTENWANDLER & STERNENZEICHNER"}
</p>
<div
className="h-0.5 w-24 mx-auto rounded-full"
style={{ background: `linear-gradient(to right, ${accentColor}, ${secondaryColor})` }}
/>
{/* Quick Section Navigation */}
<nav className="mt-8 flex flex-wrap justify-center gap-3 text-xs font-mono">
<a
href="#ueber-mich"
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.aboutTitle || "Über mich"}
</a>
<a
href="#projekte"
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.projectsTitle || "Aktuelle Projekte"}
</a>
<a
href="#buecherregal"
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.booksTitle || "Bücherregal"}
</a>
</nav>
</div>
</header>
{/* Main Container */}
<main className="max-w-7xl mx-auto px-4 py-16 space-y-24">
{/* Section 1: Über mich */}
<section id="ueber-mich" className="grid grid-cols-1 md:grid-cols-12 gap-12 items-center">
<div className="md:col-span-4 flex justify-center">
<div className="relative group">
{/* Outer neon ring */}
<div
className="absolute -inset-1.5 rounded-2xl blur opacity-30 group-hover:opacity-70 transition duration-1000 group-hover:duration-200"
style={{ background: `linear-gradient(to right, ${accentColor}, ${secondaryColor})` }}
/>
<div className="relative w-64 h-64 md:w-80 md:h-80 rounded-2xl overflow-hidden border border-slate-800" style={{ backgroundColor: cardBgColor }}>
<img
src={data.avatarUrl || "https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?auto=format&fit=crop&q=80&w=400"}
alt={data.name}
className="w-full h-full object-cover grayscale brightness-90 contrast-110 group-hover:scale-105 group-hover:grayscale-0 transition-all duration-700"
referrerPolicy="no-referrer"
/>
</div>
</div>
</div>
<div className="md:col-span-8 space-y-6">
<h2 className="text-3xl font-bold tracking-tight text-white flex items-center gap-3">
<Compass className="w-7 h-7" style={{ color: accentColor }} />
<span>{data.aboutTitle || "Über den Autor"}</span>
</h2>
<div className="prose prose-invert max-w-none text-slate-300 leading-relaxed space-y-4">
<p className="whitespace-pre-wrap">{data.bio}</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 pt-4 font-mono text-xs">
{(data.bioTag1Label || data.bioTag1Value) && (
<div className="p-3 border rounded-lg flex items-center gap-3" style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}20` }}>
<BookOpen className="w-5 h-5 shrink-0" style={{ color: secondaryColor }} />
<div>
<div className="text-slate-500 uppercase tracking-widest">{data.bioTag1Label || "Fokus"}</div>
<div className="text-white font-semibold">{data.bioTag1Value || "Sci-Fi & Fantasy"}</div>
</div>
</div>
)}
{(data.bioTag2Label || data.bioTag2Value) && (
<div className="p-3 border rounded-lg flex items-center gap-3" style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}20` }}>
<Star className="w-5 h-5 shrink-0" style={{ color: accentColor }} />
<div>
<div className="text-slate-500 uppercase tracking-widest">{data.bioTag2Label || "Welten"}</div>
<div className="text-white font-semibold">{data.bioTag2Value || "Techno-Magie"}</div>
</div>
</div>
)}
{(data.bioTag3Label || data.bioTag3Value) && (
<div className="p-3 border rounded-lg flex items-center gap-3 col-span-2 md:col-span-1" style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}20` }}>
<Award className="w-5 h-5 shrink-0" style={{ color: secondaryColor }} />
<div>
<div className="text-slate-500 uppercase tracking-widest">{data.bioTag3Label || "Stil"}</div>
<div className="text-white font-semibold">{data.bioTag3Value || "Episch & Atmosphärisch"}</div>
</div>
</div>
)}
</div>
</div>
</section>
{/* Section 1.5: Spotlight / Neuerscheinung */}
{data.books?.find(b => b.isSpotlight) && (
<SpotlightSection
book={data.books.find(b => b.isSpotlight)!}
title={data.spotlightTitle}
subtitle={data.spotlightSubtitle}
accentColor={accentColor}
secondaryColor={secondaryColor}
cardBgColor={cardBgColor}
/>
)}
{/* Section 2: Aktuelle Projekte */}
<section id="projekte" className="space-y-8">
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4 border-b border-slate-800 pb-4">
<div>
<h2 className="text-3xl font-bold tracking-tight text-white flex items-center gap-3">
<Star className="w-7 h-7" style={{ color: accentColor }} />
<span>{data.projectsTitle || "Aktuelle Projekte"}</span>
</h2>
<p className="text-slate-400 text-sm mt-1">{data.projectsSubtitle || "Ein exklusiver Blick auf das, was sich gerade auf meinem Schreib-Amboss befindet."}</p>
</div>
<div className="text-xs font-mono px-3 py-1 rounded border" style={{ color: accentColor, backgroundColor: `${accentColor}10`, borderColor: `${accentColor}30` }}>
Laufendes Telemetrie-Tracking
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{data.projects.map((project) => (
<button
key={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"
style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}25` }}
>
<div className="space-y-4 w-full">
<div className="flex items-center justify-between">
<span className="text-xs font-mono px-2.5 py-1 rounded border uppercase tracking-wider" style={{ color: accentColor, backgroundColor: `${accentColor}15`, borderColor: `${accentColor}30` }}>
{project.status || "In Arbeit"}
</span>
<span className="text-xs text-slate-400 font-mono">
Progression: {project.progress}%
</span>
</div>
<h3 className="text-xl font-bold text-white group-hover:text-cyan-300 transition-colors">
{project.title}
</h3>
<div className="text-slate-300 text-sm leading-relaxed line-clamp-3 markdown-body [&_p]:inline [&_p]:m-0">
<Markdown
components={{
a: ({ node, ...props }) => (
<a
target="_blank"
rel="noopener noreferrer"
className="underline text-cyan-400 hover:text-cyan-300 font-medium"
onClick={(e) => e.stopPropagation()}
{...props}
/>
)
}}
>
{project.description}
</Markdown>
</div>
<p className="font-mono text-[11px] mt-2 group-hover:translate-x-1 transition-transform flex items-center gap-1" style={{ color: accentColor }}>
Details anzeigen <ArrowRight className="w-3 h-3" />
</p>
</div>
{/* Custom glowing progress bar */}
<div className="mt-6 space-y-1.5 w-full">
<div className="h-1.5 w-full bg-slate-950/80 rounded-full overflow-hidden border border-slate-800">
<div
className="h-full rounded-full transition-all duration-1000 ease-out shadow-sm"
style={{ width: `${project.progress}%`, background: `linear-gradient(to right, ${accentColor}, ${secondaryColor})` }}
/>
</div>
</div>
</button>
))}
</div>
</section>
{/* Section 3: Bücherregal */}
<section id="buecherregal" className="space-y-8">
<div className="border-b border-slate-800 pb-4">
<h2 className="text-3xl font-bold tracking-tight text-white flex items-center gap-3">
<BookOpen className="w-7 h-7" style={{ color: secondaryColor }} />
<span>{data.booksTitle || "Das Bücherregal"}</span>
</h2>
<p className="text-slate-400 text-sm mt-1">{data.booksSubtitle || "Veröffentlichte Meilensteine. Klicken oder Tippen Sie auf ein Cover, um Geheimnisse zu enthüllen."}</p>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-6 justify-items-center">
{data.books.map((book) => (
<button
key={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"
style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}30` }}
>
<div className="absolute inset-0 bg-cyan-900/10 opacity-0 group-hover:opacity-100 transition-opacity z-10 pointer-events-none" />
<img
src={book.coverUrl || "https://images.unsplash.com/photo-1543002588-bfa74002ed7e?auto=format&fit=crop&q=80&w=300"}
alt={book.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500 z-0"
referrerPolicy="no-referrer"
/>
<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-[10px] font-mono mt-0.5" style={{ color: accentColor }}>Details auslesen <ArrowRight className="inline-block w-2.5 h-2.5 ml-0.5" /></p>
</div>
</button>
))}
</div>
</section>
{/* Section 4: Custom Highlight Section if present */}
{(data.customSectionTitle || data.customSectionContent) && (
<section className="p-8 rounded-2xl border relative overflow-hidden" style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}30` }}>
<div className="absolute top-0 right-0 p-3 opacity-10 font-mono text-xs uppercase" style={{ color: accentColor }}>
CUSTOM_INFO_MODULE
</div>
{data.customSectionTitle && (
<h2 className="text-2xl font-bold text-white mb-4 flex items-center gap-2">
<Star className="w-5 h-5" style={{ color: accentColor }} />
<span>{data.customSectionTitle}</span>
</h2>
)}
{data.customSectionContent && (
<div className="text-slate-300 text-sm leading-relaxed markdown-body [&_p]:mb-4 [&_p:last-child]:mb-0">
<Markdown
components={{
a: ({ node, ...props }) => (
<a
target="_blank"
rel="noopener noreferrer"
className="underline text-cyan-400 hover:text-cyan-300 font-medium"
{...props}
/>
)
}}
>
{data.customSectionContent}
</Markdown>
</div>
)}
</section>
)}
</main>
{/* Book details modular interface modal */}
{selectedBook && (
<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 */}
<button
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"
>
SCHLIESSEN [ESC]
</button>
<div className="grid grid-cols-1 md:grid-cols-12 gap-8 pt-4">
{/* Book Cover */}
<div className="col-span-1 md:col-span-4 flex flex-col items-center">
<div className="rounded-xl overflow-hidden shadow-2xl border border-indigo-900/30 max-w-[260px] aspect-[2/3] w-full">
<img
src={selectedBook.coverUrl}
alt={selectedBook.title}
className="w-full h-full object-cover"
referrerPolicy="no-referrer"
/>
</div>
{selectedBook.buyLink && (
<a
href={selectedBook.buyLink}
target="_blank"
rel="noopener noreferrer"
className="mt-6 w-full max-w-[260px] py-3 text-center bg-gradient-to-r from-cyan-500 to-indigo-600 hover:from-cyan-400 hover:to-indigo-500 text-slate-950 font-bold font-sans rounded-xl flex items-center justify-center gap-2 transition-all hover:scale-[1.02] shadow-[0_4px_12px_rgba(6,182,212,0.3)] text-sm"
>
<span>Invasion starten (Kauf-Link)</span>
<ExternalLink className="w-4 h-4" />
</a>
)}
</div>
{/* Book Details */}
<div className="col-span-1 md:col-span-8 flex flex-col justify-between space-y-6">
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-mono text-cyan-400 bg-cyan-950/40 px-2.5 py-0.5 rounded border border-cyan-900/40">
Erschienen: {selectedBook.publishedYear}
</span>
{selectedBook.genres?.map((genre) => (
<span key={genre} className="text-[11px] font-mono text-purple-400 bg-purple-950/40 px-2.5 py-0.5 rounded border border-purple-900/40">
{genre}
</span>
))}
</div>
<h3 className="text-3xl font-extrabold text-white">
{selectedBook.title}
</h3>
{selectedBook.subtitle && (
<h4 className="text-sm font-mono text-indigo-400 uppercase tracking-wider">
{selectedBook.subtitle}
</h4>
)}
<div className="h-px bg-indigo-950" />
<div className="text-slate-300 text-sm leading-relaxed markdown-body [&_p]:mb-4 [&_p:last-child]:mb-0">
<Markdown>{selectedBook.description}</Markdown>
</div>
</div>
{/* Integrated Spotify web player playlist if available */}
{selectedBook.spotifyPlaylistId && selectedBook.spotifyPlaylistId.trim() !== "" && (
<div className="space-y-2">
<p className="text-xs font-mono text-indigo-400 flex items-center gap-2">
<Star className="w-3.5 h-3.5 animate-spin" />
<span>INTEGRIERTES ATMOSPHÄREN-SYSTEM (SPOTIFY PLAYLIST)</span>
</p>
<div className="rounded-xl overflow-hidden bg-slate-950 border border-slate-800">
<iframe
src={`https://open.spotify.com/embed/playlist/${selectedBook.spotifyPlaylistId}?utm_source=generator&theme=0`}
width="100%"
height="152"
frameBorder="0"
allowFullScreen={false}
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
loading="lazy"
className="bg-slate-950"
/>
</div>
</div>
)}
</div>
</div>
</div>
</div>
</div>
)}
{/* Project details modular modal */}
{selectedProject && (
<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-2xl p-6 md:p-8 space-y-6">
{/* Close Button */}
<button
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"
>
SCHLIESSEN [ESC]
</button>
<div className="space-y-6 pt-4">
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-mono text-cyan-400 bg-cyan-950/40 px-2.5 py-0.5 rounded border border-cyan-900/40">
Status: {selectedProject.status || "In Arbeit"}
</span>
<span className="text-xs font-mono text-purple-400 bg-purple-950/40 px-2.5 py-0.5 rounded border border-purple-900/40">
Übertragung aktiv
</span>
</div>
<h3 className="text-3xl font-extrabold text-white">
{selectedProject.title}
</h3>
<div className="h-px bg-indigo-950" />
<div className="space-y-2">
<div className="flex justify-between items-center text-xs font-mono text-slate-400">
<span>ORGANISCHER FORTSCHRITT:</span>
<span className="text-cyan-400 font-bold">{selectedProject.progress}%</span>
</div>
<div className="h-2 w-full bg-slate-950 rounded-full overflow-hidden border border-slate-800">
<div
className="h-full bg-gradient-to-r from-cyan-400 via-indigo-500 to-purple-600 rounded-full transition-all duration-1000 ease-out shadow-[0_0_8px_rgba(34,211,238,0.4)]"
style={{ width: `${selectedProject.progress}%` }}
/>
</div>
</div>
<div className="bg-slate-950/60 p-5 rounded-xl border border-slate-800/60 relative overflow-hidden">
<div className="absolute top-0 right-0 p-2 text-slate-800 text-[10px] font-mono select-none">
SEC_REPORT_LOG
</div>
<h4 className="text-xs font-mono text-slate-500 uppercase tracking-widest mb-3">Projekt-Exzerpt & Fortschrittsnotizen</h4>
<div className="text-slate-300 text-sm leading-relaxed markdown-body [&_p]:mb-4 [&_p:last-child]:mb-0">
<Markdown>{selectedProject.description || "Für dieses Schreibprojekt wurden noch keine detaillierten Chiffren abgelegt."}</Markdown>
</div>
</div>
</div>
</div>
</div>
</div>
)}
{/* Styled Footer */}
<footer className="border-t border-slate-800 py-12 px-4 text-center text-xs text-slate-500 font-mono" style={{ backgroundColor: bgColor }}>
<div className="max-w-7xl mx-auto space-y-4">
<p>© {new Date().getFullYear()} {data.name}. Alle Rechte vorbehalten.</p>
<p className="text-slate-500">{data.footerText || "Dokumentation, Fragmente und stellare Chiffren. Verschlüsselt mit moderner Node.JS Technologie."}</p>
</div>
</footer>
</div>
);
}

View file

@ -0,0 +1,68 @@
import { useEffect, useState } from "react";
import { Music } from "lucide-react";
interface SpotifyConsentPlayerProps {
playlist: string;
title: string;
accentColor?: string;
}
function normalizePlaylistId(value: string): string | null {
const trimmed = value.trim();
const urlMatch = trimmed.match(/open\.spotify\.com\/playlist\/([a-zA-Z0-9]+)/);
const candidate = urlMatch?.[1] || trimmed;
return /^[a-zA-Z0-9]+$/.test(candidate) ? candidate : null;
}
export default function SpotifyConsentPlayer({ playlist, title, accentColor = "#34d399" }: SpotifyConsentPlayerProps) {
const [isLoaded, setIsLoaded] = useState(false);
const playlistId = normalizePlaylistId(playlist);
useEffect(() => {
setIsLoaded(false);
}, [playlist]);
if (!playlistId) return null;
if (isLoaded) {
return (
<div className="space-y-2">
<iframe
title={title}
src={`https://open.spotify.com/embed/playlist/${playlistId}?utm_source=generator&theme=0`}
width="100%"
height="152"
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
loading="lazy"
className="rounded-xl border-0"
/>
<button
type="button"
onClick={() => setIsLoaded(false)}
className="text-[10px] text-slate-500 underline underline-offset-2 hover:text-slate-300 cursor-pointer"
>
Spotify-Player wieder deaktivieren
</button>
</div>
);
}
return (
<div className="rounded-xl border border-white/10 bg-black/20 p-4 space-y-3">
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wider" style={{ color: accentColor }}>
<Music className="h-4 w-4" />
<span>Spotify-Playlist</span>
</div>
<p className="text-xs leading-relaxed text-slate-400">
Der Player wird erst nach Ihrer Zustimmung von Spotify geladen. Dabei wird eine Verbindung zu Spotify hergestellt.
</p>
<button
type="button"
onClick={() => setIsLoaded(true)}
className="rounded-lg border border-white/15 bg-white/10 px-4 py-2 text-xs font-semibold text-white hover:bg-white/15 transition-colors cursor-pointer"
>
Spotify-Player laden
</button>
</div>
);
}

View file

@ -1,7 +1,8 @@
import React, { useState } from "react";
import React from "react";
import Markdown from "react-markdown";
import { Book } from "../types";
import { Sparkles, Calendar, ShoppingBag, Music, ExternalLink, Tag } from "lucide-react";
import { Sparkles, Calendar, ShoppingBag, ExternalLink, Tag } from "lucide-react";
import SpotifyConsentPlayer from "./SpotifyConsentPlayer";
interface SpotlightSectionProps {
book: Book;
@ -20,8 +21,6 @@ export default function SpotlightSection({
secondaryColor,
cardBgColor,
}: SpotlightSectionProps) {
const [showSpotify, setShowSpotify] = useState(false);
// Helper to check if a release date is in the future
const isFutureRelease = (): boolean => {
if (!book.publishedYear) return false;
@ -94,7 +93,7 @@ export default function SpotlightSection({
/>
<div className="relative aspect-[2/3] w-full rounded-xl overflow-hidden border border-slate-700/60 shadow-2xl bg-slate-900">
<img
src={book.coverUrl}
src={book.coverUrl || undefined}
alt={book.title}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
referrerPolicy="no-referrer"
@ -102,23 +101,29 @@ export default function SpotlightSection({
</div>
</div>
{book.buyLink && (
{(book.ebookLink || book.buyLink) && (
<a
href={book.buyLink}
href={book.ebookLink || book.buyLink}
target="_blank"
rel="noopener noreferrer"
className="w-full max-w-[260px] py-3 px-4 rounded-xl font-bold text-xs uppercase tracking-wider transition-all duration-300 flex items-center justify-center gap-2 shadow-lg cursor-pointer hover:scale-[1.02]"
style={{ backgroundColor: accentColor, color: "#000" }}
>
<ShoppingBag className="w-4 h-4" />
<span>{isFuture ? "Jetzt vorbestellen" : "Jetzt im Handel"}</span>
<span>{isFuture ? "E-Book vorbestellen" : "E-Book"}</span>
<ExternalLink className="w-3.5 h-3.5 ml-auto opacity-70" />
</a>
)}
{book.paperbackLink && (
<a href={book.paperbackLink} target="_blank" rel="noopener noreferrer" className="w-full max-w-[260px] py-3 px-4 rounded-xl border border-slate-700 bg-slate-900 text-white font-bold text-xs uppercase tracking-wider flex items-center justify-center gap-2 hover:bg-slate-800">
<ShoppingBag className="w-4 h-4" /><span>Taschenbuch</span><ExternalLink className="w-3.5 h-3.5 ml-auto opacity-70" />
</a>
)}
</div>
{/* Details column */}
<div className="md:col-span-8 space-y-5">
{book.seriesName && <p className="text-xs font-semibold uppercase tracking-widest" style={{ color: accentColor }}>{book.seriesName}{book.seriesNumber !== undefined && book.seriesNumber !== "" ? ` · Band ${book.seriesNumber}` : ""}</p>}
<div className="flex flex-wrap items-center gap-2">
<span
className="px-3 py-1 rounded-full text-xs font-mono font-bold tracking-wide uppercase border flex items-center gap-1.5 shadow-sm"
@ -185,29 +190,10 @@ export default function SpotlightSection({
</Markdown>
</div>
{/* Spotify Player Toggle if present */}
{/* Privacy-friendly Spotify player */}
{book.spotifyPlaylistId && (
<div className="pt-2">
<button
onClick={() => setShowSpotify(!showSpotify)}
className="px-4 py-2 bg-slate-900 hover:bg-slate-850 border border-slate-700 rounded-xl text-xs font-mono text-slate-300 flex items-center gap-2 transition-all cursor-pointer"
>
<Music className="w-4 h-4 text-emerald-400" />
<span>{showSpotify ? "Playlist einklappen" : "Buch-Soundtrack (Spotify) anhören"}</span>
</button>
{showSpotify && (
<div className="mt-3 rounded-xl overflow-hidden border border-slate-800 shadow-xl bg-slate-950">
<iframe
src={`https://open.spotify.com/embed/playlist/${book.spotifyPlaylistId}?utm_source=generator&theme=0`}
width="100%"
height="152"
frameBorder="0"
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
loading="lazy"
/>
</div>
)}
<SpotifyConsentPlayer playlist={book.spotifyPlaylistId} title={`Spotify-Playlist zu ${book.title}`} accentColor={accentColor} />
</div>
)}
</div>

View file

@ -0,0 +1,51 @@
import type { FormEvent } from "react";
import { Lock, RefreshCw } from "lucide-react";
interface AdminLoginProps {
password: string;
error: string;
loading: boolean;
onPasswordChange: (password: string) => void;
onSubmit: (event: FormEvent) => void;
}
export default function AdminLogin({ password, error, loading, onPasswordChange, onSubmit }: AdminLoginProps) {
return (
<div className="min-h-screen bg-slate-950 text-slate-100 flex items-center justify-center p-4">
<div className="w-full max-w-md p-8 bg-slate-900 border border-slate-800 rounded-2xl shadow-2xl space-y-6">
<div className="text-center space-y-2">
<div className="mx-auto w-12 h-12 bg-indigo-950/80 border border-indigo-700/50 rounded-xl flex items-center justify-center text-cyan-400">
<Lock className="w-6 h-6" />
</div>
<h1 className="text-2xl font-bold tracking-tight text-white">Autoren-Zentrale Admin</h1>
<p className="text-xs text-slate-400">Authentifizierung erforderlich. Passwortgeschütztes Kontrollzentrum.</p>
</div>
<form onSubmit={onSubmit} className="space-y-4">
<div className="space-y-1">
<label className="text-xs font-mono text-cyan-400 uppercase tracking-widest">Admin-Passwort</label>
<input
type="password"
value={password}
onChange={(event) => onPasswordChange(event.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
/>
<span className="text-[10px] text-slate-500">Das Passwort wird über die Serverkonfiguration festgelegt.</span>
</div>
{error && <div className="p-3 bg-rose-950/40 border border-rose-900/50 text-rose-300 text-xs rounded-lg text-center">{error}</div>}
<button
type="submit"
disabled={loading}
className="w-full py-3 bg-gradient-to-r from-cyan-500 to-indigo-600 hover:from-cyan-400 hover:to-indigo-500 text-slate-950 font-bold rounded-xl transition-all flex items-center justify-center gap-2 text-sm cursor-pointer disabled:opacity-60"
>
{loading ? <RefreshCw className="w-4 h-4 animate-spin" /> : <span>Zentrale aufschließen</span>}
</button>
</form>
</div>
</div>
);
}

View file

@ -0,0 +1,123 @@
import { useEffect, useState } from "react";
import { ArrowDown, ArrowUp, Edit2, Plus, Save, Trash2 } from "lucide-react";
import type { DownloadPublication } from "../../types";
import { adminApi } from "../../api/adminApi";
import ImagePicker from "../ImagePicker";
import PublicationFilePicker from "./PublicationFilePicker";
interface DownloadsManagerProps {
downloads: DownloadPublication[];
title: string;
subtitle: string;
loading: boolean;
onSave: (downloads: DownloadPublication[], title: string, subtitle: string) => Promise<void>;
onError: (message: string) => void;
}
const emptyForm = (): Partial<DownloadPublication> => ({ title: "", description: "", imageUrl: "", pdfUrl: "", epubUrl: "", publishedAt: "" });
async function deleteUnreferencedFiles(urls: Array<string | undefined>) {
await Promise.all(urls.filter(Boolean).map(async (url) => {
const name = url!.split("/").pop();
if (name) await adminApi.deletePublication(name).catch(() => undefined);
}));
}
export default function DownloadsManager({ downloads, title, subtitle, loading, onSave, onError }: DownloadsManagerProps) {
const [sectionTitle, setSectionTitle] = useState(title);
const [sectionSubtitle, setSectionSubtitle] = useState(subtitle);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState<Partial<DownloadPublication>>(emptyForm());
useEffect(() => setSectionTitle(title), [title]);
useEffect(() => setSectionSubtitle(subtitle), [subtitle]);
const persist = async (nextDownloads: DownloadPublication[]) => {
try {
await onSave(nextDownloads, sectionTitle, sectionSubtitle);
} catch (error) {
onError(error instanceof Error ? error.message : "Die Downloads konnten nicht gespeichert werden.");
throw error;
}
};
const saveEntry = async () => {
if (!form.title?.trim()) {
onError("Bitte einen Titel für den Download eintragen.");
return;
}
if (!form.pdfUrl && !form.epubUrl) {
onError("Bitte mindestens eine PDF- oder ePUB-Datei hochladen.");
return;
}
const previous = editingId && editingId !== "new" ? downloads.find((item) => item.id === editingId) : undefined;
const entry: DownloadPublication = {
id: editingId === "new" ? `download_${Date.now()}` : editingId!,
title: form.title.trim(),
description: form.description || "",
imageUrl: form.imageUrl || "",
pdfUrl: form.pdfUrl || "",
epubUrl: form.epubUrl || "",
publishedAt: form.publishedAt || "",
};
const next = editingId === "new" ? [entry, ...downloads] : downloads.map((item) => item.id === editingId ? entry : item);
await persist(next);
await deleteUnreferencedFiles([
previous?.pdfUrl && previous.pdfUrl !== entry.pdfUrl ? previous.pdfUrl : undefined,
previous?.epubUrl && previous.epubUrl !== entry.epubUrl ? previous.epubUrl : undefined,
]);
setEditingId(null);
setForm(emptyForm());
};
const remove = async (entry: DownloadPublication) => {
if (!confirm(`„${entry.title}“ wirklich löschen?`)) return;
await persist(downloads.filter((item) => item.id !== entry.id));
await deleteUnreferencedFiles([entry.pdfUrl, entry.epubUrl]);
};
const move = async (index: number, direction: -1 | 1) => {
const target = index + direction;
if (target < 0 || target >= downloads.length) return;
const next = [...downloads];
[next[index], next[target]] = [next[target], next[index]];
await persist(next);
};
if (editingId) {
return (
<div className="space-y-5 rounded-2xl border border-slate-800 bg-slate-900 p-6">
<h2 className="text-lg font-bold text-white">{editingId === "new" ? "Neuen Download anlegen" : "Download bearbeiten"}</h2>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-1 md:col-span-2"><label className="text-xs font-mono uppercase text-slate-400">Titel</label><input value={form.title || ""} onChange={(event) => setForm({ ...form, title: event.target.value })} className="w-full rounded-xl border border-slate-800 bg-slate-950 px-3 py-2 text-white" /></div>
<div className="space-y-1 md:col-span-2"><label className="text-xs font-mono uppercase text-slate-400">Kurze Beschreibung</label><textarea rows={4} value={form.description || ""} onChange={(event) => setForm({ ...form, description: event.target.value })} className="w-full rounded-xl border border-slate-800 bg-slate-950 px-3 py-2 text-white" /></div>
<div className="space-y-1"><label className="text-xs font-mono uppercase text-slate-400">Veröffentlichungsdatum (optional)</label><input type="date" value={form.publishedAt || ""} onChange={(event) => setForm({ ...form, publishedAt: event.target.value })} className="w-full rounded-xl border border-slate-800 bg-slate-950 px-3 py-2 text-white" /></div>
<div className="space-y-1"><label className="text-xs font-mono uppercase text-slate-400">Optionales Bild</label><ImagePicker value={form.imageUrl || ""} onChange={(url) => setForm({ ...form, imageUrl: url })} label="Download-Bild" /></div>
<div className="space-y-1"><label className="text-xs font-mono uppercase text-slate-400">PDF-Datei</label><PublicationFilePicker format="pdf" value={form.pdfUrl || ""} onChange={(url) => setForm({ ...form, pdfUrl: url })} /></div>
<div className="space-y-1"><label className="text-xs font-mono uppercase text-slate-400">ePUB-Datei</label><PublicationFilePicker format="epub" value={form.epubUrl || ""} onChange={(url) => setForm({ ...form, epubUrl: url })} /></div>
</div>
<div className="flex gap-3 border-t border-slate-800 pt-4"><button type="button" disabled={loading} onClick={saveEntry} className="rounded-lg bg-emerald-600 px-4 py-2 text-xs font-bold text-white"><Save className="mr-1 inline h-4 w-4" />Speichern</button><button type="button" onClick={() => { setEditingId(null); setForm(emptyForm()); }} className="rounded-lg bg-slate-800 px-4 py-2 text-xs text-slate-300">Abbrechen</button></div>
</div>
);
}
return (
<div className="space-y-6">
<div className="space-y-4 rounded-2xl border border-slate-800 bg-slate-900 p-6">
<div className="flex flex-wrap items-center justify-between gap-3"><div><h2 className="text-lg font-bold text-white">Kurzgeschichten & Downloads</h2><p className="text-xs text-slate-400">Die ersten drei Einträge werden öffentlich sofort angezeigt.</p></div><button type="button" onClick={() => { setEditingId("new"); setForm(emptyForm()); }} className="rounded-lg bg-emerald-600 px-4 py-2 text-xs font-bold text-white"><Plus className="mr-1 inline h-4 w-4" />Neuer Download</button></div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2"><div><label className="text-xs font-mono uppercase text-slate-400">Abschnittstitel</label><input value={sectionTitle} onChange={(event) => setSectionTitle(event.target.value)} className="mt-1 w-full rounded-xl border border-slate-800 bg-slate-950 px-3 py-2 text-white" /></div><div><label className="text-xs font-mono uppercase text-slate-400">Untertitel</label><input value={sectionSubtitle} onChange={(event) => setSectionSubtitle(event.target.value)} className="mt-1 w-full rounded-xl border border-slate-800 bg-slate-950 px-3 py-2 text-white" /></div></div>
<button type="button" disabled={loading} onClick={() => persist(downloads)} className="rounded-lg bg-indigo-600 px-4 py-2 text-xs font-bold text-white">Überschriften speichern</button>
</div>
<div className="space-y-3">
{downloads.length === 0 && <p className="rounded-xl border border-dashed border-slate-800 p-8 text-center text-sm text-slate-500">Noch keine Downloads hinterlegt.</p>}
{downloads.map((entry, index) => (
<div key={entry.id} className="flex items-center gap-4 rounded-xl border border-slate-800 bg-slate-900 p-4">
{entry.imageUrl && <img src={entry.imageUrl} alt="" className="h-16 w-16 rounded-lg object-cover" />}
<div className="min-w-0 flex-1"><h3 className="truncate font-bold text-white">{entry.title}</h3><p className="line-clamp-1 text-xs text-slate-400">{entry.description}</p><p className="mt-1 text-[10px] text-slate-500">{entry.pdfUrl ? "PDF " : ""}{entry.epubUrl ? "ePUB" : ""}</p></div>
<div className="flex items-center gap-1"><button type="button" disabled={index === 0} onClick={() => move(index, -1)} className="p-2 text-slate-400 disabled:opacity-20" aria-label="Nach oben"><ArrowUp className="h-4 w-4" /></button><button type="button" disabled={index === downloads.length - 1} onClick={() => move(index, 1)} className="p-2 text-slate-400 disabled:opacity-20" aria-label="Nach unten"><ArrowDown className="h-4 w-4" /></button><button type="button" onClick={() => { setEditingId(entry.id); setForm(entry); }} className="p-2 text-slate-400 hover:text-white" aria-label="Bearbeiten"><Edit2 className="h-4 w-4" /></button><button type="button" onClick={() => remove(entry)} className="p-2 text-rose-400" aria-label="Löschen"><Trash2 className="h-4 w-4" /></button></div>
</div>
))}
</div>
</div>
);
}

View file

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

View file

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

View file

@ -3,7 +3,7 @@ import { AuthorData } from "./types";
export const defaultAuthorData: AuthorData = {
scifi: {
name: "Daniel Hesse",
avatarUrl: "https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?auto=format&fit=crop&q=80&w=300",
avatarUrl: "",
heroTitle: "DANIEL HESSE",
heroSubtitle: "WELTENWANDLER & STERNENZEICHNER",
bio: "Herzlich willkommen! Ich bin Daniel Hesse. Ich schreibe epische Fantasy und tiefgründige Science-Fiction, die dich an die Grenzen der Realität und weit darüber hinaus entführen. Schon als Kind faszinierten mich die unendlichen Weiten des Kosmos und die alten Sagen vergessener Völker. In meinen Büchern verschmelzen kalte, fortschrittliche Technologie und uralter, mystischer Zauber zu einer einzigartigen Atmosphäre. Wenn ich nicht gerade neue Galaxien am Laptop erschaffe, wandere ich durch dunkle Wälder oder beobachte mit meinem Teleskop den Nachthimmel.",
@ -28,7 +28,7 @@ export const defaultAuthorData: AuthorData = {
id: "book_1",
title: "Caelum: Ruf der Sterne",
subtitle: "Arche-Chroniken I",
coverUrl: "https://images.unsplash.com/photo-1451187580459-43490279c0fa?auto=format&fit=crop&q=80&w=600",
coverUrl: "",
description: "Am Rande der bekannten Galaxie sucht die Besatzung der 'Caelum' nach einer neuen Heimat für die letzten Überlebenden der Erde. Doch der leere Raum zwischen den Sternen ist nicht so unbewohnt, wie sie dachten. Es wartet eine Bedrohung, die älter ist als die Zeit selbst, aber auch eine Macht, die das Verständnis der Menschheit revolutionieren könnte.",
publishedYear: 2024,
buyLink: "https://amazon.de",
@ -41,7 +41,7 @@ export const defaultAuthorData: AuthorData = {
id: "book_2",
title: "Die Schatten von ThalÃassia",
subtitle: "Einzelband",
coverUrl: "https://images.unsplash.com/photo-1518709268805-4e9042af9f23?auto=format&fit=crop&q=80&w=600",
coverUrl: "",
description: "In der versunkenen Unterwasser-Metropole Thalassia ringen Magokraten und Kybernetiker um ein uraltes Artefakt. Ein junger Taucher stößt in den abyssalen Gräben auf ein grauenvolles Geheimnis, das das Schicksal beider Fraktionen besiegeln könnte.",
publishedYear: 2023,
buyLink: "https://amazon.de",
@ -63,6 +63,9 @@ export const defaultAuthorData: AuthorData = {
bioTag3Value: "Verschlüsselt",
projectsTitle: "Aktuelle Projekte",
projectsSubtitle: "Ein exklusiver Blick auf das, was sich gerade auf meinem Schreib-Amboss befindet.",
projectExcerptTitle: "Projekt-Exzerpt & Fortschrittsnotizen",
projectExcerptBadge: "SEC_REPORT_LOG",
projectProgressLabel: "Telemetrie / Fortschritt",
booksTitle: "Bücherregal",
booksSubtitle: "Entdecken Sie meine verlegten Romane und E-Books mit Hörbeispielen.",
footerText: "Dokumentation, Fragmente und stellare Chiffren. Verschlüsselt mit moderner Node.JS Technologie.",
@ -77,7 +80,7 @@ export const defaultAuthorData: AuthorData = {
},
erotica: {
name: "Annie Slone",
avatarUrl: "https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?auto=format&fit=crop&q=80&w=300",
avatarUrl: "",
heroTitle: "ANNIE SLONE",
heroSubtitle: "ELEGANT-SINNLICHE LITERATUR",
bio: "Willkommen in meiner privaten Welt der Versuchung. Unter dem Pseudonym Annie Slone schreibe ich stilvolle, hocherotische Romane und prickelnde Erzählungen. Meine Leidenschaft liegt darin, das feine Spiel zwischen Verlangen und Hingabe, Macht und Vertrauen in all seinen schillernden Facetten zu beleuchten. Meine Geschichten richten sich an Kenner anspruchsvoller Erotikliteratur, die Wert auf tiefgründige Charaktere, knisternde Spannung und ästhetische Beschreibungen legen. Treten Sie näher und lassen Sie sich entführen.",
@ -102,7 +105,7 @@ export const defaultAuthorData: AuthorData = {
id: "book_er_1",
title: "Flüstern im Samt",
subtitle: "Velvet Trust I",
coverUrl: "https://images.unsplash.com/photo-1517841905240-472988babdf9?auto=format&fit=crop&q=80&w=600",
coverUrl: "",
description: "Ein Blick in den überfüllten Ballsaal reicht aus, um Sophias wohlgeordnete Welt ins Wanken zu bringen. Der geheimnisvolle Kunsthändler Alexander bietet ihr einen Deal an, dem sie nicht widerstehen kann. Doch hinter den luxuriösen Fassaden verbirgt sich ein leidenschaftliches Abenteuer voller Geheimnisse, das ihre tiefsten Tabus herausfordern wird.",
publishedYear: 2025,
buyLink: "https://amazon.de",
@ -113,7 +116,7 @@ export const defaultAuthorData: AuthorData = {
id: "book_er_2",
title: "Die Maske des Begehrens",
subtitle: "Erotischer Thriller",
coverUrl: "https://images.unsplash.com/photo-1508700115892-45ecd05ae2ad?auto=format&fit=crop&q=80&w=600",
coverUrl: "",
description: "In Venedigs geheimem Karnevalskreisen kreuzen sich die Wege zweier Fremder, versteckt hinter venezianischen Seidenmasken. Ein sinnliches Versteckspiel beginnt, bei dem Identitäten verblassen und nur noch die unkontrollierbare sexuelle Spannung regiert.",
publishedYear: 2024,
buyLink: "https://amazon.de",
@ -135,6 +138,9 @@ export const defaultAuthorData: AuthorData = {
bioTag3Value: "Diskret / Pseudonym",
projectsTitle: "In der Entstehung",
projectsSubtitle: "Ein Blick in mein privates Arbeitszimmer. Hier reifen neue Kapitel voller Leidenschaft.",
projectExcerptTitle: "Konzeptuelle Skizzen & Atmosphärische Auszüge",
projectExcerptBadge: "FRAGMENT_REPORT_09",
projectProgressLabel: "Reifegrad des Werks",
booksTitle: "Das Samtregal",
booksSubtitle: "Verführerische Geschichten und sinnliche Romane für besondere Stunden.",
footerText: "Mit Liebe und Leidenschaft geschrieben. Alle Werke geschützt.",
@ -149,7 +155,7 @@ export const defaultAuthorData: AuthorData = {
},
clara: {
name: "Clara Finch",
avatarUrl: "https://images.unsplash.com/photo-1544005313-94ddf0286df2?auto=format&fit=crop&q=80&w=300",
avatarUrl: "",
heroTitle: "CLARA FINCH",
heroSubtitle: "LYRIK, ESSAYS & NATURPHILOSOPHIE",
bio: "Willkommen auf meiner kreativen Lichtung. Ich bin Clara Finch. Unter diesem Namen widme ich mich der lyrischen Poesie, den feinen Essays und naturphilosophischen Betrachtungen über die Verbindung von Mensch und Erde. Meine Texte entstehen oft auf langen Spaziergängen durch neblige Täler, dichte Wälder und karge Küstenstreifen. Sie fangen die Vergänglichkeit des Augenblicks ein – sanft wie Malve, tief verwurzelt wie Salbei. Hier teile ich meine gedruckten Werke, laufenden Notizbücher und klanglichen Inspirationen mit Ihnen. Treten Sie leise ein und verweilen Sie einen Moment.",
@ -174,7 +180,7 @@ export const defaultAuthorData: AuthorData = {
id: "book_cl_1",
title: "Die Stille der Halme",
subtitle: "Gedichte",
coverUrl: "https://images.unsplash.com/photo-1501504905252-473c47e087f8?auto=format&fit=crop&q=80&w=600",
coverUrl: "",
description: "Mein erster veröffentlichter Gedichtband, der den Leser auf eine kontemplative Reise durch unberührte Landschaften, stille Morgenstunden und das leise Rauschen der Felder entführt. Jedes Wort ist sorgsam wie ein seltener Kieselstein ausgewählt, um das Unsagbare im Alltäglichen spürbar zu machen.",
publishedYear: 2025,
buyLink: "https://amazon.de",
@ -185,7 +191,7 @@ export const defaultAuthorData: AuthorData = {
id: "book_cl_2",
title: "Knospen im Frost",
subtitle: "Prosa-Miniaturen",
coverUrl: "https://images.unsplash.com/photo-1448375240586-882707db888b?auto=format&fit=crop&q=80&w=600",
coverUrl: "",
description: "Eine Sammlung feinsinniger Prosa-Skizzen, die sich mit der Resilienz des Lebens inmitten winterlicher Kälte befassen. Metaphern aus Flora und Fauna zeichnen ein zärtliches Porträt des menschlichen Geistes in Zeiten des Umbruchs.",
publishedYear: 2024,
buyLink: "https://amazon.de",
@ -207,6 +213,9 @@ export const defaultAuthorData: AuthorData = {
bioTag3Value: "Naturkontemplation",
projectsTitle: "Laufende Notizen & Skizzen",
projectsSubtitle: "Gedankenfragmente und Manuskripte, die leise im Winde reifen.",
projectExcerptTitle: "Konzeptuelle Notizen & Auszüge",
projectExcerptBadge: "FRAGMENT_LOG_CLARA",
projectProgressLabel: "Entwurf-Fortschritt",
booksTitle: "Veröffentlichte Bände",
booksSubtitle: "Lyrikbände und Naturprosa zum Verweilen und Innehalten.",
footerText: "Gedruckt auf virtuellem Pergament. Stille Notizen aus Wald und Flur.",
@ -221,7 +230,7 @@ export const defaultAuthorData: AuthorData = {
},
renee: {
name: "Renee Heart",
avatarUrl: "https://images.unsplash.com/photo-1534528741775-53994a69daeb?auto=format&fit=crop&q=80&w=300",
avatarUrl: "",
heroTitle: "RENEE HEART",
heroSubtitle: "HERZENSGESCHICHTEN & EMOTIONALE ROMANE",
bio: "Herzlich willkommen in meinem literarischen Zuhause. Ich bin Renee Heart. Ich schreibe herzerwärmende, gefühlvolle Romane über die großen Wendepunkte im Leben, tiefe Freundschaften, unvergessliche Liebesgeschichten und den Mut, dem eigenen Herzen zu folgen. Meine Geschichten handeln von echten Emotionen, leisen wie auch stürmischen Momenten und Charakteren, die man sofort ins Herz schließt. Lassen Sie sich von meinen Büchern berühren und begleiten!",
@ -246,7 +255,7 @@ export const defaultAuthorData: AuthorData = {
id: "book_rh_1",
title: "Wo das Herz Zuhause ist",
subtitle: "Herzensroman",
coverUrl: "https://images.unsplash.com/photo-1512820790803-83ca734da794?auto=format&fit=crop&q=80&w=600",
coverUrl: "",
description: "Als Maya das alte Haus ihrer Großmutter an der Küste erbt, ahnt sie nicht, dass der Neuanfang ihr ganzes Leben verändern wird. Zwischen stürmischen Wellen und warmem Tee findet sie nicht nur vergessene Familiengeheimnisse, sondern auch die Liebe ihres Lebens.",
publishedYear: 2025,
buyLink: "https://amazon.de",
@ -257,7 +266,7 @@ export const defaultAuthorData: AuthorData = {
id: "book_rh_2",
title: "Sterne über den Dächern",
subtitle: "Romantische Komödie",
coverUrl: "https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?auto=format&fit=crop&q=80&w=600",
coverUrl: "",
description: "Eine humorvolle und tiefgründige Geschichte über zwei Gegensätze, die sich in einer nächtlichen Sternwarte näherkommen als je geahnt.",
publishedYear: 2024,
buyLink: "https://amazon.de",
@ -279,6 +288,9 @@ export const defaultAuthorData: AuthorData = {
bioTag3Value: "Gefühl & Tiefe",
projectsTitle: "Aus der Schreibstube",
projectsSubtitle: "Ein Blick auf meine aktuellen Geschichten in Entstehung – voller Hoffnung und Herzklopfen.",
projectExcerptTitle: "Gedanken & Details zum Projekt",
projectExcerptBadge: "SCHREIBSTUBE_LOG",
projectProgressLabel: "Fortschritt",
booksTitle: "Gefühlvolle Romane",
booksSubtitle: "Geschichten zum Mitfühlen, Träumen und Genießen.",
footerText: "Geschrieben mit viel Gefühl und Herzblut.",
@ -290,5 +302,82 @@ export const defaultAuthorData: AuthorData = {
cardBgColor: "#1e293b",
fontFamily: "sans",
heroBannerUrl: ""
},
legalDocuments: [
{
id: "legal_impressum",
title: "Impressum",
type: "impressum",
updatedAt: "2026-08-14",
assignedProfiles: ["scifi", "erotica", "clara", "renee"],
content: `### Angaben gemäß § 5 Digitale-Dienste-Gesetz (DDG)
**Daniel Hesse**
Musterstraße 12
10115 Berlin
Deutschland
### Kontakt
**E-Mail:** kontakt@danielhesse.de
**Website:** https://danielhesse.de
### Redaktionell verantwortlich
Daniel Hesse
Musterstraße 12
10115 Berlin
### 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.
### EU-Streitschlichtung
Die Europäische Kommission stellt eine Plattform zur Online-Streitbeilegung (OS) bereit: https://ec.europa.eu/consumers/odr/
Unsere E-Mail-Adresse finden Sie oben im Impressum.
### Verbraucherstreitbeilegung / Universalschlichtungsstelle
Wir sind nicht bereit oder verpflichtet, an Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen.`
},
{
id: "legal_privacy",
title: "Datenschutzerklärung",
type: "privacy",
updatedAt: "2026-08-14",
assignedProfiles: ["scifi", "erotica", "clara", "renee"],
content: `### 1. Datenschutz auf einen Blick
**Allgemeine Hinweise**
Die folgenden Hinweise geben einen einfachen Überblick darüber, was mit Ihren personenbezogenen Daten passiert, wenn Sie diese Website besuchen. Personenbezogene Daten sind alle Daten, mit denen Sie persönlich identifiziert werden können.
### 2. Verantwortliche Stelle
Verantwortlich für die Datenverarbeitung auf dieser Website ist:
**Daniel Hesse**
Musterstraße 12
10115 Berlin
E-Mail: kontakt@danielhesse.de
### 3. Datenerfassung auf dieser Website
**Bereitstellung der Website und Erstellung der Logfiles**
Beim Aufrufen unserer Website werden durch den auf Ihrem Endgerät zum Einsatz kommenden Browser automatisch Informationen an den Server unserer Website gesendet. Diese Informationen werden temporär in einem sogenannten Logfile gespeichert:
- Browsertyp und Browserversion
- Verwendetes Betriebssystem
- Referrer URL (die zuvor besuchte Seite)
- Hostname des zugreifenden Rechners
- Uhrzeit der Serveranfrage
- IP-Adresse
Die Rechtsgrundlage für die Datenverarbeitung ist Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse zur Gewährleistung eines reibungslosen Verbindungsaufbaus und der Systemsicherheit).
**Kontaktaufnahme per E-Mail**
Wenn Sie uns per E-Mail Anfragen zukommen lassen, werden Ihre Angaben inklusive der von Ihnen dort angegebenen Kontaktdaten zwecks Bearbeitung der Anfrage und für den Fall von Anschlussfragen bei uns gespeichert. Diese Daten geben wir nicht ohne Ihre Einwilligung weiter (Art. 6 Abs. 1 lit. b bzw. lit. f DSGVO).
### 4. Eingebundene Dienste Dritter
**Spotify Player & Externe Buch-Links**
Auf einigen Seiten sind Hörbeispiele bzw. Playlists des Dienstes Spotify eingebunden oder Links zu Buchhändlern (z. B. Amazon, Thalia) gesetzt. Wenn Sie diese externen Links anklicken oder mit externen Playern interagieren, werden Daten an die jeweiligen Anbieter übertragen.
### 5. Ihre Rechte als betroffene Person
Sie haben im Rahmen der geltenden gesetzlichen Bestimmungen jederzeit das Recht auf unentgeltliche Auskunft über Ihre gespeicherten personenbezogenen Daten, deren Herkunft und Empfänger und den Zweck der Datenverarbeitung und ggf. ein Recht auf Berichtigung, Sperrung oder Löschung dieser Daten.`
}
]
};

View file

@ -4,6 +4,8 @@ export interface Project {
status: string; // e.g., "Konzept", "In Arbeit", "Lektorat", "Abgeschlossen"
progress: number; // 0 to 100
description: string;
imageUrl?: string;
spotifyPlaylistId?: string;
}
export interface Book {
@ -14,12 +16,32 @@ export interface Book {
description: string;
publishedYear: number | string; // Accepts year (2026) or full date string (15.10.2026, Herbst 2026)
buyLink?: string;
ebookLink?: string;
paperbackLink?: string;
samplePdfUrl?: string;
seriesName?: string;
seriesNumber?: number | string;
spotifyPlaylistId?: string; // Just the playlist ID (e.g., "47R6ZAdk7Xf6M4i9mR0fP1")
genres?: string[];
isSpotlight?: boolean; // Highlighted as spotlight / new release
spotlightBadge?: string; // Optional custom badge text (e.g. "Neuerscheinung", "Vorbestellbar")
}
export interface CustomSectionLink {
label: string;
url: string;
}
export interface DownloadPublication {
id: string;
title: string;
description: string;
imageUrl?: string;
pdfUrl?: string;
epubUrl?: string;
publishedAt?: string;
}
export interface AuthorProfile {
name: string;
bio: string;
@ -30,6 +52,14 @@ export interface AuthorProfile {
books: Book[];
customSectionTitle?: string;
customSectionContent?: string;
customSectionLinks?: CustomSectionLink[];
contactEmail?: string;
instagramUrl?: string;
threadsUrl?: string;
discordUrl?: string;
downloads?: DownloadPublication[];
downloadsTitle?: string;
downloadsSubtitle?: string;
// Domain & Path Routing
customDomain?: string; // e.g., "annieslone.de, annie-slone.de"
@ -51,6 +81,9 @@ export interface AuthorProfile {
projectsTitle?: string;
projectsSubtitle?: string;
projectExcerptTitle?: string;
projectExcerptBadge?: string;
projectProgressLabel?: string;
booksTitle?: string;
booksSubtitle?: string;
footerText?: string;
@ -62,6 +95,21 @@ export interface AuthorProfile {
cardBgColor?: string;
fontFamily?: "sans" | "serif" | "mono";
heroBannerUrl?: string;
// Optional search/social metadata. Existing profiles work without these fields.
seoTitle?: string;
seoDescription?: string;
socialImageUrl?: string;
noIndex?: boolean;
}
export interface LegalDocument {
id: string;
title: string;
type: "impressum" | "privacy" | "custom";
content: string; // Markdown formatted text
updatedAt?: string;
assignedProfiles: ("scifi" | "erotica" | "clara" | "renee")[];
}
export interface AuthorData {
@ -69,6 +117,18 @@ export interface AuthorData {
erotica: AuthorProfile;
clara: AuthorProfile;
renee: AuthorProfile;
legalDocuments?: LegalDocument[];
// Additive storage metadata used for safe migrations and conflict detection.
schemaVersion?: number;
revision?: number;
}
export type PortfolioTheme = "cosmic" | "velvet" | "botanical" | "romance";
export interface PublicAuthorData {
profile: AuthorProfile;
legalDocuments: LegalDocument[];
theme: PortfolioTheme;
}
export interface AdminSettings {

335
tests/server.test.mjs Normal file
View file

@ -0,0 +1,335 @@
import test from "node:test";
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { once } from "node:events";
import { mkdtemp, rm } from "node:fs/promises";
import { createServer } from "node:net";
import { get as httpGet } from "node:http";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
const projectDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const serverEntry = path.join(projectDir, "dist", "server.cjs");
async function freePort() {
const server = createServer();
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address();
const port = typeof address === "object" && address ? address.port : 0;
server.close();
await once(server, "close");
return port;
}
function waitForServer(child) {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error("Testserver startete nicht rechtzeitig.")), 10_000);
const onData = (chunk) => {
if (chunk.toString().includes("Server running")) {
clearTimeout(timeout);
child.stdout.off("data", onData);
resolve();
}
};
child.stdout.on("data", onData);
child.once("exit", (code) => {
clearTimeout(timeout);
reject(new Error(`Testserver endete vorzeitig mit Code ${code}.`));
});
});
}
async function stopServer(child) {
if (child.exitCode !== null) return;
child.kill("SIGTERM");
await once(child, "close");
}
function getWithHost(baseUrl, host) {
return new Promise((resolve, reject) => {
const request = httpGet(baseUrl, { headers: { host } }, (response) => {
let body = "";
response.setEncoding("utf8");
response.on("data", (chunk) => { body += chunk; });
response.on("end", () => resolve({ status: response.statusCode, body }));
});
request.on("error", reject);
});
}
test("production hardening and public routing", async (t) => {
await t.test("production refuses to start without secrets", async () => {
const dataDir = await mkdtemp(path.join(tmpdir(), "author-missing-secrets-"));
const env = { ...process.env, NODE_ENV: "production", DATA_DIR: dataDir };
delete env.ADMIN_PASSWORD;
delete env.SESSION_SECRET;
const child = spawn(process.execPath, [serverEntry], { cwd: projectDir, env, stdio: ["ignore", "pipe", "pipe"] });
let stderr = "";
child.stderr.on("data", (chunk) => { stderr += chunk; });
const [code] = await once(child, "close");
assert.equal(code, 1);
assert.match(stderr, /ADMIN_PASSWORD, SESSION_SECRET/);
await rm(dataDir, { recursive: true, force: true });
});
await t.test("sessions, rate limits, domains and uploads work securely", async () => {
const dataDir = await mkdtemp(path.join(tmpdir(), "author-server-test-"));
const port = await freePort();
const baseUrl = `http://127.0.0.1:${port}`;
const child = spawn(process.execPath, [serverEntry], {
cwd: projectDir,
env: {
...process.env,
NODE_ENV: "production",
PORT: String(port),
DATA_DIR: dataDir,
ADMIN_PASSWORD: "test-password-12345",
SESSION_SECRET: "test-session-secret-12345678901234567890",
},
stdio: ["ignore", "pipe", "pipe"],
});
try {
await waitForServer(child);
let response = await fetch(`${baseUrl}/api/admin/session`);
assert.equal(response.status, 401);
response = await fetch(`${baseUrl}/api/admin/login`, {
method: "POST",
headers: { "content-type": "application/json", origin: baseUrl },
body: JSON.stringify({ password: "test-password-12345" }),
});
assert.equal(response.status, 200);
const setCookie = response.headers.get("set-cookie") || "";
assert.match(setCookie, /HttpOnly/i);
assert.match(setCookie, /Secure/i);
assert.match(setCookie, /SameSite=Strict/i);
assert.doesNotMatch(setCookie, /test-password/);
const cookie = setCookie.split(";")[0];
response = await fetch(`${baseUrl}/api/admin/session`, { headers: { cookie } });
assert.equal(response.status, 200);
response = await fetch(`${baseUrl}/api/admin/author-data`);
assert.equal(response.status, 401);
response = await fetch(`${baseUrl}/api/admin/author-data`, { headers: { cookie } });
assert.equal(response.status, 200);
const adminData = await response.json();
adminData.erotica.contactEmail = "kontakt@example.test";
adminData.erotica.instagramUrl = "https://instagram.com/example-author";
adminData.erotica.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 = [
{ label: "Zum befreundeten Pseudonym", url: "https://example-pseudonym.test/" },
];
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, 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`, {
method: "POST",
headers: { "content-type": "application/json", origin: baseUrl, cookie },
body: JSON.stringify({
profileKey: "erotica",
profileData: { ...adminData.erotica, customSectionLinks: [{ label: "Unsicher", url: "javascript:alert(1)" }] },
expectedRevision: firstSave.revision,
}),
});
assert.equal(response.status, 400);
const publicResponse = await getWithHost(`${baseUrl}/api/author-data?path=/clara`, "annieslone.de");
assert.equal(publicResponse.status, 200);
const publicData = JSON.parse(publicResponse.body);
assert.deepEqual(Object.keys(publicData).sort(), ["legalDocuments", "profile", "theme"]);
assert.equal(publicData.profile.name, "Annie Slone");
assert.equal(publicData.theme, "velvet");
assert.equal(publicData.profile.customDomain, undefined);
assert.equal(publicData.profile.customPath, undefined);
assert.deepEqual(publicData.profile.customSectionLinks, [
{ label: "Zum befreundeten Pseudonym", url: "https://example-pseudonym.test/" },
]);
assert.equal(publicData.profile.contactEmail, "kontakt@example.test");
assert.equal(publicData.profile.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("Renee Heart"), false);
assert.equal(publicResponse.body.includes("Daniel Hesse"), false);
let routedPage = await getWithHost(`${baseUrl}/`, "annieslone.de");
assert.match(routedPage.body, /<title>Annie Slone/);
routedPage = await getWithHost(`${baseUrl}/`, "www.annieslone.de");
assert.match(routedPage.body, /<title>Annie Slone/);
routedPage = await getWithHost(`${baseUrl}/`, "blog.annieslone.de");
assert.match(routedPage.body, /<title>Daniel Hesse/);
const validPng = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=";
const upload = (fileName, base64Data) => fetch(`${baseUrl}/api/admin/upload-file`, {
method: "POST",
headers: { "content-type": "application/json", origin: baseUrl, cookie },
body: JSON.stringify({ fileName, base64Data }),
});
response = await upload("cover.png", validPng);
assert.equal(response.status, 201);
const uploaded = await response.json();
assert.match(uploaded.url, /^\/uploads\/[a-z0-9-]+\.png$/);
assert.equal(uploaded.mimeType, "image/png");
response = await upload("fake.png", "data:image/png;base64,SGVsbG8=");
assert.equal(response.status, 415);
response = await upload("wrong.jpg", validPng);
assert.equal(response.status, 415);
const validPdf = `data:application/pdf;base64,${Buffer.from("%PDF-1.4\n%%EOF").toString("base64")}`;
response = await fetch(`${baseUrl}/api/admin/upload-sample`, {
method: "POST",
headers: { "content-type": "application/json", origin: baseUrl, cookie },
body: JSON.stringify({ fileName: "leseprobe.pdf", base64Data: validPdf }),
});
assert.equal(response.status, 201);
const sample = await response.json();
assert.match(sample.url, /^\/downloads\/[a-z0-9-]+\.pdf$/);
response = await fetch(`${baseUrl}${sample.url}`);
assert.equal(response.status, 200);
assert.match(response.headers.get("content-disposition") || "", /attachment/);
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}`);
assert.equal(response.status, 200);
assert.equal(response.headers.get("x-content-type-options"), "nosniff");
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 } });
assert.equal(response.status, 200);
response = await fetch(`${baseUrl}/api/admin/session`, { headers: { cookie } });
assert.equal(response.status, 401);
const attempts = [];
for (let index = 0; index < 6; index += 1) {
const attempt = await fetch(`${baseUrl}/api/admin/login`, {
method: "POST",
headers: { "content-type": "application/json", origin: baseUrl },
body: JSON.stringify({ password: "wrong-password" }),
});
attempts.push(attempt.status);
}
assert.deepEqual(attempts, [401, 401, 401, 401, 401, 429]);
response = await fetch(`${baseUrl}/`);
assert.equal(response.headers.get("x-frame-options"), "DENY");
assert.ok(response.headers.get("content-security-policy"));
response = await fetch(`${baseUrl}/does-not-exist`);
assert.equal(response.status, 404);
assert.match(response.headers.get("x-robots-tag") || "", /noindex/);
const notFoundBody = await response.text();
assert.match(notFoundBody, /Seite nicht gefunden/);
assert.equal(notFoundBody.includes("Daniel Hesse"), false);
} finally {
await stopServer(child);
await rm(dataDir, { recursive: true, force: true });
}
});
});