Compare commits
17 commits
codex/pers
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a69b81e3d7 | ||
|
|
b0c6f8d009 | ||
|
|
85768d76cd | ||
|
|
09c4b749f2 | ||
|
|
b9e9a8666e | ||
|
|
8d3980d654 | ||
|
|
040854ff1b | ||
|
|
76957c8284 | ||
|
|
2f58d6cb67 | ||
|
|
18639de047 | ||
|
|
67570d9b1f | ||
|
|
db31146199 | ||
|
|
b152e5bf4b | ||
|
|
463f98d078 | ||
|
|
cf3acf0906 | ||
|
|
9a37a6e937 | ||
|
|
9412b39919 |
21 changed files with 7474 additions and 2093 deletions
34
.forgejo/workflows/ci.yml
Normal file
34
.forgejo/workflows/ci.yml
Normal 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 .
|
||||||
|
|
@ -6,8 +6,8 @@ WORKDIR /app
|
||||||
# Copy dependency manifests
|
# Copy dependency manifests
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
# Install all dependencies
|
# Install exactly the dependency versions recorded in package-lock.json
|
||||||
RUN npm install
|
RUN npm ci
|
||||||
|
|
||||||
# Copy full application codebase
|
# Copy full application codebase
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
@ -23,8 +23,8 @@ WORKDIR /app
|
||||||
# Copy configuration files
|
# Copy configuration files
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
# Install only production dependencies (Express, dotenv, @google/genai)
|
# Install exactly the locked production dependencies
|
||||||
RUN npm install --omit=dev
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
# Copy compiled build resources from builder stage
|
# Copy compiled build resources from builder stage
|
||||||
COPY --from=builder /app/dist ./dist
|
COPY --from=builder /app/dist ./dist
|
||||||
|
|
|
||||||
313
README.md
313
README.md
|
|
@ -1,120 +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, mehrere eigenständige Autorenprofile mit individuellen Themen, Biografien, Schreibprojekten und Bücherregalen über eine einzige, passwortgeschützte Administrationsoberfläche zu pflegen.
|
Eine selbst gehostete Portfolio-Anwendung für mehrere voneinander getrennte Autorenprofile. Inhalte, Bücher, laufende Projekte, Designs, SEO-Daten und Rechtstexte werden über eine gemeinsame, passwortgeschützte Administrationsoberfläche gepflegt.
|
||||||
|
|
||||||
---
|
Technisch besteht die Anwendung aus einem React-/Tailwind-Frontend und einem Express-Backend. Sie kann als einzelner Docker-Container betrieben werden und benötigt keine externe Datenbank.
|
||||||
|
|
||||||
## 🛠️ Warum kein klassischer LAMP-Stack? (Unser Vorschlag)
|
## Funktionsumfang
|
||||||
|
|
||||||
Bisher liefen klassische Portfolios oft auf einem standardmäßigen **LAMP-Stack** (Linux, Apache, MySQL, PHP). Für diese modernisierte Website schlagen wir einen **Node.JS + Docker-Stack** vor. Hier ist der Grund, warum das für Ihre Bedürfnisse die deutlich bessere Option ist:
|
- Mehrere unabhängig konfigurierte Autorenprofile mit eigenen Domains und Themes
|
||||||
|
- Biografie, Schlagworte, individuelle Texte und optionale Zusatzsektion 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**.
|
## Trennung der Profile
|
||||||
2. **Blitzschnelle, reaktive Benutzeroberfläche (SPA):** Das Frontend nutzt modernste Webtechnologie (React & Tailwind CSS). Seitenwechsel und Buchdetails laden augenblicklich im Browser des Nutzers, ohne das klassische, träge Neuladen von PHP-Seiten.
|
|
||||||
3. **Einfachheit bei Datensicherungen (Backups):** Anstatt komplexe MySQL-Dumps durchzuführen, speichert unsere Anwendung alle Buchtitel, Projekte, Cover und Biografien in einer schlanken JSON-Datenbankdatei (`/data/database.json`). Ein Backup lässt sich durch einfaches Kopieren dieser Datei anfertigen!
|
|
||||||
4. **KI-Unterstützung integriert:** Der integrierte **Gemini-Klappentext-Assistent** läuft nahtlos und sicher über das Node-Backend (ohne API-Schlüssel im Webbrowser zu exponieren).
|
|
||||||
|
|
||||||
---
|
Die öffentliche Seite erhält ausschließlich die Daten des Profils, das zur aufgerufenen Domain gehört. Andere Profile, deren Domains und interne Pfadkonfigurationen werden von der öffentlichen API nicht ausgeliefert.
|
||||||
|
|
||||||
## 🌌 Die Trennung der Profile
|
Im Produktionsmodus gilt der Hostname als Trennlinie:
|
||||||
|
|
||||||
Das System ist von Grund auf so strukturiert, dass Besucher der Website **keine Verbindung** zwischen den gepflegten Identitäten herstellen müssen. Der Code enthält derzeit vier getrennt auflösbare Profile; Domains und Pfade können im Adminbereich gepflegt werden. Unter anderem:
|
- Konfigurierte Domains reagieren nur auf die exakte Domain und deren `www`-Variante.
|
||||||
- **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.
|
- Ein URL-Pfad oder Query-Parameter kann nicht zu einem anderen Profil wechseln.
|
||||||
- **Pseudonym (Annie Slone — Sinnliche Literatur):** Erreichbar unter anderem über den Pfad `/sensual-moments`. Das Profil verwendet ein warmes, edles Thema mit klassischer und anspruchsvoller Serif-Asthetik. Es gibt keinerlei gegenseitige Verlinkungen!
|
- 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
|
Wichtige Verzeichnisse und Dateien:
|
||||||
Stellen Sie sicher, dass sich folgende Dateien im gleichen Ordner auf Ihrem Server befinden:
|
|
||||||
- `Dockerfile`
|
|
||||||
- `docker-compose.yml`
|
|
||||||
- Der gesamte Code-Ordner
|
|
||||||
|
|
||||||
### 2. Konfiguration anpassen
|
```text
|
||||||
Kopieren Sie `.env.example` nach `.env` und tragen Sie dort die Geheimnisse ein. Die `.env`-Datei wird nicht eingecheckt:
|
src/App.tsx Öffentliche/Admin-Routen und Datenabruf
|
||||||
```dotenv
|
src/components/PortfolioPage.tsx Gemeinsame öffentliche Portfolioseite
|
||||||
ADMIN_PASSWORD=IhrSicheresLieblingsPasswort123
|
src/components/AdminPanel.tsx Administrationsarbeitsbereich
|
||||||
SESSION_SECRET=EineUnabhaengigeZufaelligeZeichenfolgeMitMindestens32Zeichen
|
src/components/admin/ Ausgelagerte Admin-Komponenten
|
||||||
GEMINI_API_KEY=Ihr_Gemini_API_Schluessel
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
Der Admin-Login erzeugt eine auf 24 Stunden begrenzte, serverseitige Sitzung in einem `HttpOnly`-, `Secure`- und `SameSite=Strict`-Cookie. Ein Container-Neustart beendet aktive Sitzungen. Das Passwort selbst wird nicht im Browser gespeichert. Ohne `ADMIN_PASSWORD` und `SESSION_SECRET` startet die Anwendung im Produktionsmodus bewusst nicht.
|
## Docker-Inbetriebnahme
|
||||||
|
|
||||||
|
### 1. Konfiguration anlegen
|
||||||
|
|
||||||
|
Kopiere `.env.example` nach `.env` und ersetze die Beispielwerte:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
ADMIN_PASSWORD=EinLangesZufaelligesAdminPasswort
|
||||||
|
SESSION_SECRET=EineUnabhaengigeZufaelligeZeichenfolgeMitMindestens32Zeichen
|
||||||
|
|
||||||
|
# Optional: aktiviert den Klappentext-Assistenten
|
||||||
|
GEMINI_API_KEY=
|
||||||
|
|
||||||
|
# Nur setzen, wenn genau ein vertrauenswürdiger Reverse Proxy vorgeschaltet ist
|
||||||
|
# TRUST_PROXY=1
|
||||||
|
```
|
||||||
|
|
||||||
|
Für den Produktionsbetrieb gelten folgende Mindestanforderungen:
|
||||||
|
|
||||||
|
- `ADMIN_PASSWORD`: gesetzt und mindestens 12 Zeichen lang
|
||||||
|
- `SESSION_SECRET`: gesetzt, unabhängig vom Passwort und mindestens 32 Zeichen lang
|
||||||
|
|
||||||
|
Ohne gültige Werte startet der Server im Produktionsmodus bewusst nicht. Geheimnisse gehören ausschließlich in `.env` beziehungsweise in die Secret-Verwaltung der Betriebsumgebung und nicht in `docker-compose.yml` oder Git.
|
||||||
|
|
||||||
|
### 2. Container starten
|
||||||
|
|
||||||
### 3. Container starten
|
|
||||||
Führen Sie im entsprechenden Verzeichnis folgenden Befehl aus:
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d --build
|
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:
|
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.
|
||||||
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.
|
|
||||||
|
|
||||||
Wenn genau ein vertrauenswürdiger Reverse Proxy vor dem Container sitzt und Port 3000 nicht direkt aus dem Internet erreichbar ist, setzen Sie zusätzlich `TRUST_PROXY=1` in `.env`. So verwendet das Login-Limit die ursprüngliche Client-IP. Bei direkter Veröffentlichung des Containerports darf diese Option nicht aktiviert werden.
|
Wenn genau ein vertrauenswürdiger Proxy vorgeschaltet und Port 3000 nicht direkt öffentlich erreichbar ist, kann `TRUST_PROXY=1` gesetzt werden. Das sorgt dafür, dass das Login-Limit die ursprüngliche Client-IP verwendet. Bei direkter Veröffentlichung des Ports darf diese Option nicht aktiviert werden.
|
||||||
|
|
||||||
---
|
## Installation ohne Docker
|
||||||
|
|
||||||
## 💾 Manuelle Installation ohne Docker (Alternativ)
|
Voraussetzung ist eine unterstützte Node.js-Version (empfohlen: Node.js 20 oder neuer).
|
||||||
|
|
||||||
Sollten Sie die Software direkt auf Ihrem Server (ohne Docker) starten wollen:
|
|
||||||
|
|
||||||
1. **Voraussetzung:** Installieren Sie **Node.js** (v18 oder neuer) auf Ihrem Linux- oder Windows-Server.
|
|
||||||
2. **Abhängigkeiten installieren:**
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
3. **Produktions-Build ausführen:**
|
|
||||||
```bash
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
4. **Umgebungsvariablen setzen:**
|
|
||||||
Erstellen Sie eine `.env`-Datei oder exportieren Sie diese im Terminal:
|
|
||||||
```bash
|
|
||||||
export ADMIN_PASSWORD="IhrSicheresPasswort"
|
|
||||||
export SESSION_SECRET="EineUnabhaengigeZufaelligeZeichenfolgeMitMindestens32Zeichen"
|
|
||||||
export GEMINI_API_KEY="Ihr_Gemini_API_Schlüssel"
|
|
||||||
```
|
|
||||||
5. **Starten:**
|
|
||||||
```bash
|
|
||||||
npm start
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🧱 Datensicherung / Backups
|
|
||||||
|
|
||||||
Alle Ihre Inhalte (Biografien, Projektfortschritte und Bücherregal-Einträge) liegen als lesbares JSON in Ihrem Projektordner unter:
|
|
||||||
`./data/database.json`
|
|
||||||
|
|
||||||
Da wir in der `docker-compose.yml` ein **lokales Volume** gemountet haben (`./data:/app/data`), wird diese Datei direkt auf die Festplatte Ihres Servers geschrieben.
|
|
||||||
- **Backup erstellen:** Sichern Sie einfach die Datei `./data/database.json`.
|
|
||||||
- **Wiederherstellen:** Platzieren Sie eine gesicherte `database.json` in den `./data/`-Ordner vor dem Starten des Containers.
|
|
||||||
|
|
||||||
Beim Ergänzen neuer Datenfelder legt die Anwendung vor der Migration automatisch eine unveränderte Sicherung unter `./data/backups/` an. Schreibvorgänge erfolgen atomar und werden nacheinander verarbeitet, damit die Datei bei parallelen Änderungen nicht teilweise überschrieben wird. Das Docker-Volume ersetzt dennoch kein externes Backup des Hostsystems.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Betrieb & SEO
|
|
||||||
|
|
||||||
- `/health/live` zeigt an, ob der Prozess läuft.
|
|
||||||
- `/health/ready` zeigt an, ob die Daten erfolgreich geladen wurden und die Anwendung bereit ist.
|
|
||||||
- Seitentitel, Beschreibung, Canonical URL, Open-Graph-Daten und strukturierte Personendaten werden passend zur aufgerufenen Autorendomain serverseitig ausgegeben.
|
|
||||||
- `/robots.txt` und `/sitemap.xml` werden ebenfalls profilabhängig erzeugt; der Adminbereich ist mit `noindex` gekennzeichnet.
|
|
||||||
- Optionale SEO-Felder können später je Profil gepflegt werden. Ohne sie werden die Angaben rückwärtskompatibel aus den vorhandenen Profildaten abgeleitet.
|
|
||||||
|
|
||||||
### Bild-Uploads
|
|
||||||
|
|
||||||
Der Adminbereich akzeptiert JPEG, PNG, WebP, GIF und AVIF bis maximal 8 MB. Der Server prüft den tatsächlichen Dateikopf unabhängig von Dateiname und Browserangabe, erzeugt einen zufälligen nicht überschreibbaren Namen und begrenzt Uploads auf 30 Versuche pro Sitzung und Stunde. SVG, HTML und andere aktive Dateiformate werden weder angenommen noch aus dem Uploadverzeichnis ausgeliefert.
|
|
||||||
|
|
||||||
### Qualitätschecks
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run typecheck # TypeScript ohne Ausgabe prüfen
|
npm install
|
||||||
npm test # Produktionsbuild und Integrationstests
|
npm run build
|
||||||
npm run check # vollständiger Check aus TypeScript, Build und Tests
|
NODE_ENV=production \
|
||||||
|
ADMIN_PASSWORD="EinLangesZufaelligesAdminPasswort" \
|
||||||
|
SESSION_SECRET="EineUnabhaengigeZufaelligeZeichenfolgeMitMindestens32Zeichen" \
|
||||||
|
npm start
|
||||||
```
|
```
|
||||||
|
|
||||||
Die Integrationstests verwenden ausschließlich temporäre Datenverzeichnisse und einen kurzlebigen lokalen Server. Sie prüfen Produktions-Secrets, Admin-Sitzung und Logout, Origin-Schutz, Login-Limit, exaktes Domain-Routing sowie gültige und manipulierte Bild-Uploads.
|
Optional können `GEMINI_API_KEY`, `TRUST_PROXY`, `PORT` und `DATA_DIR` ergänzt werden. Standardmäßig lauscht die Anwendung auf Port `3000` und verwendet `./data` als Datenverzeichnis.
|
||||||
|
|
||||||
|
Für die lokale Entwicklung genügt:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Die im Entwicklungsmodus vorhandenen Fallback-Secrets sind ausschließlich für lokale Entwicklung gedacht und werden in Produktion nicht akzeptiert.
|
||||||
|
|
||||||
|
## Admin-Authentifizierung und Schutzmaßnahmen
|
||||||
|
|
||||||
|
Das Admin-Passwort wird nur beim Login übertragen und weder als Bearer-Token zurückgegeben noch im Browser gespeichert. Nach erfolgreicher Anmeldung wird eine zufällige, serverseitig verwaltete Sitzung erzeugt.
|
||||||
|
|
||||||
|
- Sitzungsdauer: 24 Stunden
|
||||||
|
- Cookie: `HttpOnly`, `SameSite=Strict`, in Produktion zusätzlich `Secure`
|
||||||
|
- Sitzungen liegen nur im Arbeitsspeicher und enden spätestens bei einem Server-/Container-Neustart
|
||||||
|
- 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.
|
||||||
|
|
|
||||||
5607
package-lock.json
generated
Normal file
5607
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
428
server.ts
428
server.ts
|
|
@ -5,7 +5,7 @@ import { createHmac, randomBytes, timingSafeEqual } from "crypto";
|
||||||
import dotenv from "dotenv";
|
import dotenv from "dotenv";
|
||||||
import { GoogleGenAI } from "@google/genai";
|
import { GoogleGenAI } from "@google/genai";
|
||||||
import { defaultAuthorData } from "./src/defaultData.js";
|
import { defaultAuthorData } from "./src/defaultData.js";
|
||||||
import { AuthorData, AuthorProfile } from "./src/types.js";
|
import { AuthorData, AuthorProfile, CustomSectionLink, DownloadPublication, PortfolioTheme, PublicAuthorData } from "./src/types.js";
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
|
|
@ -16,6 +16,7 @@ const PORT = Number.parseInt(process.env.PORT || "3000", 10);
|
||||||
const DATA_DIR = path.resolve(process.env.DATA_DIR || path.join(process.cwd(), "data"));
|
const DATA_DIR = path.resolve(process.env.DATA_DIR || path.join(process.cwd(), "data"));
|
||||||
const DATA_FILE = path.join(DATA_DIR, "database.json");
|
const DATA_FILE = path.join(DATA_DIR, "database.json");
|
||||||
const BACKUP_DIR = path.join(DATA_DIR, "backups");
|
const BACKUP_DIR = path.join(DATA_DIR, "backups");
|
||||||
|
const DOWNLOAD_DIR = path.join(DATA_DIR, "downloads");
|
||||||
const CURRENT_SCHEMA_VERSION = 2;
|
const CURRENT_SCHEMA_VERSION = 2;
|
||||||
const LEGACY_DEFAULT_IMAGE_URLS = new Set([
|
const LEGACY_DEFAULT_IMAGE_URLS = new Set([
|
||||||
"https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?auto=format&fit=crop&q=80&w=300",
|
"https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?auto=format&fit=crop&q=80&w=300",
|
||||||
|
|
@ -43,7 +44,10 @@ const GEMINI_MAX_REQUESTS = 20;
|
||||||
const UPLOAD_WINDOW_MS = 60 * 60 * 1000;
|
const UPLOAD_WINDOW_MS = 60 * 60 * 1000;
|
||||||
const UPLOAD_MAX_REQUESTS = 30;
|
const UPLOAD_MAX_REQUESTS = 30;
|
||||||
const MAX_UPLOAD_BYTES = 8 * 1024 * 1024;
|
const MAX_UPLOAD_BYTES = 8 * 1024 * 1024;
|
||||||
|
const MAX_PDF_BYTES = 10 * 1024 * 1024;
|
||||||
const SAFE_UPLOAD_NAME = /^(?!\.)[a-zA-Z0-9._-]+\.(?:jpe?g|png|webp|gif|avif)$/i;
|
const SAFE_UPLOAD_NAME = /^(?!\.)[a-zA-Z0-9._-]+\.(?:jpe?g|png|webp|gif|avif)$/i;
|
||||||
|
const SAFE_PDF_NAME = /^(?!\.)[a-zA-Z0-9._-]+\.pdf$/i;
|
||||||
|
const SAFE_DOWNLOAD_NAME = /^(?!\.)[a-zA-Z0-9._-]+\.(?:pdf|epub)$/i;
|
||||||
|
|
||||||
interface SessionRecord {
|
interface SessionRecord {
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
|
|
@ -54,6 +58,8 @@ interface RateRecord {
|
||||||
resetAt: number;
|
resetAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class RevisionConflictError extends Error {}
|
||||||
|
|
||||||
const sessions = new Map<string, SessionRecord>();
|
const sessions = new Map<string, SessionRecord>();
|
||||||
const loginFailures = new Map<string, RateRecord>();
|
const loginFailures = new Map<string, RateRecord>();
|
||||||
const geminiRequests = new Map<string, RateRecord>();
|
const geminiRequests = new Map<string, RateRecord>();
|
||||||
|
|
@ -69,6 +75,12 @@ setInterval(() => {
|
||||||
|
|
||||||
const profileKeys = ["scifi", "erotica", "clara", "renee"] as const;
|
const profileKeys = ["scifi", "erotica", "clara", "renee"] as const;
|
||||||
type ProfileKey = typeof profileKeys[number];
|
type ProfileKey = typeof profileKeys[number];
|
||||||
|
const publicThemes: Record<ProfileKey, PortfolioTheme> = {
|
||||||
|
scifi: "cosmic",
|
||||||
|
erotica: "velvet",
|
||||||
|
clara: "botanical",
|
||||||
|
renee: "romance",
|
||||||
|
};
|
||||||
|
|
||||||
function isAuthorData(value: unknown): value is AuthorData {
|
function isAuthorData(value: unknown): value is AuthorData {
|
||||||
if (!value || typeof value !== "object") return false;
|
if (!value || typeof value !== "object") return false;
|
||||||
|
|
@ -113,6 +125,7 @@ async function createMigrationBackup(rawData: string): Promise<void> {
|
||||||
async function initDatabase(): Promise<AuthorData> {
|
async function initDatabase(): Promise<AuthorData> {
|
||||||
await fs.mkdir(DATA_DIR, { recursive: true });
|
await fs.mkdir(DATA_DIR, { recursive: true });
|
||||||
await fs.mkdir(path.join(DATA_DIR, "uploads"), { recursive: true });
|
await fs.mkdir(path.join(DATA_DIR, "uploads"), { recursive: true });
|
||||||
|
await fs.mkdir(DOWNLOAD_DIR, { recursive: true });
|
||||||
try {
|
try {
|
||||||
const existingData = await fs.readFile(DATA_FILE, "utf-8");
|
const existingData = await fs.readFile(DATA_FILE, "utf-8");
|
||||||
if (!existingData.trim()) {
|
if (!existingData.trim()) {
|
||||||
|
|
@ -253,7 +266,7 @@ app.use((_req, res, next) => {
|
||||||
}
|
}
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
app.use(express.json({ limit: "12mb" }));
|
app.use(express.json({ limit: "15mb" }));
|
||||||
|
|
||||||
// Initialize Google GenAI if API key exists
|
// Initialize Google GenAI if API key exists
|
||||||
const getGeminiClient = () => {
|
const getGeminiClient = () => {
|
||||||
|
|
@ -420,19 +433,24 @@ function profileForRequest(hostname: string, pathname: string): ProfileKey {
|
||||||
scifi: ["hesse-sf.de", "danielhesse.de"],
|
scifi: ["hesse-sf.de", "danielhesse.de"],
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const key of profileKeys) {
|
|
||||||
const configuredPaths = (dbCache[key].customPath || "").split(",").map((item) => item.trim().toLowerCase()).filter(Boolean);
|
|
||||||
for (const value of [...configuredPaths, ...standardPaths[key]]) {
|
|
||||||
const prefix = value.startsWith("/") ? value : `/${value}`;
|
|
||||||
if (pathname === prefix || pathname.startsWith(`${prefix}/`)) return key;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const key of profileKeys) {
|
for (const key of profileKeys) {
|
||||||
const configuredDomains = (dbCache[key].customDomain || "").split(",").map(cleanDomain).filter(Boolean);
|
const configuredDomains = (dbCache[key].customDomain || "").split(",").map(cleanDomain).filter(Boolean);
|
||||||
for (const domain of [...configuredDomains, ...standardDomains[key]]) {
|
for (const domain of [...configuredDomains, ...standardDomains[key]]) {
|
||||||
if (matchesConfiguredDomain(hostname, domain)) return key;
|
if (matchesConfiguredDomain(hostname, domain)) return key;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Path previews are a local development convenience only. In production the
|
||||||
|
// hostname is the security boundary, so a path can never reveal another site.
|
||||||
|
if (process.env.NODE_ENV !== "production") {
|
||||||
|
for (const key of profileKeys) {
|
||||||
|
const configuredPaths = (dbCache[key].customPath || "").split(",").map((item) => item.trim().toLowerCase()).filter(Boolean);
|
||||||
|
for (const value of [...configuredPaths, ...standardPaths[key]]) {
|
||||||
|
const prefix = value.startsWith("/") ? value : `/${value}`;
|
||||||
|
if (pathname === prefix || pathname.startsWith(`${prefix}/`)) return key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return "scifi";
|
return "scifi";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -451,6 +469,148 @@ function absoluteUrl(value: string | undefined, origin: string): string | undefi
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeRegExp(value: string): string {
|
||||||
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isolateLegalContent(content: string, activeKey: ProfileKey): string {
|
||||||
|
let isolated = content;
|
||||||
|
for (const key of profileKeys) {
|
||||||
|
if (key === activeKey) continue;
|
||||||
|
const profile = dbCache[key];
|
||||||
|
const identifiers = [
|
||||||
|
profile.name,
|
||||||
|
profile.heroTitle,
|
||||||
|
...(profile.customDomain || "").split(",").map((domain) => cleanDomain(domain)),
|
||||||
|
...(profile.customPath || "").split(",").map((pathValue) => pathValue.trim()),
|
||||||
|
...profile.books.map((book) => book.title),
|
||||||
|
...profile.projects.map((project) => project.title),
|
||||||
|
].filter((identifier) => identifier.length >= 4);
|
||||||
|
for (const identifier of identifiers) {
|
||||||
|
isolated = isolated.replace(new RegExp(escapeRegExp(identifier), "gi"), "Autorinnen- oder Autorenprofil");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return isolated;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCustomSectionLinks(value: unknown): CustomSectionLink[] | null {
|
||||||
|
if (value === undefined) return [];
|
||||||
|
if (!Array.isArray(value) || value.length > 3) return null;
|
||||||
|
|
||||||
|
const links: CustomSectionLink[] = [];
|
||||||
|
for (const candidate of value) {
|
||||||
|
if (!candidate || typeof candidate !== "object") return null;
|
||||||
|
const { label, url } = candidate as Record<string, unknown>;
|
||||||
|
if (typeof label !== "string" || typeof url !== "string" || label.length > 80 || url.length > 2048) return null;
|
||||||
|
const trimmedLabel = label.trim();
|
||||||
|
const trimmedUrl = url.trim();
|
||||||
|
if (!trimmedLabel && !trimmedUrl) continue;
|
||||||
|
if (!trimmedLabel || !trimmedUrl) continue;
|
||||||
|
try {
|
||||||
|
const parsedUrl = new URL(trimmedUrl);
|
||||||
|
if (parsedUrl.protocol !== "https:" && parsedUrl.protocol !== "http:") return null;
|
||||||
|
links.push({ label: trimmedLabel, url: parsedUrl.toString() });
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return links;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSafeContentUrl(value: unknown, allowSpotifyId = false): boolean {
|
||||||
|
if (value === undefined || value === "") return true;
|
||||||
|
if (typeof value !== "string" || value.length > 2048) return false;
|
||||||
|
if (allowSpotifyId && /^[a-zA-Z0-9]+$/.test(value)) return true;
|
||||||
|
if (value.startsWith("/uploads/")) return SAFE_UPLOAD_NAME.test(path.basename(value));
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
return url.protocol === "https:" || url.protocol === "http:";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateProfile(profile: unknown): string | null {
|
||||||
|
if (!profile || typeof profile !== "object") return "Profildaten fehlen.";
|
||||||
|
const value = profile as AuthorProfile;
|
||||||
|
if (!Array.isArray(value.books) || value.books.length > 500) return "Die Bücherliste ist ungültig oder zu groß.";
|
||||||
|
if (!Array.isArray(value.projects) || value.projects.length > 200) return "Die Projektliste ist ungültig oder zu groß.";
|
||||||
|
if (value.downloads !== undefined && (!Array.isArray(value.downloads) || value.downloads.length > 200)) return "Die Downloadliste ist ungültig oder zu groß.";
|
||||||
|
const requiredStrings: Array<[unknown, string, number]> = [
|
||||||
|
[value.name, "Name", 200], [value.bio, "Biografie", 50_000],
|
||||||
|
[value.heroTitle, "Hero-Titel", 500], [value.heroSubtitle, "Hero-Untertitel", 500],
|
||||||
|
];
|
||||||
|
for (const [field, label, maxLength] of requiredStrings) {
|
||||||
|
if (typeof field !== "string" || field.length > maxLength) return `${label} ist ungültig oder zu lang.`;
|
||||||
|
}
|
||||||
|
if (!isSafeContentUrl(value.avatarUrl) || !isSafeContentUrl(value.heroBannerUrl) || !isSafeContentUrl(value.socialImageUrl)) {
|
||||||
|
return "Mindestens eine Bild-URL ist ungültig.";
|
||||||
|
}
|
||||||
|
if (value.contactEmail && (value.contactEmail.length > 320 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.contactEmail))) return "Die Kontakt-E-Mail-Adresse ist ungültig.";
|
||||||
|
if (!isSafeContentUrl(value.instagramUrl) || !isSafeContentUrl(value.threadsUrl) || !isSafeContentUrl(value.discordUrl)) return "Mindestens ein Social-Media-Link ist ungültig.";
|
||||||
|
if (value.fontFamily && !["sans", "serif", "mono"].includes(value.fontFamily)) return "Die Schriftart ist ungültig.";
|
||||||
|
for (const project of value.projects) {
|
||||||
|
if (!project || typeof project.id !== "string" || typeof project.title !== "string" || project.title.length > 500 ||
|
||||||
|
typeof project.description !== "string" || project.description.length > 100_000 ||
|
||||||
|
!Number.isFinite(project.progress) || project.progress < 0 || project.progress > 100 ||
|
||||||
|
!isSafeContentUrl(project.imageUrl) || !isSafeContentUrl(project.spotifyPlaylistId, true)) {
|
||||||
|
return "Mindestens ein Projekt enthält ungültige Werte.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const book of value.books) {
|
||||||
|
if (!book || typeof book.id !== "string" || typeof book.title !== "string" || book.title.length > 500 ||
|
||||||
|
typeof book.description !== "string" || book.description.length > 100_000 ||
|
||||||
|
!isSafeContentUrl(book.coverUrl) || !isSafeContentUrl(book.buyLink) || !isSafeContentUrl(book.ebookLink) || !isSafeContentUrl(book.paperbackLink) ||
|
||||||
|
(book.samplePdfUrl !== undefined && book.samplePdfUrl !== "" && (typeof book.samplePdfUrl !== "string" || !book.samplePdfUrl.startsWith("/downloads/") || !SAFE_PDF_NAME.test(path.basename(book.samplePdfUrl)))) ||
|
||||||
|
!isSafeContentUrl(book.spotifyPlaylistId, true) ||
|
||||||
|
(book.seriesName !== undefined && (typeof book.seriesName !== "string" || book.seriesName.length > 300)) ||
|
||||||
|
(book.seriesNumber !== undefined && book.seriesNumber !== "" && (!Number.isFinite(Number(book.seriesNumber)) || Number(book.seriesNumber) < 0 || Number(book.seriesNumber) > 999)) ||
|
||||||
|
(book.genres !== undefined && (!Array.isArray(book.genres) || book.genres.length > 20 || book.genres.some((genre) => typeof genre !== "string" || genre.length > 100)))) {
|
||||||
|
return "Mindestens ein Buch enthält ungültige Werte.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const download of value.downloads || []) {
|
||||||
|
if (!download || typeof download.id !== "string" || download.id.length > 200 ||
|
||||||
|
typeof download.title !== "string" || !download.title.trim() || download.title.length > 500 ||
|
||||||
|
typeof download.description !== "string" || download.description.length > 50_000 ||
|
||||||
|
!isSafeContentUrl(download.imageUrl) ||
|
||||||
|
(download.pdfUrl !== undefined && download.pdfUrl !== "" && (typeof download.pdfUrl !== "string" || !download.pdfUrl.startsWith("/downloads/") || !SAFE_PDF_NAME.test(path.basename(download.pdfUrl)))) ||
|
||||||
|
(download.epubUrl !== undefined && download.epubUrl !== "" && (typeof download.epubUrl !== "string" || !download.epubUrl.startsWith("/downloads/") || !/\.epub$/i.test(path.basename(download.epubUrl)))) ||
|
||||||
|
(download.publishedAt !== undefined && (typeof download.publishedAt !== "string" || download.publishedAt.length > 50))) {
|
||||||
|
return "Mindestens ein Download enthält ungültige Werte.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateLegalDocuments(value: unknown): value is AuthorData["legalDocuments"] {
|
||||||
|
return Array.isArray(value) && value.length <= 100 && value.every((document) =>
|
||||||
|
document && typeof document.id === "string" && document.id.length <= 200 &&
|
||||||
|
typeof document.title === "string" && document.title.length <= 500 &&
|
||||||
|
["impressum", "privacy", "custom"].includes(document.type) &&
|
||||||
|
typeof document.content === "string" && document.content.length <= 200_000 &&
|
||||||
|
Array.isArray(document.assignedProfiles) && document.assignedProfiles.length <= profileKeys.length &&
|
||||||
|
document.assignedProfiles.every((key: unknown) => profileKeys.includes(key as ProfileKey))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasRevisionConflict(expectedRevision: unknown): boolean {
|
||||||
|
return !Number.isInteger(expectedRevision) || expectedRevision !== (dbCache.revision ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUploadReferenced(url: string): boolean {
|
||||||
|
return profileKeys.some((key) => {
|
||||||
|
const profile = dbCache[key];
|
||||||
|
return profile.avatarUrl === url || profile.heroBannerUrl === url || profile.socialImageUrl === url ||
|
||||||
|
profile.books.some((book) => book.coverUrl === url) || profile.projects.some((project) => project.imageUrl === url);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDownloadReferenced(url: string): boolean {
|
||||||
|
return profileKeys.some((key) => dbCache[key].books.some((book) => book.samplePdfUrl === url) ||
|
||||||
|
(dbCache[key].downloads || []).some((download: DownloadPublication) => download.pdfUrl === url || download.epubUrl === url));
|
||||||
|
}
|
||||||
|
|
||||||
function seoMeta(req: express.Request, scriptNonce?: string): string {
|
function seoMeta(req: express.Request, scriptNonce?: string): string {
|
||||||
const key = profileForRequest(req.hostname, req.path);
|
const key = profileForRequest(req.hostname, req.path);
|
||||||
const profile = dbCache[key];
|
const profile = dbCache[key];
|
||||||
|
|
@ -491,8 +651,27 @@ function seoMeta(req: express.Request, scriptNonce?: string): string {
|
||||||
|
|
||||||
// --- API ROUTES ---
|
// --- API ROUTES ---
|
||||||
|
|
||||||
// 1. Get entire public author setup
|
// Return only the public data belonging to the requested hostname.
|
||||||
app.get("/api/author-data", (req, res) => {
|
app.get("/api/author-data", (req, res) => {
|
||||||
|
const key = profileForRequest(req.hostname, req.path);
|
||||||
|
const { customDomain: _customDomain, customPath: _customPath, ...publicProfile } = dbCache[key];
|
||||||
|
publicProfile.customSectionLinks = normalizeCustomSectionLinks(publicProfile.customSectionLinks) || [];
|
||||||
|
const legalDocuments = (dbCache.legalDocuments || [])
|
||||||
|
.filter((document) => document.assignedProfiles.includes(key))
|
||||||
|
.map((document) => ({
|
||||||
|
...document,
|
||||||
|
content: isolateLegalContent(document.content, key),
|
||||||
|
assignedProfiles: [],
|
||||||
|
}));
|
||||||
|
const response: PublicAuthorData = {
|
||||||
|
profile: publicProfile,
|
||||||
|
legalDocuments,
|
||||||
|
theme: publicThemes[key],
|
||||||
|
};
|
||||||
|
res.json(response);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/admin/author-data", verifySession, (_req, res) => {
|
||||||
res.json(dbCache);
|
res.json(dbCache);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -548,24 +727,38 @@ app.post("/api/admin/logout", verifySameOrigin, (req, res) => {
|
||||||
|
|
||||||
// 3. Save modified profile configurations (About, Projects, Books)
|
// 3. Save modified profile configurations (About, Projects, Books)
|
||||||
app.post("/api/admin/save-profile", verifySession, verifySameOrigin, async (req, res) => {
|
app.post("/api/admin/save-profile", verifySession, verifySameOrigin, async (req, res) => {
|
||||||
const { profileKey, profileData } = req.body;
|
const { profileKey, profileData, expectedRevision } = req.body;
|
||||||
if (profileKey !== "scifi" && profileKey !== "erotica" && profileKey !== "clara" && profileKey !== "renee") {
|
if (profileKey !== "scifi" && profileKey !== "erotica" && profileKey !== "clara" && profileKey !== "renee") {
|
||||||
res.status(400).json({ error: "Ungültiger Profilschlüssel." });
|
res.status(400).json({ error: "Ungültiger Profilschlüssel." });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!profileData || typeof profileData !== "object" || !Array.isArray(profileData.books) || !Array.isArray(profileData.projects)) {
|
if (hasRevisionConflict(expectedRevision)) {
|
||||||
res.status(400).json({ error: "Profildaten besitzen nicht die erwartete Struktur." });
|
res.status(409).json({ error: "Die Daten wurden zwischenzeitlich geändert. Bitte neu laden und die Änderung erneut vornehmen.", revision: dbCache.revision ?? 0 });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const nextData = await updateDatabase((current) => ({
|
const validationError = validateProfile(profileData);
|
||||||
...current,
|
if (validationError) {
|
||||||
[profileKey]: profileData,
|
res.status(400).json({ error: validationError });
|
||||||
revision: (current.revision ?? 0) + 1,
|
return;
|
||||||
}));
|
}
|
||||||
|
const customSectionLinks = normalizeCustomSectionLinks(profileData.customSectionLinks);
|
||||||
|
if (customSectionLinks === null) {
|
||||||
|
res.status(400).json({ error: "Die optionalen Buttons enthalten eine ungültige URL oder überschreiten das Limit von drei Einträgen." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sanitizedProfileData = { ...profileData, customSectionLinks };
|
||||||
|
const nextData = await updateDatabase((current) => {
|
||||||
|
if (expectedRevision !== (current.revision ?? 0)) throw new RevisionConflictError();
|
||||||
|
return { ...current, [profileKey]: sanitizedProfileData, revision: (current.revision ?? 0) + 1 };
|
||||||
|
});
|
||||||
res.json({ success: true, revision: nextData.revision, message: "Profil erfolgreich gespeichert." });
|
res.json({ success: true, revision: nextData.revision, message: "Profil erfolgreich gespeichert." });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
if (err instanceof RevisionConflictError) {
|
||||||
|
res.status(409).json({ error: "Die Daten wurden zwischenzeitlich geändert. Bitte neu laden und die Änderung erneut vornehmen.", revision: dbCache.revision ?? 0 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
console.error("Failed to write to database.json:", err);
|
console.error("Failed to write to database.json:", err);
|
||||||
res.status(500).json({ error: "Fehler beim persistenten Speichern der Formulardaten." });
|
res.status(500).json({ error: "Fehler beim persistenten Speichern der Formulardaten." });
|
||||||
}
|
}
|
||||||
|
|
@ -573,20 +766,27 @@ app.post("/api/admin/save-profile", verifySession, verifySameOrigin, async (req,
|
||||||
|
|
||||||
// 3b. Save legal documents (Impressum & Datenschutzerklärung)
|
// 3b. Save legal documents (Impressum & Datenschutzerklärung)
|
||||||
app.post("/api/admin/save-legal", verifySession, verifySameOrigin, async (req, res) => {
|
app.post("/api/admin/save-legal", verifySession, verifySameOrigin, async (req, res) => {
|
||||||
const { legalDocuments } = req.body;
|
const { legalDocuments, expectedRevision } = req.body;
|
||||||
if (!Array.isArray(legalDocuments)) {
|
if (hasRevisionConflict(expectedRevision)) {
|
||||||
res.status(400).json({ error: "legalDocuments muss ein Array sein." });
|
res.status(409).json({ error: "Die Daten wurden zwischenzeitlich geändert. Bitte neu laden und die Änderung erneut vornehmen.", revision: dbCache.revision ?? 0 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!validateLegalDocuments(legalDocuments)) {
|
||||||
|
res.status(400).json({ error: "Die rechtlichen Dokumente besitzen nicht die erwartete Struktur oder überschreiten zulässige Längen." });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const nextData = await updateDatabase((current) => ({
|
const nextData = await updateDatabase((current) => {
|
||||||
...current,
|
if (expectedRevision !== (current.revision ?? 0)) throw new RevisionConflictError();
|
||||||
legalDocuments,
|
return { ...current, legalDocuments, revision: (current.revision ?? 0) + 1 };
|
||||||
revision: (current.revision ?? 0) + 1,
|
});
|
||||||
}));
|
|
||||||
res.json({ success: true, revision: nextData.revision, message: "Rechtliche Dokumente erfolgreich gespeichert." });
|
res.json({ success: true, revision: nextData.revision, message: "Rechtliche Dokumente erfolgreich gespeichert." });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
if (err instanceof RevisionConflictError) {
|
||||||
|
res.status(409).json({ error: "Die Daten wurden zwischenzeitlich geändert. Bitte neu laden und die Änderung erneut vornehmen.", revision: dbCache.revision ?? 0 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
console.error("Failed to write legal documents to database.json:", err);
|
console.error("Failed to write legal documents to database.json:", err);
|
||||||
res.status(500).json({ error: "Fehler beim Speichern der rechtlichen Dokumente." });
|
res.status(500).json({ error: "Fehler beim Speichern der rechtlichen Dokumente." });
|
||||||
}
|
}
|
||||||
|
|
@ -714,6 +914,156 @@ app.get("/api/admin/list-uploads", verifySession, async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.delete("/api/admin/uploads/:name", verifySession, verifySameOrigin, async (req, res) => {
|
||||||
|
const name = req.params.name;
|
||||||
|
if (!SAFE_UPLOAD_NAME.test(name) || path.basename(name) !== name) {
|
||||||
|
res.status(400).json({ error: "Ungültiger Dateiname." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = `/uploads/${name}`;
|
||||||
|
if (isUploadReferenced(url)) {
|
||||||
|
res.status(409).json({ error: "Das Bild wird noch in mindestens einem Profil verwendet und kann nicht gelöscht werden." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await fs.unlink(path.join(DATA_DIR, "uploads", name));
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.code === "ENOENT") {
|
||||||
|
res.status(404).json({ error: "Die Datei wurde nicht gefunden." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error("Failed to delete upload:", error);
|
||||||
|
res.status(500).json({ error: "Die Datei konnte nicht gelöscht werden." });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/admin/upload-sample", verifySession, verifySameOrigin, async (req, res) => {
|
||||||
|
const sessionId = res.locals.sessionId as string;
|
||||||
|
const rate = rateRecord(uploadRequests, sessionId, UPLOAD_WINDOW_MS);
|
||||||
|
if (rate.count >= UPLOAD_MAX_REQUESTS) {
|
||||||
|
res.status(429).json({ error: "Das stündliche Upload-Limit ist erreicht." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rate.count += 1;
|
||||||
|
const { fileName, base64Data } = req.body;
|
||||||
|
if (typeof fileName !== "string" || typeof base64Data !== "string" || path.extname(fileName).toLowerCase() !== ".pdf") {
|
||||||
|
res.status(400).json({ error: "Eine PDF-Datei ist erforderlich." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const match = base64Data.match(/^data:application\/pdf;base64,([a-zA-Z0-9+/]+={0,2})$/);
|
||||||
|
if (!match) {
|
||||||
|
res.status(400).json({ error: "Das PDF-Uploadformat ist ungültig." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const buffer = Buffer.from(match[1], "base64");
|
||||||
|
if (buffer.length === 0 || buffer.length > MAX_PDF_BYTES) {
|
||||||
|
res.status(413).json({ error: "Die Leseprobe darf maximal 10 MB groß sein." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (buffer.subarray(0, 5).toString("ascii") !== "%PDF-") {
|
||||||
|
res.status(415).json({ error: "Die Datei besitzt keinen gültigen PDF-Dateikopf." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const generatedName = `${Date.now().toString(36)}-${randomBytes(16).toString("hex")}.pdf`;
|
||||||
|
await fs.writeFile(path.join(DOWNLOAD_DIR, generatedName), buffer, { flag: "wx" });
|
||||||
|
res.status(201).json({ success: true, url: `/downloads/${generatedName}`, size: buffer.length });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Sample upload failed:", error);
|
||||||
|
res.status(500).json({ error: "Die Leseprobe konnte nicht gespeichert werden." });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/admin/upload-publication", verifySession, verifySameOrigin, async (req, res) => {
|
||||||
|
const sessionId = res.locals.sessionId as string;
|
||||||
|
const rate = rateRecord(uploadRequests, sessionId, UPLOAD_WINDOW_MS);
|
||||||
|
if (rate.count >= UPLOAD_MAX_REQUESTS) {
|
||||||
|
res.status(429).json({ error: "Das stündliche Upload-Limit ist erreicht." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rate.count += 1;
|
||||||
|
const { fileName, base64Data, format } = req.body;
|
||||||
|
if (typeof fileName !== "string" || typeof base64Data !== "string" || (format !== "pdf" && format !== "epub")) {
|
||||||
|
res.status(400).json({ error: "Dateiname, Datei und Format sind erforderlich." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (path.extname(fileName).toLowerCase() !== `.${format}`) {
|
||||||
|
res.status(400).json({ error: "Dateiendung und gewähltes Format stimmen nicht überein." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const match = base64Data.match(/^data:(?:[^;]+)?;base64,([a-zA-Z0-9+/]+={0,2})$/);
|
||||||
|
if (!match) {
|
||||||
|
res.status(400).json({ error: "Das Uploadformat ist ungültig." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const buffer = Buffer.from(match[1], "base64");
|
||||||
|
if (buffer.length === 0 || buffer.length > MAX_PDF_BYTES) {
|
||||||
|
res.status(413).json({ error: "Die Datei darf maximal 10 MB groß sein." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const isPdf = buffer.subarray(0, 5).toString("ascii") === "%PDF-";
|
||||||
|
const isEpub = buffer.length > 58 && buffer[0] === 0x50 && buffer[1] === 0x4b && buffer.includes(Buffer.from("application/epub+zip"));
|
||||||
|
if ((format === "pdf" && !isPdf) || (format === "epub" && !isEpub)) {
|
||||||
|
res.status(415).json({ error: `Die Datei besitzt keine gültige ${format.toUpperCase()}-Struktur.` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const generatedName = `${Date.now().toString(36)}-${randomBytes(16).toString("hex")}.${format}`;
|
||||||
|
await fs.writeFile(path.join(DOWNLOAD_DIR, generatedName), buffer, { flag: "wx" });
|
||||||
|
res.status(201).json({ success: true, url: `/downloads/${generatedName}`, size: buffer.length, format });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Publication upload failed:", error);
|
||||||
|
res.status(500).json({ error: "Die Datei konnte nicht gespeichert werden." });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete("/api/admin/samples/:name", verifySession, verifySameOrigin, async (req, res) => {
|
||||||
|
const name = req.params.name;
|
||||||
|
if (!SAFE_PDF_NAME.test(name) || path.basename(name) !== name) {
|
||||||
|
res.status(400).json({ error: "Ungültiger Dateiname." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = `/downloads/${name}`;
|
||||||
|
if (isDownloadReferenced(url)) {
|
||||||
|
res.status(409).json({ error: "Die Leseprobe wird noch von einem Buch verwendet." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await fs.unlink(path.join(DOWNLOAD_DIR, name));
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.code === "ENOENT") {
|
||||||
|
res.status(404).json({ error: "Die Leseprobe wurde nicht gefunden." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(500).json({ error: "Die Leseprobe konnte nicht gelöscht werden." });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete("/api/admin/publications/:name", verifySession, verifySameOrigin, async (req, res) => {
|
||||||
|
const name = req.params.name;
|
||||||
|
if (!SAFE_DOWNLOAD_NAME.test(name) || path.basename(name) !== name) {
|
||||||
|
res.status(400).json({ error: "Ungültiger Dateiname." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = `/downloads/${name}`;
|
||||||
|
if (isDownloadReferenced(url)) {
|
||||||
|
res.status(409).json({ error: "Die Datei wird noch in einem veröffentlichten Eintrag verwendet." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await fs.unlink(path.join(DOWNLOAD_DIR, name));
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.code === "ENOENT") {
|
||||||
|
res.status(404).json({ error: "Die Datei wurde nicht gefunden." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(500).json({ error: "Die Datei konnte nicht gelöscht werden." });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Configure Vite middleware or static serve
|
// Configure Vite middleware or static serve
|
||||||
async function startServer() {
|
async function startServer() {
|
||||||
// Serve the dynamic uploads directory statically
|
// Serve the dynamic uploads directory statically
|
||||||
|
|
@ -735,6 +1085,23 @@ async function startServer() {
|
||||||
res.setHeader("X-Content-Type-Options", "nosniff");
|
res.setHeader("X-Content-Type-Options", "nosniff");
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
app.use("/downloads", (req, res, next) => {
|
||||||
|
const requestedName = path.basename(req.path);
|
||||||
|
if (!SAFE_DOWNLOAD_NAME.test(requestedName)) {
|
||||||
|
res.status(404).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
app.use("/downloads", express.static(DOWNLOAD_DIR, {
|
||||||
|
dotfiles: "deny",
|
||||||
|
fallthrough: false,
|
||||||
|
setHeaders: (res, filePath) => {
|
||||||
|
res.setHeader("Content-Type", filePath.toLowerCase().endsWith(".epub") ? "application/epub+zip" : "application/pdf");
|
||||||
|
res.setHeader("Content-Disposition", `attachment; filename="${path.basename(filePath)}"`);
|
||||||
|
res.setHeader("X-Content-Type-Options", "nosniff");
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
if (process.env.NODE_ENV !== "production") {
|
if (process.env.NODE_ENV !== "production") {
|
||||||
const { createServer: createViteServer } = await import("vite");
|
const { createServer: createViteServer } = await import("vite");
|
||||||
|
|
@ -763,9 +1130,14 @@ async function startServer() {
|
||||||
|
|
||||||
app.use(express.static(distPath, { index: false }));
|
app.use(express.static(distPath, { index: false }));
|
||||||
app.get("*", (req: express.Request, res: express.Response) => {
|
app.get("*", (req: express.Request, res: express.Response) => {
|
||||||
const html = indexTemplate.replace("<!-- SEO_META -->\n <title>Autoren-Portfolio</title>", seoMeta(req, res.locals.scriptNonce));
|
const isKnownRoute = req.path === "/" || req.path === "/admin" || req.path.startsWith("/admin/");
|
||||||
|
const meta = isKnownRoute
|
||||||
|
? seoMeta(req, res.locals.scriptNonce)
|
||||||
|
: '<title>Seite nicht gefunden</title>\n <meta name="robots" content="noindex, nofollow, noarchive" />';
|
||||||
|
const html = indexTemplate.replace("<!-- SEO_META -->\n <title>Autoren-Portfolio</title>", meta);
|
||||||
if (req.path.startsWith("/admin")) res.setHeader("X-Robots-Tag", "noindex, nofollow, noarchive");
|
if (req.path.startsWith("/admin")) res.setHeader("X-Robots-Tag", "noindex, nofollow, noarchive");
|
||||||
res.type("html").send(html);
|
if (!isKnownRoute) res.setHeader("X-Robots-Tag", "noindex, nofollow, noarchive");
|
||||||
|
res.status(isKnownRoute ? 200 : 404).type("html").send(html);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
206
src/App.tsx
206
src/App.tsx
|
|
@ -1,193 +1,115 @@
|
||||||
import { lazy, Suspense, useState, useEffect } from "react";
|
import { lazy, Suspense, useEffect, useState } from "react";
|
||||||
import { AuthorData } from "./types";
|
|
||||||
import { Lock, RefreshCw } from "lucide-react";
|
import { Lock, RefreshCw } from "lucide-react";
|
||||||
|
import type { PublicAuthorData } from "./types";
|
||||||
|
|
||||||
const SciFiPortfolio = lazy(() => import("./components/SciFiPortfolio"));
|
const PortfolioPage = lazy(() => import("./components/PortfolioPage"));
|
||||||
const EroticaPortfolio = lazy(() => import("./components/EroticaPortfolio"));
|
|
||||||
const ClaraPortfolio = lazy(() => import("./components/ClaraPortfolio"));
|
|
||||||
const ReneePortfolio = lazy(() => import("./components/ReneePortfolio"));
|
|
||||||
const AdminPanel = lazy(() => import("./components/AdminPanel"));
|
const AdminPanel = lazy(() => import("./components/AdminPanel"));
|
||||||
|
|
||||||
function LoadingScreen() {
|
function LoadingScreen() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-950 text-slate-100 flex flex-col items-center justify-center space-y-4">
|
<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" />
|
<RefreshCw className="w-8 h-8 animate-spin text-cyan-400" />
|
||||||
<p className="font-mono text-xs text-slate-500 uppercase tracking-widest">Autoren-Portfolio wird geladen...</p>
|
<p className="font-mono text-xs text-slate-500 uppercase tracking-widest">Portfolio wird geladen...</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [authorData, setAuthorData] = useState<AuthorData | null>(null);
|
const [publicData, setPublicData] = useState<PublicAuthorData | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [currentPath, setCurrentPath] = useState(window.location.pathname);
|
const [currentPath, setCurrentPath] = useState(window.location.pathname);
|
||||||
|
const isAdmin = currentPath === "/admin" || currentPath.startsWith("/admin/");
|
||||||
|
|
||||||
// Monitor location changes for custom routing
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleLocationChange = () => {
|
const handleLocationChange = () => setCurrentPath(window.location.pathname);
|
||||||
setCurrentPath(window.location.pathname);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Listen to popstate for navigation back/forward support
|
|
||||||
window.addEventListener("popstate", handleLocationChange);
|
window.addEventListener("popstate", handleLocationChange);
|
||||||
return () => {
|
return () => window.removeEventListener("popstate", handleLocationChange);
|
||||||
window.removeEventListener("popstate", handleLocationChange);
|
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Fetch profiles configuration from server
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/author-data")
|
if (isAdmin) {
|
||||||
.then((res) => res.json())
|
setLoading(false);
|
||||||
.then((data) => {
|
return;
|
||||||
setAuthorData(data);
|
}
|
||||||
setLoading(false);
|
|
||||||
|
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>;
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.then((data) => setPublicData(data))
|
||||||
console.error("Fehler beim Abrufen der Autoren-Informationen:", err);
|
.catch((error) => {
|
||||||
setLoading(false);
|
if (error instanceof DOMException && error.name === "AbortError") return;
|
||||||
});
|
console.error("Fehler beim Abrufen des Portfolios:", error);
|
||||||
}, []);
|
setPublicData(null);
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
|
||||||
// Ermittelt die aktive Suite basierend auf Pfad-Überschreibungen oder dem Domainnamen (Hostname)
|
return () => controller.abort();
|
||||||
const getActiveSuite = (): "erotica" | "clara" | "scifi" | "renee" => {
|
}, [isAdmin]);
|
||||||
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 === `www.${cleanDomain}`)) {
|
|
||||||
return key;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Standard-Fallback (Daniel Hesse SciFi)
|
|
||||||
return "scifi";
|
|
||||||
};
|
|
||||||
|
|
||||||
// Dynamisch den Dokumententitel (Browsertab-Titel) basierend auf Suite und Autorendaten anpassen
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authorData) {
|
if (isAdmin) document.title = "Administration";
|
||||||
document.title = "Lade Autoren-Portfolio...";
|
else if (publicData) document.title = publicData.profile.seoTitle || publicData.profile.name;
|
||||||
return;
|
else document.title = "Portfolio";
|
||||||
}
|
}, [isAdmin, publicData]);
|
||||||
if (currentPath === "/admin" || currentPath.startsWith("/admin/")) {
|
|
||||||
document.title = "Admin-Zentrale | Autor-Suiten";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeSuite = getActiveSuite();
|
const navigateTo = (path: string) => {
|
||||||
if (activeSuite === "erotica") {
|
window.history.pushState({}, "", path);
|
||||||
document.title = authorData.erotica.name || "Annie Slone";
|
setCurrentPath(path);
|
||||||
} 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) {
|
if (isAdmin) {
|
||||||
return <LoadingScreen />;
|
return (
|
||||||
|
<Suspense fallback={<LoadingScreen />}>
|
||||||
|
<AdminPanel onLogout={() => navigateTo("/")} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!authorData) {
|
if (loading) return <LoadingScreen />;
|
||||||
|
|
||||||
|
if (!publicData) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-950 text-slate-100 flex flex-col items-center justify-center p-4">
|
<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">
|
<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>
|
<h2 className="text-lg font-bold text-rose-400">Verbindungsfehler</h2>
|
||||||
<p className="text-xs text-slate-400 leading-relaxed">
|
<p className="text-xs text-slate-400 leading-relaxed">Das Portfolio konnte nicht geladen werden.</p>
|
||||||
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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Purely route-based render isolation structure
|
const isLocalPreview = ["localhost", "127.0.0.1"].includes(window.location.hostname);
|
||||||
// 1. Administration panel
|
if (currentPath !== "/" && !isLocalPreview) {
|
||||||
if (currentPath === "/admin" || currentPath.startsWith("/admin/")) {
|
return (
|
||||||
return <Suspense fallback={<LoadingScreen />}><AdminPanel onLogout={handleAdminLogout} /></Suspense>;
|
<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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render active portfolio
|
|
||||||
const renderActivePortfolio = () => {
|
|
||||||
const activeSuite = getActiveSuite();
|
|
||||||
const legalDocs = authorData.legalDocuments || [];
|
|
||||||
if (activeSuite === "erotica") {
|
|
||||||
return <EroticaPortfolio data={authorData.erotica} legalDocuments={legalDocs} />;
|
|
||||||
}
|
|
||||||
if (activeSuite === "clara") {
|
|
||||||
return <ClaraPortfolio data={authorData.clara} legalDocuments={legalDocs} />;
|
|
||||||
}
|
|
||||||
if (activeSuite === "renee") {
|
|
||||||
return <ReneePortfolio data={authorData.renee} legalDocuments={legalDocs} />;
|
|
||||||
}
|
|
||||||
return <SciFiPortfolio data={authorData.scifi} legalDocuments={legalDocs} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<LoadingScreen />}>
|
<Suspense fallback={<LoadingScreen />}>
|
||||||
{renderActivePortfolio()}
|
<PortfolioPage data={publicData.profile} legalDocuments={publicData.legalDocuments} theme={publicData.theme} />
|
||||||
|
|
||||||
{/* Absolute floating administration bypass link hidden on the bottom left (hover to show) */}
|
|
||||||
<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">
|
<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
|
<a
|
||||||
href="/admin"
|
href="/admin"
|
||||||
onClick={(e) => {
|
onClick={(event) => {
|
||||||
e.preventDefault();
|
event.preventDefault();
|
||||||
window.history.pushState({}, "", "/admin");
|
navigateTo("/admin");
|
||||||
setCurrentPath("/admin");
|
|
||||||
}}
|
}}
|
||||||
className="text-stone-500 hover:text-cyan-400 font-bold flex items-center gap-1.5 transition-colors px-1"
|
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" />
|
<Lock className="w-3 h-3" />
|
||||||
<span>Admin-Zentrale</span>
|
<span>Administration</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
|
||||||
49
src/api/adminApi.ts
Normal file
49
src/api/adminApi.ts
Normal 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" }),
|
||||||
|
};
|
||||||
72
src/components/AccessibleModal.tsx
Normal file
72
src/components/AccessibleModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -2,12 +2,16 @@ import React, { useState, useEffect, FormEvent } from "react";
|
||||||
import Markdown from "react-markdown";
|
import Markdown from "react-markdown";
|
||||||
import { AuthorData, AuthorProfile, Book, Project, LegalDocument } from "../types";
|
import { AuthorData, AuthorProfile, Book, Project, LegalDocument } from "../types";
|
||||||
import {
|
import {
|
||||||
Lock, Save, Plus, Trash2, Edit2, Check, RefreshCw, Sparkles,
|
Save, Plus, Trash2, Edit2, Check, RefreshCw, Sparkles,
|
||||||
BookOpen, Compass, Clipboard, Eye, Music, LogOut, ArrowLeftRight,
|
BookOpen, Compass, Clipboard, Eye, Music, LogOut, ArrowLeftRight,
|
||||||
Palette, Type, Image as ImageIcon, Globe, Tag,
|
Palette, Type, Image as ImageIcon, Globe, Tag,
|
||||||
Scale, ShieldCheck, FileText, CheckSquare, Square, Copy, ArrowRight
|
Scale, ShieldCheck, FileText, CheckSquare, Square, Copy, ArrowRight, Download
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import ImagePicker from "./ImagePicker";
|
import ImagePicker from "./ImagePicker";
|
||||||
|
import AdminLogin from "./admin/AdminLogin";
|
||||||
|
import SamplePdfPicker from "./admin/SamplePdfPicker";
|
||||||
|
import DownloadsManager from "./admin/DownloadsManager";
|
||||||
|
import { adminApi, AdminApiError } from "../api/adminApi";
|
||||||
|
|
||||||
interface AdminPanelProps {
|
interface AdminPanelProps {
|
||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
|
|
@ -18,6 +22,7 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
||||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [notice, setNotice] = useState<{ message: string; tone: "success" | "error" } | null>(null);
|
||||||
|
|
||||||
// Author Data State
|
// Author Data State
|
||||||
const [authorData, setAuthorData] = useState<AuthorData | null>(null);
|
const [authorData, setAuthorData] = useState<AuthorData | null>(null);
|
||||||
|
|
@ -26,7 +31,7 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
||||||
const [activeProfile, setActiveProfile] = useState<"scifi" | "erotica" | "clara" | "renee">("scifi");
|
const [activeProfile, setActiveProfile] = useState<"scifi" | "erotica" | "clara" | "renee">("scifi");
|
||||||
|
|
||||||
// Sub-tab selection state ("profile" | "texts" | "design" | "books" | "projects" | "legal")
|
// Sub-tab selection state ("profile" | "texts" | "design" | "books" | "projects" | "legal")
|
||||||
const [activeTab, setActiveTab] = useState<"profile" | "texts" | "design" | "books" | "projects" | "legal">("profile");
|
const [activeTab, setActiveTab] = useState<"profile" | "texts" | "design" | "books" | "projects" | "downloads" | "legal">("profile");
|
||||||
|
|
||||||
// Editing forms state
|
// Editing forms state
|
||||||
const [editingBookId, setEditingBookId] = useState<string | null>(null);
|
const [editingBookId, setEditingBookId] = useState<string | null>(null);
|
||||||
|
|
@ -56,11 +61,9 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
||||||
const restoreSession = async () => {
|
const restoreSession = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/admin/session");
|
await adminApi.session();
|
||||||
if (response.ok) {
|
setIsLoggedIn(true);
|
||||||
setIsLoggedIn(true);
|
await fetchAuthorData();
|
||||||
await fetchAuthorData();
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Admin session check failed:", err);
|
console.error("Admin session check failed:", err);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -70,11 +73,20 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
||||||
restoreSession();
|
restoreSession();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleExpiredSession = () => {
|
||||||
|
setIsLoggedIn(false);
|
||||||
|
setAuthorData(null);
|
||||||
|
setError("Die Admin-Sitzung ist abgelaufen. Bitte erneut anmelden.");
|
||||||
|
};
|
||||||
|
window.addEventListener("admin-session-expired", handleExpiredSession);
|
||||||
|
return () => window.removeEventListener("admin-session-expired", handleExpiredSession);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const fetchAuthorData = async () => {
|
const fetchAuthorData = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/author-data");
|
const data = await adminApi.loadData();
|
||||||
const data = await res.json();
|
|
||||||
setAuthorData(data);
|
setAuthorData(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error fetching author data:", err);
|
console.error("Error fetching author data:", err);
|
||||||
|
|
@ -88,21 +100,12 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
||||||
setError("");
|
setError("");
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/admin/login", {
|
await adminApi.login(password);
|
||||||
method: "POST",
|
setPassword("");
|
||||||
headers: { "Content-Type": "application/json" },
|
setIsLoggedIn(true);
|
||||||
body: JSON.stringify({ password }),
|
await fetchAuthorData();
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (res.ok && data.success) {
|
|
||||||
setPassword("");
|
|
||||||
setIsLoggedIn(true);
|
|
||||||
await fetchAuthorData();
|
|
||||||
} else {
|
|
||||||
setError(data.error || "Ungültiges Passwort.");
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError("Verbindung zum Server fehlgeschlagen.");
|
setError(err instanceof AdminApiError ? err.message : "Verbindung zum Server fehlgeschlagen.");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -110,7 +113,7 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
||||||
|
|
||||||
const handleLogoutLocal = async () => {
|
const handleLogoutLocal = async () => {
|
||||||
try {
|
try {
|
||||||
await fetch("/api/admin/logout", { method: "POST" });
|
await adminApi.logout();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Admin logout failed:", err);
|
console.error("Admin logout failed:", err);
|
||||||
}
|
}
|
||||||
|
|
@ -119,6 +122,28 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
||||||
onLogout();
|
onLogout();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const persistProfile = async (profileKey: "scifi" | "erotica" | "clara" | "renee", profileData: AuthorProfile) => {
|
||||||
|
if (!authorData) throw new Error("Keine Profildaten geladen.");
|
||||||
|
const result = await adminApi.saveProfile(profileKey, profileData, authorData.revision ?? 0);
|
||||||
|
setAuthorData((current) => current ? { ...current, [profileKey]: profileData, revision: result.revision } : current);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const persistLegalDocuments = async (legalDocuments: LegalDocument[]) => {
|
||||||
|
if (!authorData) throw new Error("Keine Profildaten geladen.");
|
||||||
|
const result = await adminApi.saveLegal(legalDocuments, authorData.revision ?? 0);
|
||||||
|
setAuthorData((current) => current ? { ...current, legalDocuments, revision: result.revision } : current);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const showNotice = (message: string, tone: "success" | "error" = "error") => setNotice({ message, tone });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!notice) return;
|
||||||
|
const timeout = window.setTimeout(() => setNotice(null), 5000);
|
||||||
|
return () => window.clearTimeout(timeout);
|
||||||
|
}, [notice]);
|
||||||
|
|
||||||
// Profile save handler
|
// Profile save handler
|
||||||
const handleSaveProfileInfo = async (profileKey: "scifi" | "erotica" | "clara" | "renee") => {
|
const handleSaveProfileInfo = async (profileKey: "scifi" | "erotica" | "clara" | "renee") => {
|
||||||
if (!authorData) return;
|
if (!authorData) return;
|
||||||
|
|
@ -126,26 +151,10 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/admin/save-profile", {
|
await persistProfile(profileKey, authorData[profileKey]);
|
||||||
method: "POST",
|
showNotice("Profilinformationen erfolgreich gespeichert!", "success");
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
profileKey,
|
|
||||||
profileData: authorData[profileKey]
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const resData = await res.json();
|
|
||||||
if (res.ok && resData.success) {
|
|
||||||
alert("Profilinformationen erfolgreich gespeichert!");
|
|
||||||
fetchAuthorData();
|
|
||||||
} else {
|
|
||||||
setError(resData.error || "Profile konnte nicht gespeichert werden.");
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError("Fehler beim Senden der Profildaten.");
|
setError(err instanceof AdminApiError ? err.message : "Fehler beim Senden der Profildaten.");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -164,6 +173,16 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateCustomSectionLink = (index: number, field: "label" | "url", value: string) => {
|
||||||
|
if (!profile) return;
|
||||||
|
const links = Array.from({ length: 3 }, (_, linkIndex) => ({
|
||||||
|
label: profile.customSectionLinks?.[linkIndex]?.label || "",
|
||||||
|
url: profile.customSectionLinks?.[linkIndex]?.url || "",
|
||||||
|
}));
|
||||||
|
links[index] = { ...links[index], [field]: value };
|
||||||
|
updateProfileField("customSectionLinks", links);
|
||||||
|
};
|
||||||
|
|
||||||
// --- PROJECT ACTIONS ---
|
// --- PROJECT ACTIONS ---
|
||||||
const handleSaveProject = async () => {
|
const handleSaveProject = async () => {
|
||||||
if (!authorData || !profile) return;
|
if (!authorData || !profile) return;
|
||||||
|
|
@ -187,27 +206,14 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedProfile = { ...profile, projects: updatedProjects };
|
const updatedProfile = { ...profile, projects: updatedProjects };
|
||||||
const nextData = { ...authorData, [activeProfile]: updatedProfile };
|
|
||||||
|
|
||||||
// Save to server
|
// Save to server
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/admin/save-profile", {
|
await persistProfile(activeProfile, updatedProfile);
|
||||||
method: "POST",
|
setEditingProjectId(null);
|
||||||
headers: {
|
setProjectForm({});
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ profileKey: activeProfile, profileData: updatedProfile }),
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
setAuthorData(nextData);
|
|
||||||
setEditingProjectId(null);
|
|
||||||
setProjectForm({});
|
|
||||||
} else {
|
|
||||||
alert("Fehler beim Speichern des Projekts.");
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert("Netzwerkfehler.");
|
showNotice(err instanceof AdminApiError ? err.message : "Netzwerkfehler.");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -218,24 +224,11 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
||||||
|
|
||||||
const updatedProjects = profile.projects.filter(p => p.id !== projId);
|
const updatedProjects = profile.projects.filter(p => p.id !== projId);
|
||||||
const updatedProfile = { ...profile, projects: updatedProjects };
|
const updatedProfile = { ...profile, projects: updatedProjects };
|
||||||
const nextData = { ...authorData, [activeProfile]: updatedProfile };
|
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/admin/save-profile", {
|
await persistProfile(activeProfile, updatedProfile);
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ profileKey: activeProfile, profileData: updatedProfile }),
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
setAuthorData(nextData);
|
|
||||||
} else {
|
|
||||||
alert("Fehler beim Löschen.");
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert("Netzwerkfehler.");
|
showNotice(err instanceof AdminApiError ? err.message : "Netzwerkfehler.");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -247,6 +240,7 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
||||||
|
|
||||||
const parsedGenres = genreInputRaw.split(",").map(g => g.trim()).filter(Boolean);
|
const parsedGenres = genreInputRaw.split(",").map(g => g.trim()).filter(Boolean);
|
||||||
let updatedBooks = [...profile.books];
|
let updatedBooks = [...profile.books];
|
||||||
|
const previousSample = editingBookId && editingBookId !== "new" ? profile.books.find((book) => book.id === editingBookId)?.samplePdfUrl : undefined;
|
||||||
|
|
||||||
// If current book is marked as spotlight, clear spotlight flag on all other books
|
// If current book is marked as spotlight, clear spotlight flag on all other books
|
||||||
if (bookForm.isSpotlight) {
|
if (bookForm.isSpotlight) {
|
||||||
|
|
@ -262,6 +256,11 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
||||||
description: bookForm.description || "",
|
description: bookForm.description || "",
|
||||||
publishedYear: bookForm.publishedYear || new Date().getFullYear(),
|
publishedYear: bookForm.publishedYear || new Date().getFullYear(),
|
||||||
buyLink: bookForm.buyLink || "",
|
buyLink: bookForm.buyLink || "",
|
||||||
|
ebookLink: bookForm.ebookLink || bookForm.buyLink || "",
|
||||||
|
paperbackLink: bookForm.paperbackLink || "",
|
||||||
|
samplePdfUrl: bookForm.samplePdfUrl || "",
|
||||||
|
seriesName: bookForm.seriesName || "",
|
||||||
|
seriesNumber: bookForm.seriesNumber || "",
|
||||||
spotifyPlaylistId: bookForm.spotifyPlaylistId || "",
|
spotifyPlaylistId: bookForm.spotifyPlaylistId || "",
|
||||||
genres: parsedGenres,
|
genres: parsedGenres,
|
||||||
isSpotlight: !!bookForm.isSpotlight,
|
isSpotlight: !!bookForm.isSpotlight,
|
||||||
|
|
@ -281,26 +280,18 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedProfile = { ...profile, books: updatedBooks };
|
const updatedProfile = { ...profile, books: updatedBooks };
|
||||||
const nextData = { ...authorData, [activeProfile]: updatedProfile };
|
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/admin/save-profile", {
|
await persistProfile(activeProfile, updatedProfile);
|
||||||
method: "POST",
|
const savedSample = updatedBooks.find((book) => book.id === editingBookId || (editingBookId === "new" && book === updatedBooks.at(-1)))?.samplePdfUrl;
|
||||||
headers: {
|
if (previousSample && previousSample !== savedSample) {
|
||||||
"Content-Type": "application/json",
|
const name = previousSample.split("/").pop();
|
||||||
},
|
if (name) adminApi.deleteSample(name).catch(() => undefined);
|
||||||
body: JSON.stringify({ profileKey: activeProfile, profileData: updatedProfile }),
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
setAuthorData(nextData);
|
|
||||||
setEditingBookId(null);
|
|
||||||
setBookForm({});
|
|
||||||
} else {
|
|
||||||
alert("Fehler beim Speichern des Buches.");
|
|
||||||
}
|
}
|
||||||
|
setEditingBookId(null);
|
||||||
|
setBookForm({});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert("Netzwerkfehler.");
|
showNotice(err instanceof AdminApiError ? err.message : "Netzwerkfehler.");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -309,26 +300,16 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
||||||
const handleDeleteBook = async (bookId: string) => {
|
const handleDeleteBook = async (bookId: string) => {
|
||||||
if (!authorData || !profile || !confirm("Möchten Sie dieses Buch aus dem Bücherregal löschen?")) return;
|
if (!authorData || !profile || !confirm("Möchten Sie dieses Buch aus dem Bücherregal löschen?")) return;
|
||||||
|
|
||||||
|
const removedSample = profile.books.find((book) => book.id === bookId)?.samplePdfUrl;
|
||||||
const updatedBooks = profile.books.filter(b => b.id !== bookId);
|
const updatedBooks = profile.books.filter(b => b.id !== bookId);
|
||||||
const updatedProfile = { ...profile, books: updatedBooks };
|
const updatedProfile = { ...profile, books: updatedBooks };
|
||||||
const nextData = { ...authorData, [activeProfile]: updatedProfile };
|
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/admin/save-profile", {
|
await persistProfile(activeProfile, updatedProfile);
|
||||||
method: "POST",
|
const name = removedSample?.split("/").pop();
|
||||||
headers: {
|
if (name) adminApi.deleteSample(name).catch(() => undefined);
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ profileKey: activeProfile, profileData: updatedProfile }),
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
setAuthorData(nextData);
|
|
||||||
} else {
|
|
||||||
alert("Fehler beim Löschen des Buches.");
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert("Netzwerkfehler.");
|
showNotice(err instanceof AdminApiError ? err.message : "Netzwerkfehler.");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -337,36 +318,22 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
||||||
// --- GEMINI SYNOPSIS GENERATOR ---
|
// --- GEMINI SYNOPSIS GENERATOR ---
|
||||||
const handleGenerateAISynopsis = async () => {
|
const handleGenerateAISynopsis = async () => {
|
||||||
if (!aiTitle.trim()) {
|
if (!aiTitle.trim()) {
|
||||||
alert("Bitte geben Sie einen Buchtitel für den KI-Assistenten an.");
|
showNotice("Bitte geben Sie einen Buchtitel für den KI-Assistenten an.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setAiLoading(true);
|
setAiLoading(true);
|
||||||
setAiResult("");
|
setAiResult("");
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/admin/generate-blurb", {
|
const data = await adminApi.generateBlurb({
|
||||||
method: "POST",
|
title: aiTitle,
|
||||||
headers: {
|
genre: aiGenre || (activeProfile === "scifi" ? "Science-Fiction" : "Literatur"),
|
||||||
"Content-Type": "application/json",
|
ideas: aiIdeas,
|
||||||
},
|
tone: aiTone,
|
||||||
body: JSON.stringify({
|
|
||||||
title: aiTitle,
|
|
||||||
genre: aiGenre || activeProfile === "scifi" ? "Science-Fiction" : "Sinnliche Literatur",
|
|
||||||
ideas: aiIdeas,
|
|
||||||
tone: aiTone
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
setAiResult(data.text);
|
||||||
if (res.ok && data.text) {
|
setBookForm({ ...bookForm, description: data.text });
|
||||||
setAiResult(data.text);
|
} catch (err) {
|
||||||
setBookForm({
|
showNotice(err instanceof AdminApiError ? err.message : "Fehler bei der Verbindung zur KI-Schnittstelle.");
|
||||||
...bookForm,
|
|
||||||
description: data.text
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
alert(data.error || "Generierung fehlgeschlagen.");
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
alert("Fehler bei der Verbindung zur KI-Schnittstelle.");
|
|
||||||
} finally {
|
} finally {
|
||||||
setAiLoading(false);
|
setAiLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -407,22 +374,11 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/admin/save-legal", {
|
await persistLegalDocuments(updatedDocs);
|
||||||
method: "POST",
|
setEditingLegalId(null);
|
||||||
headers: {
|
setLegalForm({});
|
||||||
"Content-Type": "application/json",
|
} catch (err) {
|
||||||
},
|
showNotice(err instanceof AdminApiError ? err.message : "Netzwerkfehler beim Speichern.");
|
||||||
body: JSON.stringify({ legalDocuments: updatedDocs })
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
setAuthorData({ ...authorData, legalDocuments: updatedDocs });
|
|
||||||
setEditingLegalId(null);
|
|
||||||
setLegalForm({});
|
|
||||||
} else {
|
|
||||||
alert("Fehler beim Speichern des Dokuments.");
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
alert("Netzwerkfehler beim Speichern.");
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -433,24 +389,13 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
||||||
const updatedDocs = (authorData.legalDocuments || []).filter(d => d.id !== docId);
|
const updatedDocs = (authorData.legalDocuments || []).filter(d => d.id !== docId);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/admin/save-legal", {
|
await persistLegalDocuments(updatedDocs);
|
||||||
method: "POST",
|
if (editingLegalId === docId) {
|
||||||
headers: {
|
setEditingLegalId(null);
|
||||||
"Content-Type": "application/json",
|
setLegalForm({});
|
||||||
},
|
|
||||||
body: JSON.stringify({ legalDocuments: updatedDocs })
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
setAuthorData({ ...authorData, legalDocuments: updatedDocs });
|
|
||||||
if (editingLegalId === docId) {
|
|
||||||
setEditingLegalId(null);
|
|
||||||
setLegalForm({});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
alert("Fehler beim Löschen des Dokuments.");
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
alert("Netzwerkfehler beim Löschen.");
|
showNotice(err instanceof AdminApiError ? err.message : "Netzwerkfehler beim Löschen.");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -485,48 +430,7 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isLoggedIn) {
|
if (!isLoggedIn) {
|
||||||
return (
|
return <AdminLogin password={password} error={error} loading={loading} onPasswordChange={setPassword} onSubmit={handleLogin} />;
|
||||||
<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={handleLogin} 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={(e) => setPassword(e.target.value)}
|
|
||||||
placeholder="Passwort eingeben..."
|
|
||||||
className="w-full px-4 py-3 bg-slate-950 border border-slate-800 rounded-xl text-white outline-none focus:border-cyan-400 transition-colors text-sm"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<span className="text-[10px] text-slate-500">Standard-Passwort im Self-Host ist `autor2026`, anpassbar via Env-Variable `ADMIN_PASSWORD`.</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 font-sans">
|
|
||||||
{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"
|
|
||||||
>
|
|
||||||
{loading ? <RefreshCw className="w-4 h-4 animate-spin text-slate-950" /> : <span>Zentrale aufschließen</span>}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!authorData || !profile) {
|
if (!authorData || !profile) {
|
||||||
|
|
@ -540,6 +444,15 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-950 text-slate-200">
|
<div className="min-h-screen bg-slate-950 text-slate-200">
|
||||||
|
{notice && (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
className={`fixed right-4 top-4 z-[70] max-w-sm rounded-xl border px-4 py-3 text-sm shadow-2xl ${notice.tone === "success" ? "border-emerald-700 bg-emerald-950 text-emerald-100" : "border-rose-800 bg-rose-950 text-rose-100"}`}
|
||||||
|
>
|
||||||
|
{notice.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Top Navbar */}
|
{/* Top Navbar */}
|
||||||
<nav className="bg-slate-900/80 border-b border-slate-800 sticky top-0 z-30 backdrop-blur-md">
|
<nav className="bg-slate-900/80 border-b border-slate-800 sticky top-0 z-30 backdrop-blur-md">
|
||||||
|
|
@ -561,25 +474,25 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
||||||
onClick={() => { setActiveProfile("scifi"); setActiveTab("profile"); }}
|
onClick={() => { setActiveProfile("scifi"); setActiveTab("profile"); }}
|
||||||
className={`px-3 py-1.5 rounded-lg text-xs font-semibold cursor-pointer transition-all ${activeProfile === "scifi" ? "bg-cyan-500 text-slate-950" : "text-slate-400 hover:text-white"}`}
|
className={`px-3 py-1.5 rounded-lg text-xs font-semibold cursor-pointer transition-all ${activeProfile === "scifi" ? "bg-cyan-500 text-slate-950" : "text-slate-400 hover:text-white"}`}
|
||||||
>
|
>
|
||||||
Sci-Fi (Daniel Hesse)
|
Sci-Fi ({authorData.scifi.name})
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setActiveProfile("erotica"); setActiveTab("profile"); }}
|
onClick={() => { setActiveProfile("erotica"); setActiveTab("profile"); }}
|
||||||
className={`px-3 py-1.5 rounded-lg text-xs font-semibold cursor-pointer transition-all ${activeProfile === "erotica" ? "bg-rose-800 text-white" : "text-slate-400 hover:text-white"}`}
|
className={`px-3 py-1.5 rounded-lg text-xs font-semibold cursor-pointer transition-all ${activeProfile === "erotica" ? "bg-rose-800 text-white" : "text-slate-400 hover:text-white"}`}
|
||||||
>
|
>
|
||||||
Erotik ({authorData?.erotica?.name || "Annie Slone"})
|
Erotik ({authorData.erotica.name})
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setActiveProfile("clara"); setActiveTab("profile"); }}
|
onClick={() => { setActiveProfile("clara"); setActiveTab("profile"); }}
|
||||||
className={`px-3 py-1.5 rounded-lg text-xs font-semibold cursor-pointer transition-all ${activeProfile === "clara" ? "bg-emerald-700 text-white" : "text-slate-400 hover:text-white"}`}
|
className={`px-3 py-1.5 rounded-lg text-xs font-semibold cursor-pointer transition-all ${activeProfile === "clara" ? "bg-emerald-700 text-white" : "text-slate-400 hover:text-white"}`}
|
||||||
>
|
>
|
||||||
Naturlyrik ({authorData?.clara?.name || "Clara Finch"})
|
Naturlyrik ({authorData.clara.name})
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setActiveProfile("renee"); setActiveTab("profile"); }}
|
onClick={() => { setActiveProfile("renee"); setActiveTab("profile"); }}
|
||||||
className={`px-3 py-1.5 rounded-lg text-xs font-semibold cursor-pointer transition-all ${activeProfile === "renee" ? "bg-yellow-500 text-slate-950" : "text-slate-400 hover:text-white"}`}
|
className={`px-3 py-1.5 rounded-lg text-xs font-semibold cursor-pointer transition-all ${activeProfile === "renee" ? "bg-yellow-500 text-slate-950" : "text-slate-400 hover:text-white"}`}
|
||||||
>
|
>
|
||||||
Romantik ({authorData?.renee?.name || "Renee Heart"})
|
Romantik ({authorData.renee.name})
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -651,6 +564,13 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
||||||
<BookOpen className="w-4 h-4" />
|
<BookOpen className="w-4 h-4" />
|
||||||
<span>Bücherregal ({profile.books.length})</span>
|
<span>Bücherregal ({profile.books.length})</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab("downloads")}
|
||||||
|
className={`px-3 py-2.5 rounded-xl text-left text-xs font-bold transition-all flex items-center gap-2 cursor-pointer ${activeTab === "downloads" ? "bg-slate-800 border-l-4 border-indigo-500 text-white" : "text-slate-400 hover:bg-slate-800/40 hover:text-white"}`}
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
<span>Downloads ({(profile.downloads || []).length})</span>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setActiveTab("legal"); setEditingLegalId(null); setLegalForm({}); }}
|
onClick={() => { setActiveTab("legal"); setEditingLegalId(null); setLegalForm({}); }}
|
||||||
className={`px-3 py-2.5 rounded-xl text-left text-xs font-bold transition-all flex items-center gap-2 cursor-pointer ${activeTab === "legal" ? "bg-slate-800 border-l-4 border-indigo-500 text-white" : "text-slate-400 hover:bg-slate-800/40 hover:text-white"}`}
|
className={`px-3 py-2.5 rounded-xl text-left text-xs font-bold transition-all flex items-center gap-2 cursor-pointer ${activeTab === "legal" ? "bg-slate-800 border-l-4 border-indigo-500 text-white" : "text-slate-400 hover:bg-slate-800/40 hover:text-white"}`}
|
||||||
|
|
@ -754,7 +674,7 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
||||||
<p className="text-xs text-slate-500">Diese drei Kacheln erscheinen unter der Biografie. Sie können die Kategoriename (z.B. GENRE, TONALITÄT, FOKUS) und den zugehörigen Inhalt frei anpassen.</p>
|
<p className="text-xs text-slate-500">Diese drei Kacheln erscheinen unter der Biografie. Sie können die Kategoriename (z.B. GENRE, TONALITÄT, FOKUS) und den zugehörigen Inhalt frei anpassen.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{/* Schlagwort 1 */}
|
{/* Schlagwort 1 */}
|
||||||
<div className="p-3 bg-slate-950 border border-slate-800 rounded-xl space-y-2">
|
<div className="p-3 bg-slate-950 border border-slate-800 rounded-xl space-y-2">
|
||||||
<div className="text-xs font-semibold text-amber-400 uppercase flex items-center gap-1">
|
<div className="text-xs font-semibold text-amber-400 uppercase flex items-center gap-1">
|
||||||
|
|
@ -855,10 +775,10 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
||||||
type="text"
|
type="text"
|
||||||
value={profile.customDomain || ""}
|
value={profile.customDomain || ""}
|
||||||
onChange={(e) => updateProfileField("customDomain", e.target.value)}
|
onChange={(e) => updateProfileField("customDomain", e.target.value)}
|
||||||
placeholder="z.B. reneeheart.de, renee-heart.de"
|
placeholder="z.B. autorin.de, www.autorin.de"
|
||||||
className="w-full px-3 py-2 bg-slate-950 border border-slate-800 rounded-xl text-white outline-none focus:border-indigo-500 text-sm font-mono"
|
className="w-full px-3 py-2 bg-slate-950 border border-slate-800 rounded-xl text-white outline-none focus:border-indigo-500 text-sm font-mono"
|
||||||
/>
|
/>
|
||||||
<p className="text-[11px] text-slate-500">Kommagetrennte Domainnamen. Bsp: <code className="text-slate-400">reneeheart.de, renee-heart.de</code></p>
|
<p className="text-[11px] text-slate-500">Kommagetrennte Domainnamen. Bsp: <code className="text-slate-400">autorin.de, autorin.com</code></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
|
@ -906,6 +826,66 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
||||||
className="w-full px-3 py-2 bg-slate-950 border border-slate-800 rounded-xl text-white outline-none focus:border-indigo-500 text-sm font-sans"
|
className="w-full px-3 py-2 bg-slate-950 border border-slate-800 rounded-xl text-white outline-none focus:border-indigo-500 text-sm font-sans"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-3 md:col-span-2 pt-2">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-mono text-slate-400 uppercase">Optionale Buttons (maximal 3)</label>
|
||||||
|
<p className="text-[11px] text-slate-500 mt-1">Ein Button erscheint nur, wenn Beschriftung und eine vollständige HTTP(S)-Adresse eingetragen sind. Damit freigegebene Ziele werden öffentlich verlinkt.</p>
|
||||||
|
</div>
|
||||||
|
{[0, 1, 2].map((index) => (
|
||||||
|
<div key={index} className="grid grid-cols-1 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)] gap-3 rounded-xl border border-slate-800 bg-slate-950/50 p-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-[10px] font-mono text-slate-500 uppercase">Button {index + 1} – Beschriftung</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
maxLength={80}
|
||||||
|
value={profile.customSectionLinks?.[index]?.label || ""}
|
||||||
|
onChange={(event) => updateCustomSectionLink(index, "label", event.target.value)}
|
||||||
|
placeholder="z.B. Zur anderen Autorin"
|
||||||
|
className="w-full px-3 py-2 bg-slate-950 border border-slate-800 rounded-lg text-white outline-none focus:border-indigo-500 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-[10px] font-mono text-slate-500 uppercase">Ziel-URL</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={profile.customSectionLinks?.[index]?.url || ""}
|
||||||
|
onChange={(event) => updateCustomSectionLink(index, "url", event.target.value)}
|
||||||
|
placeholder="https://www.beispiel.de"
|
||||||
|
className="w-full px-3 py-2 bg-slate-950 border border-slate-800 rounded-lg text-white outline-none focus:border-indigo-500 text-sm font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contact Section */}
|
||||||
|
<div className="md:col-span-2 border-t border-slate-800/80 pt-6 mt-2 space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-bold text-white flex items-center gap-2">
|
||||||
|
<Globe className="w-4 h-4 text-emerald-400" />
|
||||||
|
<span>Kontakt & Social Media</span>
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-slate-500">Der Kontaktabschnitt erscheint unter dem zusätzlichen Textmodul. Es werden ausschließlich ausgefüllte Angaben angezeigt.</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-mono text-slate-400 uppercase">E-Mail-Adresse</label>
|
||||||
|
<input type="email" value={profile.contactEmail || ""} onChange={(event) => updateProfileField("contactEmail", event.target.value)} placeholder="kontakt@autorin.de" className="w-full px-3 py-2 bg-slate-950 border border-slate-800 rounded-xl text-white outline-none focus:border-indigo-500 text-sm" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-mono text-slate-400 uppercase">Instagram-Profil</label>
|
||||||
|
<input type="url" value={profile.instagramUrl || ""} onChange={(event) => updateProfileField("instagramUrl", event.target.value)} placeholder="https://www.instagram.com/…" className="w-full px-3 py-2 bg-slate-950 border border-slate-800 rounded-xl text-white outline-none focus:border-indigo-500 text-sm" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-mono text-slate-400 uppercase">Threads-Profil</label>
|
||||||
|
<input type="url" value={profile.threadsUrl || ""} onChange={(event) => updateProfileField("threadsUrl", event.target.value)} placeholder="https://www.threads.net/@…" className="w-full px-3 py-2 bg-slate-950 border border-slate-800 rounded-xl text-white outline-none focus:border-indigo-500 text-sm" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-mono text-slate-400 uppercase">Discord-Einladung / Community</label>
|
||||||
|
<input type="url" value={profile.discordUrl || ""} onChange={(event) => updateProfileField("discordUrl", event.target.value)} placeholder="https://discord.gg/…" className="w-full px-3 py-2 bg-slate-950 border border-slate-800 rounded-xl text-white outline-none focus:border-indigo-500 text-sm" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1458,6 +1438,7 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
||||||
setBookForm({
|
setBookForm({
|
||||||
title: "", subtitle: "", coverUrl: "", description: "",
|
title: "", subtitle: "", coverUrl: "", description: "",
|
||||||
publishedYear: new Date().getFullYear(), buyLink: "",
|
publishedYear: new Date().getFullYear(), buyLink: "",
|
||||||
|
ebookLink: "", paperbackLink: "", samplePdfUrl: "", seriesName: "", seriesNumber: "",
|
||||||
spotifyPlaylistId: "", genres: [], isSpotlight: false, spotlightBadge: ""
|
spotifyPlaylistId: "", genres: [], isSpotlight: false, spotlightBadge: ""
|
||||||
});
|
});
|
||||||
setGenreInputRaw("");
|
setGenreInputRaw("");
|
||||||
|
|
@ -1593,6 +1574,14 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
||||||
className="w-full px-3 py-2 bg-slate-950 border border-slate-800 rounded-xl text-white outline-none focus:border-indigo-500 text-sm"
|
className="w-full px-3 py-2 bg-slate-950 border border-slate-800 rounded-xl text-white outline-none focus:border-indigo-500 text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-mono text-slate-400 uppercase">Serienname (optional)</label>
|
||||||
|
<input type="text" value={bookForm.seriesName || ""} onChange={(event) => setBookForm({ ...bookForm, seriesName: event.target.value })} placeholder="z.B. Arche-Chroniken" className="w-full px-3 py-2 bg-slate-950 border border-slate-800 rounded-xl text-white outline-none focus:border-indigo-500 text-sm" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-mono text-slate-400 uppercase">Bandnummer (optional)</label>
|
||||||
|
<input type="number" min="0" max="999" step="1" value={bookForm.seriesNumber || ""} onChange={(event) => setBookForm({ ...bookForm, seriesNumber: event.target.value })} placeholder="1" className="w-full px-3 py-2 bg-slate-950 border border-slate-800 rounded-xl text-white outline-none focus:border-indigo-500 text-sm" />
|
||||||
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-xs font-mono text-slate-400 uppercase font-bold text-white">Cover-Bild</label>
|
<label className="text-xs font-mono text-slate-400 uppercase font-bold text-white">Cover-Bild</label>
|
||||||
<ImagePicker
|
<ImagePicker
|
||||||
|
|
@ -1615,16 +1604,20 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-xs font-mono text-slate-400 uppercase flex items-center gap-1">
|
<label className="text-xs font-mono text-slate-400 uppercase flex items-center gap-1">
|
||||||
<Eye className="w-3.5 h-3.5" />
|
<Eye className="w-3.5 h-3.5" />
|
||||||
<span>Buy-Link / Shop-Link (z.B. Amazon, Thalia)</span>
|
<span>KDP-Link: E-Book</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="url"
|
||||||
value={bookForm.buyLink || ""}
|
value={bookForm.ebookLink || bookForm.buyLink || ""}
|
||||||
onChange={(e) => setBookForm({ ...bookForm, buyLink: e.target.value })}
|
onChange={(e) => setBookForm({ ...bookForm, ebookLink: e.target.value, buyLink: "" })}
|
||||||
placeholder="https://..."
|
placeholder="https://..."
|
||||||
className="w-full px-3 py-2 bg-slate-950 border border-slate-800 rounded-xl text-white outline-none focus:border-indigo-500 text-sm"
|
className="w-full px-3 py-2 bg-slate-950 border border-slate-800 rounded-xl text-white outline-none focus:border-indigo-500 text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-mono text-slate-400 uppercase flex items-center gap-1"><BookOpen className="w-3.5 h-3.5" /><span>KDP-Link: Taschenbuch</span></label>
|
||||||
|
<input type="url" value={bookForm.paperbackLink || ""} onChange={(event) => setBookForm({ ...bookForm, paperbackLink: event.target.value })} placeholder="https://..." className="w-full px-3 py-2 bg-slate-950 border border-slate-800 rounded-xl text-white outline-none focus:border-indigo-500 text-sm" />
|
||||||
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-xs font-mono text-slate-400 uppercase flex items-center gap-1">
|
<label className="text-xs font-mono text-slate-400 uppercase flex items-center gap-1">
|
||||||
<Music className="w-3.5 h-3.5" />
|
<Music className="w-3.5 h-3.5" />
|
||||||
|
|
@ -1650,6 +1643,10 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
||||||
/>
|
/>
|
||||||
<p className="text-[10px] text-slate-500">Trennen Sie mehrere Genres einfach durch Kommas.</p>
|
<p className="text-[10px] text-slate-500">Trennen Sie mehrere Genres einfach durch Kommas.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-1 md:col-span-2">
|
||||||
|
<label className="text-xs font-mono text-slate-400 uppercase">PDF-Leseprobe (optional)</label>
|
||||||
|
<SamplePdfPicker value={bookForm.samplePdfUrl || ""} onChange={(url) => setBookForm({ ...bookForm, samplePdfUrl: url })} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Buch-Klappentext Synopsis / Description */}
|
{/* Buch-Klappentext Synopsis / Description */}
|
||||||
<div className="space-y-1 md:col-span-2">
|
<div className="space-y-1 md:col-span-2">
|
||||||
|
|
@ -1770,6 +1767,25 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeTab === "downloads" && (
|
||||||
|
<DownloadsManager
|
||||||
|
downloads={profile.downloads || []}
|
||||||
|
title={profile.downloadsTitle || "Kurzgeschichten & Downloads"}
|
||||||
|
subtitle={profile.downloadsSubtitle || "Kostenlose Geschichten zum Herunterladen."}
|
||||||
|
loading={loading}
|
||||||
|
onError={(message) => showNotice(message)}
|
||||||
|
onSave={async (downloads, downloadsTitle, downloadsSubtitle) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await persistProfile(activeProfile, { ...profile, downloads, downloadsTitle, downloadsSubtitle });
|
||||||
|
showNotice("Download-Bereich gespeichert.", "success");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* TAB: LEGAL DOCUMENTS (IMPRESSUM & DATENSCHUTZ) */}
|
{/* TAB: LEGAL DOCUMENTS (IMPRESSUM & DATENSCHUTZ) */}
|
||||||
{activeTab === "legal" && (
|
{activeTab === "legal" && (
|
||||||
<div className="p-6 md:p-8 space-y-8">
|
<div className="p-6 md:p-8 space-y-8">
|
||||||
|
|
@ -1840,10 +1856,10 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
||||||
<div className="grid grid-cols-1 gap-4">
|
<div className="grid grid-cols-1 gap-4">
|
||||||
{authorData.legalDocuments.map((doc) => {
|
{authorData.legalDocuments.map((doc) => {
|
||||||
const profileNamesMap = {
|
const profileNamesMap = {
|
||||||
scifi: authorData.scifi?.name || "Daniel Hesse",
|
scifi: authorData.scifi.name,
|
||||||
erotica: authorData.erotica?.name || "Annie Slone",
|
erotica: authorData.erotica.name,
|
||||||
clara: authorData.clara?.name || "Clara Finch",
|
clara: authorData.clara.name,
|
||||||
renee: authorData.renee?.name || "Renee Heart"
|
renee: authorData.renee.name
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -2044,10 +2060,10 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-3 pt-2">
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-3 pt-2">
|
||||||
{[
|
{[
|
||||||
{ key: "scifi" as const, name: authorData.scifi?.name || "Daniel Hesse", badge: "Sci-Fi" },
|
{ key: "scifi" as const, name: authorData.scifi.name, badge: "Sci-Fi" },
|
||||||
{ key: "erotica" as const, name: authorData.erotica?.name || "Annie Slone", badge: "Erotik" },
|
{ key: "erotica" as const, name: authorData.erotica.name, badge: "Erotik" },
|
||||||
{ key: "clara" as const, name: authorData.clara?.name || "Clara Finch", badge: "Naturlyrik" },
|
{ key: "clara" as const, name: authorData.clara.name, badge: "Naturlyrik" },
|
||||||
{ key: "renee" as const, name: authorData.renee?.name || "Renee Heart", badge: "Romantik" }
|
{ key: "renee" as const, name: authorData.renee.name, badge: "Romantik" }
|
||||||
].map((item) => {
|
].map((item) => {
|
||||||
const checked = legalForm.assignedProfiles?.includes(item.key);
|
const checked = legalForm.assignedProfiles?.includes(item.key);
|
||||||
return (
|
return (
|
||||||
|
|
@ -2094,22 +2110,22 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
||||||
type: "impressum",
|
type: "impressum",
|
||||||
content: `### Angaben gemäß § 5 Digitale-Dienste-Gesetz (DDG)
|
content: `### Angaben gemäß § 5 Digitale-Dienste-Gesetz (DDG)
|
||||||
|
|
||||||
**Daniel Hesse**
|
**Vor- und Nachname**
|
||||||
Musterstraße 12
|
Straße und Hausnummer
|
||||||
10115 Berlin
|
PLZ Ort
|
||||||
Deutschland
|
Deutschland
|
||||||
|
|
||||||
### Kontakt
|
### Kontakt
|
||||||
**E-Mail:** kontakt@danielhesse.de
|
**E-Mail:** kontakt@example.org
|
||||||
**Website:** https://danielhesse.de
|
**Website:** https://example.org
|
||||||
|
|
||||||
### Redaktionell verantwortlich
|
### Redaktionell verantwortlich
|
||||||
Daniel Hesse
|
Vor- und Nachname
|
||||||
Musterstraße 12
|
Straße und Hausnummer
|
||||||
10115 Berlin
|
PLZ Ort
|
||||||
|
|
||||||
### Künstlerische Pseudonyme & Urheberrecht
|
### 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.
|
Die auf dieser Internetpräsenz präsentierten literarischen Werke und Inhalte sind urheberrechtlich geschützt.
|
||||||
|
|
||||||
### EU-Streitschlichtung
|
### EU-Streitschlichtung
|
||||||
Die Europäische Kommission stellt eine Plattform zur Online-Streitbeilegung (OS) bereit: https://ec.europa.eu/consumers/odr/
|
Die Europäische Kommission stellt eine Plattform zur Online-Streitbeilegung (OS) bereit: https://ec.europa.eu/consumers/odr/
|
||||||
|
|
@ -2139,10 +2155,10 @@ Die folgenden Hinweise geben einen einfachen Überblick darüber, was mit Ihren
|
||||||
### 2. Verantwortliche Stelle
|
### 2. Verantwortliche Stelle
|
||||||
Verantwortlich für die Datenverarbeitung auf dieser Website ist:
|
Verantwortlich für die Datenverarbeitung auf dieser Website ist:
|
||||||
|
|
||||||
**Daniel Hesse**
|
**Vor- und Nachname**
|
||||||
Musterstraße 12
|
Straße und Hausnummer
|
||||||
10115 Berlin
|
PLZ Ort
|
||||||
E-Mail: kontakt@danielhesse.de
|
E-Mail: kontakt@example.org
|
||||||
|
|
||||||
### 3. Datenerfassung auf dieser Website
|
### 3. Datenerfassung auf dieser Website
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,492 +0,0 @@
|
||||||
import { useState } from "react";
|
|
||||||
import Markdown from "react-markdown";
|
|
||||||
import { Book, Project, AuthorProfile, LegalDocument } from "../types";
|
|
||||||
import { Leaf, Feather, Sparkles, BookOpen, ExternalLink, ArrowRight } from "lucide-react";
|
|
||||||
import SpotlightSection from "./SpotlightSection";
|
|
||||||
import LegalModal from "./LegalModal";
|
|
||||||
import ProjectDetailMedia from "./ProjectDetailMedia";
|
|
||||||
import SpotifyConsentPlayer from "./SpotifyConsentPlayer";
|
|
||||||
|
|
||||||
interface ClaraPortfolioProps {
|
|
||||||
data: AuthorProfile;
|
|
||||||
legalDocuments?: LegalDocument[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ClaraPortfolio({ data, legalDocuments = [] }: ClaraPortfolioProps) {
|
|
||||||
const [selectedBook, setSelectedBook] = useState<Book | null>(null);
|
|
||||||
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
|
|
||||||
const [legalModalOpen, setLegalModalOpen] = useState(false);
|
|
||||||
const [activeLegalId, setActiveLegalId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const relevantLegalDocs = legalDocuments.filter(doc => doc.assignedProfiles.includes("clara"));
|
|
||||||
|
|
||||||
const accentColor = data.accentColor || "#8FA797";
|
|
||||||
const secondaryColor = data.secondaryColor || "#B59EB1";
|
|
||||||
const bgColor = data.backgroundColor || "#0c0a09";
|
|
||||||
const cardBgColor = data.cardBgColor || "#1c1917";
|
|
||||||
|
|
||||||
const fontClass = data.fontFamily === "sans" ? "font-sans" : data.fontFamily === "mono" ? "font-mono" : "font-serif";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<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 || undefined}
|
|
||||||
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 || undefined}
|
|
||||||
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 || undefined}
|
|
||||||
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>
|
|
||||||
|
|
||||||
{/* Privacy-friendly Spotify player */}
|
|
||||||
{selectedBook.spotifyPlaylistId && selectedBook.spotifyPlaylistId.trim() !== "" && (
|
|
||||||
<SpotifyConsentPlayer playlist={selectedBook.spotifyPlaylistId} title={`Spotify-Playlist zu ${selectedBook.title}`} accentColor={accentColor} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
</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-5xl 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>{data.projectProgressLabel ? `${data.projectProgressLabel.toUpperCase()}:` : "ENTWURF-FORTSCHRITT:"}</span>
|
|
||||||
<span className="font-bold" style={{ color: accentColor }}>{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 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-[#4A5D50]/5 p-5 rounded-xl border border-[#4A5D50]/20 relative overflow-hidden">
|
|
||||||
{data.projectExcerptBadge && (
|
|
||||||
<div className="absolute top-0 right-0 p-2 text-stone-700 text-[10px] font-mono select-none">
|
|
||||||
{data.projectExcerptBadge}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<h4 className="text-xs font-sans uppercase tracking-wider mb-3 italic" style={{ color: accentColor }}>
|
|
||||||
{data.projectExcerptTitle || "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>
|
|
||||||
</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-3">
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{relevantLegalDocs.length > 0 && (
|
|
||||||
<div className="flex flex-wrap items-center justify-center gap-6 pt-3 border-t border-stone-800/60 max-w-md mx-auto not-italic">
|
|
||||||
{relevantLegalDocs.map((doc) => (
|
|
||||||
<button
|
|
||||||
key={doc.id}
|
|
||||||
onClick={() => {
|
|
||||||
setActiveLegalId(doc.id);
|
|
||||||
setLegalModalOpen(true);
|
|
||||||
}}
|
|
||||||
className="text-stone-400 hover:text-[#8FA797] 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 || "serif"}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,492 +0,0 @@
|
||||||
import { useState } from "react";
|
|
||||||
import Markdown from "react-markdown";
|
|
||||||
import { Book, Project, AuthorProfile, LegalDocument } from "../types";
|
|
||||||
import { Heart, Key, Sparkles, BookOpen, ExternalLink, ArrowRight } from "lucide-react";
|
|
||||||
import SpotlightSection from "./SpotlightSection";
|
|
||||||
import LegalModal from "./LegalModal";
|
|
||||||
import ProjectDetailMedia from "./ProjectDetailMedia";
|
|
||||||
import SpotifyConsentPlayer from "./SpotifyConsentPlayer";
|
|
||||||
|
|
||||||
interface EroticaPortfolioProps {
|
|
||||||
data: AuthorProfile;
|
|
||||||
legalDocuments?: LegalDocument[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function EroticaPortfolio({ data, legalDocuments = [] }: EroticaPortfolioProps) {
|
|
||||||
const [selectedBook, setSelectedBook] = useState<Book | null>(null);
|
|
||||||
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
|
|
||||||
const [legalModalOpen, setLegalModalOpen] = useState(false);
|
|
||||||
const [activeLegalId, setActiveLegalId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const relevantLegalDocs = legalDocuments.filter(doc => doc.assignedProfiles.includes("erotica"));
|
|
||||||
|
|
||||||
const accentColor = data.accentColor || "#f97316";
|
|
||||||
const secondaryColor = data.secondaryColor || "#f59e0b";
|
|
||||||
const bgColor = data.backgroundColor || "#0a0a0a";
|
|
||||||
const cardBgColor = data.cardBgColor || "#171717";
|
|
||||||
|
|
||||||
const fontClass = data.fontFamily === "sans" ? "font-sans" : data.fontFamily === "mono" ? "font-mono" : "font-serif";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<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 || undefined}
|
|
||||||
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 || undefined}
|
|
||||||
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 || undefined}
|
|
||||||
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>
|
|
||||||
|
|
||||||
{/* Privacy-friendly Spotify player */}
|
|
||||||
{selectedBook.spotifyPlaylistId && selectedBook.spotifyPlaylistId.trim() !== "" && (
|
|
||||||
<SpotifyConsentPlayer playlist={selectedBook.spotifyPlaylistId} title={`Spotify-Playlist zu ${selectedBook.title}`} accentColor={accentColor} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
</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-5xl 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-400">
|
|
||||||
<span>{data.projectProgressLabel ? `${data.projectProgressLabel.toUpperCase()}:` : "REIFEGRAD DES WERKS:"}</span>
|
|
||||||
<span className="font-bold" style={{ color: accentColor }}>{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 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-orange-950/10 p-5 rounded-xl border border-orange-950/20 relative overflow-hidden">
|
|
||||||
{data.projectExcerptBadge && (
|
|
||||||
<div className="absolute top-0 right-0 p-2 text-neutral-700 text-[10px] font-mono select-none">
|
|
||||||
{data.projectExcerptBadge}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<h4 className="text-xs font-sans uppercase tracking-wider mb-3 italic" style={{ color: accentColor }}>
|
|
||||||
{data.projectExcerptTitle || "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>
|
|
||||||
</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-3">
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{relevantLegalDocs.length > 0 && (
|
|
||||||
<div className="flex flex-wrap items-center justify-center gap-6 pt-3 border-t border-neutral-800/60 max-w-md mx-auto not-italic">
|
|
||||||
{relevantLegalDocs.map((doc) => (
|
|
||||||
<button
|
|
||||||
key={doc.id}
|
|
||||||
onClick={() => {
|
|
||||||
setActiveLegalId(doc.id);
|
|
||||||
setLegalModalOpen(true);
|
|
||||||
}}
|
|
||||||
className="text-neutral-400 hover:text-orange-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 || "serif"}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import React, { useState, useEffect, useRef } from "react";
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
import { Upload, Image as ImageIcon, Link as LinkIcon, X, Check, Loader2, FolderOpen, AlertCircle } 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 {
|
interface ImagePickerProps {
|
||||||
value: string;
|
value: string;
|
||||||
|
|
@ -39,18 +40,10 @@ export default function ImagePicker({ value, onChange, label }: ImagePickerProps
|
||||||
setLoadingFiles(true);
|
setLoadingFiles(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/admin/list-uploads");
|
const data = await adminApi.listUploads();
|
||||||
if (!response.ok) {
|
setServerFiles(data.files || []);
|
||||||
throw new Error("Fehler beim Laden der Serverdateien");
|
} catch (err) {
|
||||||
}
|
setError(err instanceof AdminApiError ? err.message : "Verbindung zum Server fehlgeschlagen.");
|
||||||
const data = await response.json();
|
|
||||||
if (data.success) {
|
|
||||||
setServerFiles(data.files || []);
|
|
||||||
} else {
|
|
||||||
setError(data.error || "Laden der Dateien fehlgeschlagen.");
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
setError("Verbindung zum Server fehlgeschlagen.");
|
|
||||||
console.error(err);
|
console.error(err);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingFiles(false);
|
setLoadingFiles(false);
|
||||||
|
|
@ -78,31 +71,11 @@ export default function ImagePicker({ value, onChange, label }: ImagePickerProps
|
||||||
reader.onloadend = async () => {
|
reader.onloadend = async () => {
|
||||||
const base64Data = reader.result as string;
|
const base64Data = reader.result as string;
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/admin/upload-file", {
|
const data = await adminApi.upload(file.name, base64Data);
|
||||||
method: "POST",
|
onChange(data.url);
|
||||||
headers: {
|
setIsOpen(false);
|
||||||
"Content-Type": "application/json",
|
} catch (err) {
|
||||||
},
|
setError(err instanceof AdminApiError ? err.message : "Upload fehlgeschlagen.");
|
||||||
body: JSON.stringify({
|
|
||||||
fileName: file.name,
|
|
||||||
base64Data: base64Data,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const responseData = await response.json().catch(() => null);
|
|
||||||
throw new Error(responseData?.error || "Serverfehler beim Upload.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
if (data.success) {
|
|
||||||
onChange(data.url);
|
|
||||||
setIsOpen(false);
|
|
||||||
} else {
|
|
||||||
setError(data.error || "Upload fehlgeschlagen.");
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
setError("Upload-Fehler: " + err.message);
|
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -156,6 +129,18 @@ export default function ImagePicker({ value, onChange, label }: ImagePickerProps
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDeleteServerFile = async (file: ServerFile) => {
|
||||||
|
if (!confirm(`Bild „${file.name}“ wirklich dauerhaft löschen?`)) return;
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await adminApi.deleteUpload(file.name);
|
||||||
|
setServerFiles((files) => files.filter((candidate) => candidate.name !== file.name));
|
||||||
|
if (value === file.url) onChange("");
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof AdminApiError ? err.message : "Die Datei konnte nicht gelöscht werden.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|
@ -330,22 +315,22 @@ export default function ImagePicker({ value, onChange, label }: ImagePickerProps
|
||||||
{serverFiles.map((file) => {
|
{serverFiles.map((file) => {
|
||||||
const isSelected = value === file.url;
|
const isSelected = value === file.url;
|
||||||
return (
|
return (
|
||||||
<button
|
<div
|
||||||
key={file.name}
|
key={file.name}
|
||||||
type="button"
|
|
||||||
onClick={() => handleSelectServerFile(file.url)}
|
|
||||||
className={`group relative aspect-square rounded-xl overflow-hidden border bg-slate-950 text-left transition-all ${
|
className={`group relative aspect-square rounded-xl overflow-hidden border bg-slate-950 text-left transition-all ${
|
||||||
isSelected
|
isSelected
|
||||||
? "border-indigo-500 ring-2 ring-indigo-500/20"
|
? "border-indigo-500 ring-2 ring-indigo-500/20"
|
||||||
: "border-slate-800 hover:border-slate-700"
|
: "border-slate-800 hover:border-slate-700"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
<button type="button" onClick={() => handleSelectServerFile(file.url)} className="absolute inset-0 z-0 w-full h-full">
|
||||||
<img
|
<img
|
||||||
src={file.url}
|
src={file.url}
|
||||||
alt={file.name}
|
alt={file.name}
|
||||||
className="w-full h-full object-cover transition-transform group-hover:scale-105"
|
className="w-full h-full object-cover transition-transform group-hover:scale-105"
|
||||||
referrerPolicy="no-referrer"
|
referrerPolicy="no-referrer"
|
||||||
/>
|
/>
|
||||||
|
</button>
|
||||||
{/* Overlay filename on hover */}
|
{/* Overlay filename on hover */}
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/40 to-transparent p-2 flex flex-col justify-end opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/40 to-transparent p-2 flex flex-col justify-end opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
<p className="text-[9px] text-slate-200 truncate font-mono">{file.name}</p>
|
<p className="text-[9px] text-slate-200 truncate font-mono">{file.name}</p>
|
||||||
|
|
@ -356,7 +341,16 @@ export default function ImagePicker({ value, onChange, label }: ImagePickerProps
|
||||||
<Check className="w-3 h-3" />
|
<Check className="w-3 h-3" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</button>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDeleteServerFile(file)}
|
||||||
|
className="absolute top-1.5 left-1.5 z-20 rounded-md bg-black/75 p-1.5 text-slate-300 opacity-0 transition-opacity hover:bg-rose-900 hover:text-white group-hover:opacity-100 focus:opacity-100"
|
||||||
|
aria-label={`${file.name} löschen`}
|
||||||
|
title="Bild löschen"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,113 @@
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import Markdown from "react-markdown";
|
import Markdown from "react-markdown";
|
||||||
import { Book, Project, AuthorProfile, LegalDocument } from "../types";
|
import { Book, Project, AuthorProfile, LegalDocument, PortfolioTheme } from "../types";
|
||||||
import { BookOpen, Star, HelpCircle, Award, Compass, ArrowRight, ExternalLink, Moon } from "lucide-react";
|
import { 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 SpotlightSection from "./SpotlightSection";
|
||||||
import LegalModal from "./LegalModal";
|
import LegalModal from "./LegalModal";
|
||||||
import ProjectDetailMedia from "./ProjectDetailMedia";
|
import ProjectDetailMedia from "./ProjectDetailMedia";
|
||||||
import SpotifyConsentPlayer from "./SpotifyConsentPlayer";
|
import SpotifyConsentPlayer from "./SpotifyConsentPlayer";
|
||||||
|
import AccessibleModal from "./AccessibleModal";
|
||||||
|
|
||||||
interface SciFiPortfolioProps {
|
interface PortfolioPageProps {
|
||||||
data: AuthorProfile;
|
data: AuthorProfile;
|
||||||
legalDocuments?: LegalDocument[];
|
legalDocuments?: LegalDocument[];
|
||||||
|
theme: PortfolioTheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SciFiPortfolio({ data, legalDocuments = [] }: SciFiPortfolioProps) {
|
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 [selectedBook, setSelectedBook] = useState<Book | null>(null);
|
||||||
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
|
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
|
||||||
const [legalModalOpen, setLegalModalOpen] = useState(false);
|
const [legalModalOpen, setLegalModalOpen] = useState(false);
|
||||||
const [activeLegalId, setActiveLegalId] = useState<string | null>(null);
|
const [activeLegalId, setActiveLegalId] = useState<string | null>(null);
|
||||||
|
const [shareNotice, setShareNotice] = useState("");
|
||||||
|
const [showAllDownloads, setShowAllDownloads] = useState(false);
|
||||||
|
|
||||||
const relevantLegalDocs = legalDocuments.filter(doc => doc.assignedProfiles.includes("scifi"));
|
const relevantLegalDocs = legalDocuments;
|
||||||
|
|
||||||
const accentColor = data.accentColor || "#06b6d4";
|
const defaults = themeDefaults[theme];
|
||||||
const secondaryColor = data.secondaryColor || "#a855f7";
|
const accentColor = data.accentColor || defaults.accent;
|
||||||
const bgColor = data.backgroundColor || "#020617";
|
const secondaryColor = data.secondaryColor || defaults.secondary;
|
||||||
const cardBgColor = data.cardBgColor || "#0f172a";
|
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 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -60,14 +142,14 @@ export default function SciFiPortfolio({ data, legalDocuments = [] }: SciFiPortf
|
||||||
style={{ borderColor: `${accentColor}40`, color: accentColor }}
|
style={{ borderColor: `${accentColor}40`, color: accentColor }}
|
||||||
>
|
>
|
||||||
<Star className="w-3.5 h-3.5 animate-pulse" style={{ color: accentColor }} />
|
<Star className="w-3.5 h-3.5 animate-pulse" style={{ color: accentColor }} />
|
||||||
<span>{data.badgeText || "Cyber-Mystisches Archive"}</span>
|
<span>{data.badgeText || defaults.badge}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 className="text-5xl md:text-7xl tracking-tight text-white mb-4 uppercase drop-shadow-md font-bold">
|
<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"}
|
{data.heroTitle || data.name}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="font-mono text-xs md:text-sm tracking-widest uppercase mb-8" style={{ color: accentColor }}>
|
<p className="font-mono text-xs md:text-sm tracking-widest uppercase mb-8" style={{ color: accentColor }}>
|
||||||
{data.heroSubtitle || "WELTENWANDLER & STERNENZEICHNER"}
|
{data.heroSubtitle || defaults.subtitle}
|
||||||
</p>
|
</p>
|
||||||
<div
|
<div
|
||||||
className="h-0.5 w-24 mx-auto rounded-full"
|
className="h-0.5 w-24 mx-auto rounded-full"
|
||||||
|
|
@ -94,6 +176,11 @@ export default function SciFiPortfolio({ data, legalDocuments = [] }: SciFiPortf
|
||||||
>
|
>
|
||||||
🪐 {data.booksTitle || "Bücherregal"}
|
🪐 {data.booksTitle || "Bücherregal"}
|
||||||
</a>
|
</a>
|
||||||
|
{availableDownloads.length > 0 && (
|
||||||
|
<a href="#downloads" className="px-4 py-2 rounded-full border border-slate-800 bg-slate-900/60 hover:border-cyan-400 hover:text-cyan-400 transition-all text-slate-300 uppercase tracking-wider">
|
||||||
|
⬇ {data.downloadsTitle || "Downloads"}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
@ -135,7 +222,7 @@ export default function SciFiPortfolio({ data, legalDocuments = [] }: SciFiPortf
|
||||||
<BookOpen className="w-5 h-5 shrink-0" style={{ color: secondaryColor }} />
|
<BookOpen className="w-5 h-5 shrink-0" style={{ color: secondaryColor }} />
|
||||||
<div>
|
<div>
|
||||||
<div className="text-slate-500 uppercase tracking-widest">{data.bioTag1Label || "Fokus"}</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 className="text-white font-semibold">{data.bioTag1Value || "Literatur"}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -144,7 +231,7 @@ export default function SciFiPortfolio({ data, legalDocuments = [] }: SciFiPortf
|
||||||
<Star className="w-5 h-5 shrink-0" style={{ color: accentColor }} />
|
<Star className="w-5 h-5 shrink-0" style={{ color: accentColor }} />
|
||||||
<div>
|
<div>
|
||||||
<div className="text-slate-500 uppercase tracking-widest">{data.bioTag2Label || "Welten"}</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 className="text-white font-semibold">{data.bioTag2Value || "Geschichten"}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -153,7 +240,7 @@ export default function SciFiPortfolio({ data, legalDocuments = [] }: SciFiPortf
|
||||||
<Award className="w-5 h-5 shrink-0" style={{ color: secondaryColor }} />
|
<Award className="w-5 h-5 shrink-0" style={{ color: secondaryColor }} />
|
||||||
<div>
|
<div>
|
||||||
<div className="text-slate-500 uppercase tracking-widest">{data.bioTag3Label || "Stil"}</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 className="text-white font-semibold">{data.bioTag3Value || "Atmosphärisch"}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -184,7 +271,7 @@ export default function SciFiPortfolio({ data, legalDocuments = [] }: SciFiPortf
|
||||||
<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>
|
<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>
|
||||||
<div className="text-xs font-mono px-3 py-1 rounded border" style={{ color: accentColor, backgroundColor: `${accentColor}10`, borderColor: `${accentColor}30` }}>
|
<div className="text-xs font-mono px-3 py-1 rounded border" style={{ color: accentColor, backgroundColor: `${accentColor}10`, borderColor: `${accentColor}30` }}>
|
||||||
Laufendes Telemetrie-Tracking
|
Aktuell in Arbeit
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -192,7 +279,7 @@ export default function SciFiPortfolio({ data, legalDocuments = [] }: SciFiPortf
|
||||||
{data.projects.map((project) => (
|
{data.projects.map((project) => (
|
||||||
<button
|
<button
|
||||||
key={project.id}
|
key={project.id}
|
||||||
onClick={() => setSelectedProject(project)}
|
onClick={() => openDetail("project", project.id)}
|
||||||
className="p-6 border rounded-xl transition-all duration-300 flex flex-col text-left w-full justify-between group cursor-pointer hover:scale-[1.01] focus:outline-none shadow-lg"
|
className="p-6 border rounded-xl transition-all duration-300 flex flex-col text-left w-full justify-between group cursor-pointer hover:scale-[1.01] focus:outline-none shadow-lg"
|
||||||
style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}25` }}
|
style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}25` }}
|
||||||
>
|
>
|
||||||
|
|
@ -254,10 +341,10 @@ export default function SciFiPortfolio({ data, legalDocuments = [] }: SciFiPortf
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-6 justify-items-center">
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-6 justify-items-center">
|
||||||
{data.books.map((book) => (
|
{displayedBooks.map((book) => (
|
||||||
<button
|
<button
|
||||||
key={book.id}
|
key={book.id}
|
||||||
onClick={() => setSelectedBook(book)}
|
onClick={() => openDetail("book", book.id)}
|
||||||
className="group relative focus:outline-none text-left w-full max-w-[210px] aspect-[2/3] rounded-xl overflow-hidden border hover:border-cyan-400 transition-all duration-300 hover:shadow-xl cursor-pointer"
|
className="group relative focus:outline-none text-left w-full max-w-[210px] aspect-[2/3] rounded-xl overflow-hidden border hover:border-cyan-400 transition-all duration-300 hover:shadow-xl cursor-pointer"
|
||||||
style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}30` }}
|
style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}30` }}
|
||||||
>
|
>
|
||||||
|
|
@ -271,15 +358,48 @@ export default function SciFiPortfolio({ data, legalDocuments = [] }: SciFiPortf
|
||||||
|
|
||||||
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-slate-950 via-slate-950/90 to-transparent p-4 opacity-80 group-hover:opacity-100 transition-all duration-350 z-20">
|
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-slate-950 via-slate-950/90 to-transparent p-4 opacity-80 group-hover:opacity-100 transition-all duration-350 z-20">
|
||||||
<p className="text-white text-xs font-bold truncate">{book.title}</p>
|
<p className="text-white text-xs font-bold truncate">{book.title}</p>
|
||||||
<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>
|
{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>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{availableDownloads.length > 0 && (
|
||||||
|
<section id="downloads" className="space-y-8 scroll-mt-6">
|
||||||
|
<div className="border-b border-slate-800 pb-4">
|
||||||
|
<h2 className="flex items-center gap-3 text-3xl font-bold tracking-tight text-white"><Download className="h-7 w-7" style={{ color: accentColor }} /><span>{data.downloadsTitle || "Kurzgeschichten & Downloads"}</span></h2>
|
||||||
|
{data.downloadsSubtitle && <p className="mt-1 text-sm text-slate-400">{data.downloadsSubtitle}</p>}
|
||||||
|
</div>
|
||||||
|
<div id="download-grid" className="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{visibleDownloads.map((entry) => (
|
||||||
|
<article key={entry.id} className="flex flex-col overflow-hidden rounded-2xl border shadow-lg" style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}30` }}>
|
||||||
|
{entry.imageUrl && <img src={entry.imageUrl} alt={`Motiv zu ${entry.title}`} className="block h-auto w-full" loading="lazy" referrerPolicy="no-referrer" />}
|
||||||
|
<div className="flex flex-1 flex-col p-6">
|
||||||
|
{entry.publishedAt && <p className="mb-2 text-[10px] font-mono uppercase tracking-widest text-slate-500">{entry.publishedAt}</p>}
|
||||||
|
<h3 className="text-xl font-bold text-white">{entry.title}</h3>
|
||||||
|
{entry.description && <div className="mt-3 flex-1 text-sm leading-relaxed text-slate-300 markdown-body"><Markdown>{entry.description}</Markdown></div>}
|
||||||
|
<div className="mt-6 flex flex-col gap-2 sm:flex-row">
|
||||||
|
{entry.pdfUrl && <a href={entry.pdfUrl} className="inline-flex flex-1 items-center justify-center gap-2 rounded-xl border px-4 py-2.5 text-xs font-bold text-white hover:bg-white/10" style={{ borderColor: `${accentColor}60` }}><FileText className="h-4 w-4" /><span>PDF</span><Download className="h-3.5 w-3.5" /></a>}
|
||||||
|
{entry.epubUrl && <a href={entry.epubUrl} className="inline-flex flex-1 items-center justify-center gap-2 rounded-xl border px-4 py-2.5 text-xs font-bold text-white hover:bg-white/10" style={{ borderColor: `${secondaryColor}60` }}><BookOpen className="h-4 w-4" /><span>ePUB</span><Download className="h-3.5 w-3.5" /></a>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{availableDownloads.length > 3 && (
|
||||||
|
<div className="text-center">
|
||||||
|
<button type="button" onClick={toggleDownloads} aria-expanded={showAllDownloads} aria-controls="download-grid" className="inline-flex items-center gap-2 rounded-xl border border-slate-700 bg-slate-900 px-5 py-3 text-sm font-semibold text-white hover:bg-slate-800">
|
||||||
|
{showAllDownloads ? <><ChevronUp className="h-4 w-4" /><span>Weniger anzeigen</span></> : <><ChevronDown className="h-4 w-4" /><span>Alle Kurzgeschichten anzeigen ({availableDownloads.length})</span></>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Section 4: Custom Highlight Section if present */}
|
{/* Section 4: Custom Highlight Section if present */}
|
||||||
{(data.customSectionTitle || data.customSectionContent) && (
|
{(data.customSectionTitle || data.customSectionContent || customSectionLinks.length > 0) && (
|
||||||
<section className="p-8 rounded-2xl border relative overflow-hidden" style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}30` }}>
|
<section className="p-8 rounded-2xl border relative overflow-hidden" style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}30` }}>
|
||||||
<div className="absolute top-0 right-0 p-3 opacity-10 font-mono text-xs uppercase" style={{ color: accentColor }}>
|
<div className="absolute top-0 right-0 p-3 opacity-10 font-mono text-xs uppercase" style={{ color: accentColor }}>
|
||||||
CUSTOM_INFO_MODULE
|
CUSTOM_INFO_MODULE
|
||||||
|
|
@ -308,26 +428,70 @@ export default function SciFiPortfolio({ data, legalDocuments = [] }: SciFiPortf
|
||||||
</Markdown>
|
</Markdown>
|
||||||
</div>
|
</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>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{shareNotice && <div role="status" aria-live="polite" className="fixed bottom-5 left-1/2 z-[70] -translate-x-1/2 rounded-xl border border-slate-700 bg-slate-900 px-4 py-2 text-sm text-white shadow-xl">{shareNotice}</div>}
|
||||||
|
|
||||||
{/* Book details modular interface modal */}
|
{/* Book details modular interface modal */}
|
||||||
{selectedBook && (
|
{selectedBook && (
|
||||||
<div className="fixed inset-0 z-50 overflow-y-auto" role="dialog" aria-modal="true">
|
<AccessibleModal titleId="book-detail-title" onClose={closeDetail}>
|
||||||
{/* Overlay background */}
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 bg-slate-950/80 backdrop-blur-md transition-opacity"
|
|
||||||
onClick={() => setSelectedBook(null)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
|
|
||||||
<div className="relative transform overflow-hidden rounded-2xl bg-slate-900 border border-slate-800 text-left shadow-2xl transition-all sm:my-8 sm:w-full sm:max-w-4xl p-6 md:p-8 space-y-6">
|
|
||||||
|
|
||||||
{/* Close Button */}
|
{/* Close Button */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelectedBook(null)}
|
onClick={closeDetail}
|
||||||
className="absolute top-4 right-4 text-slate-400 hover:text-white hover:bg-slate-800/80 px-2.5 py-1 rounded-md text-xs font-mono border border-slate-800 transition-all cursor-pointer"
|
className="absolute top-4 right-4 text-slate-400 hover:text-white hover:bg-slate-800/80 px-2.5 py-1 rounded-md text-xs font-mono border border-slate-800 transition-all cursor-pointer"
|
||||||
>
|
>
|
||||||
SCHLIESSEN [ESC]
|
SCHLIESSEN [ESC]
|
||||||
|
|
@ -345,17 +509,11 @@ export default function SciFiPortfolio({ data, legalDocuments = [] }: SciFiPortf
|
||||||
referrerPolicy="no-referrer"
|
referrerPolicy="no-referrer"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{selectedBook.buyLink && (
|
<div className="mt-6 w-full max-w-[260px] space-y-2">
|
||||||
<a
|
{(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>}
|
||||||
href={selectedBook.buyLink}
|
{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>}
|
||||||
target="_blank"
|
{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>}
|
||||||
rel="noopener noreferrer"
|
</div>
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
{/* Book Details */}
|
{/* Book Details */}
|
||||||
|
|
@ -372,7 +530,7 @@ export default function SciFiPortfolio({ data, legalDocuments = [] }: SciFiPortf
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 className="text-3xl font-extrabold text-white">
|
<h3 id="book-detail-title" className="text-3xl font-extrabold text-white">
|
||||||
{selectedBook.title}
|
{selectedBook.title}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
|
|
@ -381,6 +539,11 @@ export default function SciFiPortfolio({ data, legalDocuments = [] }: SciFiPortf
|
||||||
{selectedBook.subtitle}
|
{selectedBook.subtitle}
|
||||||
</h4>
|
</h4>
|
||||||
)}
|
)}
|
||||||
|
{selectedBook.seriesName && (
|
||||||
|
<p className="text-sm font-semibold" style={{ color: accentColor }}>{selectedBook.seriesName}{selectedBook.seriesNumber !== undefined && selectedBook.seriesNumber !== "" ? ` · Band ${selectedBook.seriesNumber}` : ""}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button type="button" onClick={() => shareDetail(selectedBook.title)} className="inline-flex w-fit items-center gap-2 rounded-lg border border-slate-700 px-3 py-2 text-xs text-slate-300 hover:bg-slate-800"><Share2 className="h-4 w-4" /><span>Teilen / Link kopieren</span></button>
|
||||||
|
|
||||||
<div className="h-px bg-indigo-950" />
|
<div className="h-px bg-indigo-950" />
|
||||||
|
|
||||||
|
|
@ -398,26 +561,16 @@ export default function SciFiPortfolio({ data, legalDocuments = [] }: SciFiPortf
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</AccessibleModal>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Project details modular modal */}
|
{/* Project details modular modal */}
|
||||||
{selectedProject && (
|
{selectedProject && (
|
||||||
<div className="fixed inset-0 z-50 overflow-y-auto" role="dialog" aria-modal="true">
|
<AccessibleModal titleId="project-detail-title" onClose={closeDetail} maxWidthClass="sm:max-w-5xl">
|
||||||
{/* Overlay background */}
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 bg-slate-950/80 backdrop-blur-md transition-opacity"
|
|
||||||
onClick={() => setSelectedProject(null)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
|
|
||||||
<div className="relative transform overflow-hidden rounded-2xl bg-slate-900 border border-slate-800 text-left shadow-2xl transition-all sm:my-8 sm:w-full sm:max-w-5xl p-6 md:p-8 space-y-6">
|
|
||||||
|
|
||||||
{/* Close Button */}
|
{/* Close Button */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelectedProject(null)}
|
onClick={closeDetail}
|
||||||
className="absolute top-4 right-4 text-slate-400 hover:text-white hover:bg-slate-800/80 px-2.5 py-1 rounded-md text-xs font-mono border border-slate-800 transition-all cursor-pointer"
|
className="absolute top-4 right-4 text-slate-400 hover:text-white hover:bg-slate-800/80 px-2.5 py-1 rounded-md text-xs font-mono border border-slate-800 transition-all cursor-pointer"
|
||||||
>
|
>
|
||||||
SCHLIESSEN [ESC]
|
SCHLIESSEN [ESC]
|
||||||
|
|
@ -429,19 +582,21 @@ export default function SciFiPortfolio({ data, legalDocuments = [] }: SciFiPortf
|
||||||
Status: {selectedProject.status || "In Arbeit"}
|
Status: {selectedProject.status || "In Arbeit"}
|
||||||
</span>
|
</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">
|
<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
|
Aktuelles Projekt
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 className="text-3xl font-extrabold text-white">
|
<h3 id="project-detail-title" className="text-3xl font-extrabold text-white">
|
||||||
{selectedProject.title}
|
{selectedProject.title}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
|
<button type="button" onClick={() => shareDetail(selectedProject.title)} className="inline-flex w-fit items-center gap-2 rounded-lg border border-slate-700 px-3 py-2 text-xs text-slate-300 hover:bg-slate-800"><Share2 className="h-4 w-4" /><span>Teilen / Link kopieren</span></button>
|
||||||
|
|
||||||
<div className="h-px bg-indigo-950" />
|
<div className="h-px bg-indigo-950" />
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex justify-between items-center text-xs font-mono text-slate-400">
|
<div className="flex justify-between items-center text-xs font-mono text-slate-400">
|
||||||
<span>{data.projectProgressLabel ? `${data.projectProgressLabel.toUpperCase()}:` : "ORGANISCHER FORTSCHRITT:"}</span>
|
<span>{data.projectProgressLabel ? `${data.projectProgressLabel.toUpperCase()}:` : "FORTSCHRITT:"}</span>
|
||||||
<span className="font-bold" style={{ color: accentColor }}>{selectedProject.progress}%</span>
|
<span className="font-bold" style={{ color: accentColor }}>{selectedProject.progress}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-2 w-full bg-slate-950 rounded-full overflow-hidden border border-slate-800">
|
<div className="h-2 w-full bg-slate-950 rounded-full overflow-hidden border border-slate-800">
|
||||||
|
|
@ -471,16 +626,14 @@ export default function SciFiPortfolio({ data, legalDocuments = [] }: SciFiPortf
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</AccessibleModal>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Styled Footer */}
|
{/* 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 }}>
|
<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">
|
<div className="max-w-7xl mx-auto space-y-4">
|
||||||
<p>© {new Date().getFullYear()} {data.name}. Alle Rechte vorbehalten.</p>
|
<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>
|
<p className="text-slate-500">{data.footerText || "Geschichten, Projekte und Veröffentlichungen."}</p>
|
||||||
|
|
||||||
{relevantLegalDocs.length > 0 && (
|
{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">
|
<div className="flex flex-wrap items-center justify-center gap-6 pt-3 border-t border-slate-800/60 max-w-md mx-auto">
|
||||||
|
|
@ -1,492 +0,0 @@
|
||||||
import React, { useState } from "react";
|
|
||||||
import Markdown from "react-markdown";
|
|
||||||
import { AuthorProfile, Book, Project, LegalDocument } from "../types";
|
|
||||||
import {
|
|
||||||
Heart, Sparkles, BookOpen, ExternalLink, X, ArrowRight, Sun
|
|
||||||
} from "lucide-react";
|
|
||||||
import SpotlightSection from "./SpotlightSection";
|
|
||||||
import LegalModal from "./LegalModal";
|
|
||||||
import ProjectDetailMedia from "./ProjectDetailMedia";
|
|
||||||
import SpotifyConsentPlayer from "./SpotifyConsentPlayer";
|
|
||||||
|
|
||||||
interface ReneePortfolioProps {
|
|
||||||
data: AuthorProfile;
|
|
||||||
legalDocuments?: LegalDocument[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ReneePortfolio({ data, legalDocuments = [] }: ReneePortfolioProps) {
|
|
||||||
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 relevantLegalDocs = legalDocuments.filter(doc => doc.assignedProfiles.includes("renee"));
|
|
||||||
|
|
||||||
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 || undefined}
|
|
||||||
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 || undefined}
|
|
||||||
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 || undefined}
|
|
||||||
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>
|
|
||||||
|
|
||||||
{/* Privacy-friendly Spotify player */}
|
|
||||||
{selectedBook.spotifyPlaylistId && (
|
|
||||||
<SpotifyConsentPlayer playlist={selectedBook.spotifyPlaylistId} title={`Spotify-Playlist zu ${selectedBook.title}`} accentColor={accentColor} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{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 overflow-y-auto bg-slate-950/80 backdrop-blur-md animate-fade-in">
|
|
||||||
<div className="flex min-h-full items-center justify-center p-4">
|
|
||||||
<div
|
|
||||||
className="relative w-full max-w-5xl border rounded-2xl p-6 md:p-8 my-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">
|
|
||||||
{data.projectProgressLabel ? `${data.projectProgressLabel}: ` : "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={`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-900/60 p-5 rounded-xl border border-slate-800 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>
|
|
||||||
)}
|
|
||||||
{data.projectExcerptTitle && (
|
|
||||||
<h4 className="text-xs uppercase tracking-wider mb-3 font-semibold" style={{ color: accentColor }}>
|
|
||||||
{data.projectExcerptTitle}
|
|
||||||
</h4>
|
|
||||||
)}
|
|
||||||
<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>
|
|
||||||
</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-3">
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{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-yellow-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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -101,23 +101,29 @@ export default function SpotlightSection({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{book.buyLink && (
|
{(book.ebookLink || book.buyLink) && (
|
||||||
<a
|
<a
|
||||||
href={book.buyLink}
|
href={book.ebookLink || book.buyLink}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="w-full max-w-[260px] py-3 px-4 rounded-xl font-bold text-xs uppercase tracking-wider transition-all duration-300 flex items-center justify-center gap-2 shadow-lg cursor-pointer hover:scale-[1.02]"
|
className="w-full max-w-[260px] py-3 px-4 rounded-xl font-bold text-xs uppercase tracking-wider transition-all duration-300 flex items-center justify-center gap-2 shadow-lg cursor-pointer hover:scale-[1.02]"
|
||||||
style={{ backgroundColor: accentColor, color: "#000" }}
|
style={{ backgroundColor: accentColor, color: "#000" }}
|
||||||
>
|
>
|
||||||
<ShoppingBag className="w-4 h-4" />
|
<ShoppingBag className="w-4 h-4" />
|
||||||
<span>{isFuture ? "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" />
|
<ExternalLink className="w-3.5 h-3.5 ml-auto opacity-70" />
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
{book.paperbackLink && (
|
||||||
|
<a href={book.paperbackLink} target="_blank" rel="noopener noreferrer" className="w-full max-w-[260px] py-3 px-4 rounded-xl border border-slate-700 bg-slate-900 text-white font-bold text-xs uppercase tracking-wider flex items-center justify-center gap-2 hover:bg-slate-800">
|
||||||
|
<ShoppingBag className="w-4 h-4" /><span>Taschenbuch</span><ExternalLink className="w-3.5 h-3.5 ml-auto opacity-70" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Details column */}
|
{/* Details column */}
|
||||||
<div className="md:col-span-8 space-y-5">
|
<div className="md:col-span-8 space-y-5">
|
||||||
|
{book.seriesName && <p className="text-xs font-semibold uppercase tracking-widest" style={{ color: accentColor }}>{book.seriesName}{book.seriesNumber !== undefined && book.seriesNumber !== "" ? ` · Band ${book.seriesNumber}` : ""}</p>}
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className="px-3 py-1 rounded-full text-xs font-mono font-bold tracking-wide uppercase border flex items-center gap-1.5 shadow-sm"
|
className="px-3 py-1 rounded-full text-xs font-mono font-bold tracking-wide uppercase border flex items-center gap-1.5 shadow-sm"
|
||||||
|
|
|
||||||
51
src/components/admin/AdminLogin.tsx
Normal file
51
src/components/admin/AdminLogin.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
123
src/components/admin/DownloadsManager.tsx
Normal file
123
src/components/admin/DownloadsManager.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
src/components/admin/PublicationFilePicker.tsx
Normal file
62
src/components/admin/PublicationFilePicker.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
src/components/admin/SamplePdfPicker.tsx
Normal file
68
src/components/admin/SamplePdfPicker.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
src/types.ts
36
src/types.ts
|
|
@ -16,12 +16,32 @@ export interface Book {
|
||||||
description: string;
|
description: string;
|
||||||
publishedYear: number | string; // Accepts year (2026) or full date string (15.10.2026, Herbst 2026)
|
publishedYear: number | string; // Accepts year (2026) or full date string (15.10.2026, Herbst 2026)
|
||||||
buyLink?: string;
|
buyLink?: string;
|
||||||
|
ebookLink?: string;
|
||||||
|
paperbackLink?: string;
|
||||||
|
samplePdfUrl?: string;
|
||||||
|
seriesName?: string;
|
||||||
|
seriesNumber?: number | string;
|
||||||
spotifyPlaylistId?: string; // Just the playlist ID (e.g., "47R6ZAdk7Xf6M4i9mR0fP1")
|
spotifyPlaylistId?: string; // Just the playlist ID (e.g., "47R6ZAdk7Xf6M4i9mR0fP1")
|
||||||
genres?: string[];
|
genres?: string[];
|
||||||
isSpotlight?: boolean; // Highlighted as spotlight / new release
|
isSpotlight?: boolean; // Highlighted as spotlight / new release
|
||||||
spotlightBadge?: string; // Optional custom badge text (e.g. "Neuerscheinung", "Vorbestellbar")
|
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 {
|
export interface AuthorProfile {
|
||||||
name: string;
|
name: string;
|
||||||
bio: string;
|
bio: string;
|
||||||
|
|
@ -32,6 +52,14 @@ export interface AuthorProfile {
|
||||||
books: Book[];
|
books: Book[];
|
||||||
customSectionTitle?: string;
|
customSectionTitle?: string;
|
||||||
customSectionContent?: string;
|
customSectionContent?: string;
|
||||||
|
customSectionLinks?: CustomSectionLink[];
|
||||||
|
contactEmail?: string;
|
||||||
|
instagramUrl?: string;
|
||||||
|
threadsUrl?: string;
|
||||||
|
discordUrl?: string;
|
||||||
|
downloads?: DownloadPublication[];
|
||||||
|
downloadsTitle?: string;
|
||||||
|
downloadsSubtitle?: string;
|
||||||
|
|
||||||
// Domain & Path Routing
|
// Domain & Path Routing
|
||||||
customDomain?: string; // e.g., "annieslone.de, annie-slone.de"
|
customDomain?: string; // e.g., "annieslone.de, annie-slone.de"
|
||||||
|
|
@ -95,6 +123,14 @@ export interface AuthorData {
|
||||||
revision?: number;
|
revision?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PortfolioTheme = "cosmic" | "velvet" | "botanical" | "romance";
|
||||||
|
|
||||||
|
export interface PublicAuthorData {
|
||||||
|
profile: AuthorProfile;
|
||||||
|
legalDocuments: LegalDocument[];
|
||||||
|
theme: PortfolioTheme;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AdminSettings {
|
export interface AdminSettings {
|
||||||
adminPasswordHash?: string; // Optional if we hash, or we check against .env
|
adminPasswordHash?: string; // Optional if we hash, or we check against .env
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,67 @@ test("production hardening and public routing", async (t) => {
|
||||||
response = await fetch(`${baseUrl}/api/admin/session`, { headers: { cookie } });
|
response = await fetch(`${baseUrl}/api/admin/session`, { headers: { cookie } });
|
||||||
assert.equal(response.status, 200);
|
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");
|
let routedPage = await getWithHost(`${baseUrl}/`, "annieslone.de");
|
||||||
assert.match(routedPage.body, /<title>Annie Slone/);
|
assert.match(routedPage.body, /<title>Annie Slone/);
|
||||||
routedPage = await getWithHost(`${baseUrl}/`, "www.annieslone.de");
|
routedPage = await getWithHost(`${baseUrl}/`, "www.annieslone.de");
|
||||||
|
|
@ -138,11 +199,109 @@ test("production hardening and public routing", async (t) => {
|
||||||
response = await upload("wrong.jpg", validPng);
|
response = await upload("wrong.jpg", validPng);
|
||||||
assert.equal(response.status, 415);
|
assert.equal(response.status, 415);
|
||||||
|
|
||||||
|
const validPdf = `data:application/pdf;base64,${Buffer.from("%PDF-1.4\n%%EOF").toString("base64")}`;
|
||||||
|
response = await fetch(`${baseUrl}/api/admin/upload-sample`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json", origin: baseUrl, cookie },
|
||||||
|
body: JSON.stringify({ fileName: "leseprobe.pdf", base64Data: validPdf }),
|
||||||
|
});
|
||||||
|
assert.equal(response.status, 201);
|
||||||
|
const sample = await response.json();
|
||||||
|
assert.match(sample.url, /^\/downloads\/[a-z0-9-]+\.pdf$/);
|
||||||
|
response = await fetch(`${baseUrl}${sample.url}`);
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
assert.match(response.headers.get("content-disposition") || "", /attachment/);
|
||||||
|
|
||||||
|
response = await fetch(`${baseUrl}/api/admin/upload-publication`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json", origin: baseUrl, cookie },
|
||||||
|
body: JSON.stringify({ fileName: "geschichte.pdf", base64Data: validPdf, format: "pdf" }),
|
||||||
|
});
|
||||||
|
assert.equal(response.status, 201);
|
||||||
|
const publicationPdf = await response.json();
|
||||||
|
const epubBuffer = Buffer.concat([Buffer.from([0x50, 0x4b, 0x03, 0x04]), Buffer.alloc(60), Buffer.from("application/epub+zip")]);
|
||||||
|
response = await fetch(`${baseUrl}/api/admin/upload-publication`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json", origin: baseUrl, cookie },
|
||||||
|
body: JSON.stringify({ fileName: "geschichte.epub", base64Data: `data:application/epub+zip;base64,${epubBuffer.toString("base64")}`, format: "epub" }),
|
||||||
|
});
|
||||||
|
assert.equal(response.status, 201);
|
||||||
|
const publicationEpub = await response.json();
|
||||||
|
response = await fetch(`${baseUrl}${publicationEpub.url}`);
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
assert.equal(response.headers.get("content-type"), "application/epub+zip");
|
||||||
|
|
||||||
|
adminData.erotica.books[0].samplePdfUrl = sample.url;
|
||||||
|
adminData.erotica.downloadsTitle = "Kostenlose Geschichten";
|
||||||
|
adminData.erotica.downloads = [{
|
||||||
|
id: "download_test",
|
||||||
|
title: "Testgeschichte",
|
||||||
|
description: "Eine kurze Beschreibung.",
|
||||||
|
imageUrl: "",
|
||||||
|
pdfUrl: publicationPdf.url,
|
||||||
|
epubUrl: publicationEpub.url,
|
||||||
|
publishedAt: "2026-08-18",
|
||||||
|
}];
|
||||||
|
response = await fetch(`${baseUrl}/api/admin/save-profile`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json", origin: baseUrl, cookie },
|
||||||
|
body: JSON.stringify({ profileKey: "erotica", profileData: adminData.erotica, expectedRevision: firstSave.revision }),
|
||||||
|
});
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
const sampleSave = await response.json();
|
||||||
|
const downloadPublicResponse = await getWithHost(`${baseUrl}/api/author-data`, "annieslone.de");
|
||||||
|
const downloadPublicData = JSON.parse(downloadPublicResponse.body);
|
||||||
|
assert.equal(downloadPublicData.profile.downloads[0].title, "Testgeschichte");
|
||||||
|
assert.equal(downloadPublicData.profile.downloads[0].epubUrl, publicationEpub.url);
|
||||||
|
response = await fetch(`${baseUrl}/api/admin/samples/${path.basename(sample.url)}`, { method: "DELETE", headers: { origin: baseUrl, cookie } });
|
||||||
|
assert.equal(response.status, 409);
|
||||||
|
response = await fetch(`${baseUrl}/api/admin/publications/${path.basename(publicationEpub.url)}`, { method: "DELETE", headers: { origin: baseUrl, cookie } });
|
||||||
|
assert.equal(response.status, 409);
|
||||||
|
|
||||||
response = await fetch(`${baseUrl}${uploaded.url}`);
|
response = await fetch(`${baseUrl}${uploaded.url}`);
|
||||||
assert.equal(response.status, 200);
|
assert.equal(response.status, 200);
|
||||||
assert.equal(response.headers.get("x-content-type-options"), "nosniff");
|
assert.equal(response.headers.get("x-content-type-options"), "nosniff");
|
||||||
assert.match(response.headers.get("cache-control") || "", /immutable/);
|
assert.match(response.headers.get("cache-control") || "", /immutable/);
|
||||||
|
|
||||||
|
adminData.erotica.avatarUrl = uploaded.url;
|
||||||
|
response = await fetch(`${baseUrl}/api/admin/save-profile`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json", origin: baseUrl, cookie },
|
||||||
|
body: JSON.stringify({ profileKey: "erotica", profileData: adminData.erotica, expectedRevision: sampleSave.revision }),
|
||||||
|
});
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
const referencedSave = await response.json();
|
||||||
|
|
||||||
|
response = await fetch(`${baseUrl}/api/admin/uploads/${path.basename(uploaded.url)}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { origin: baseUrl, cookie },
|
||||||
|
});
|
||||||
|
assert.equal(response.status, 409);
|
||||||
|
|
||||||
|
adminData.erotica.avatarUrl = "";
|
||||||
|
adminData.erotica.books[0].samplePdfUrl = "";
|
||||||
|
adminData.erotica.downloads = [];
|
||||||
|
response = await fetch(`${baseUrl}/api/admin/save-profile`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json", origin: baseUrl, cookie },
|
||||||
|
body: JSON.stringify({ profileKey: "erotica", profileData: adminData.erotica, expectedRevision: referencedSave.revision }),
|
||||||
|
});
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
|
||||||
|
response = await fetch(`${baseUrl}/api/admin/uploads/${path.basename(uploaded.url)}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { origin: baseUrl, cookie },
|
||||||
|
});
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
response = await fetch(`${baseUrl}${uploaded.url}`);
|
||||||
|
assert.equal(response.status, 404);
|
||||||
|
response = await fetch(`${baseUrl}/api/admin/samples/${path.basename(sample.url)}`, { method: "DELETE", headers: { origin: baseUrl, cookie } });
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
response = await fetch(`${baseUrl}/api/admin/publications/${path.basename(publicationPdf.url)}`, { method: "DELETE", headers: { origin: baseUrl, cookie } });
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
response = await fetch(`${baseUrl}/api/admin/publications/${path.basename(publicationEpub.url)}`, { method: "DELETE", headers: { origin: baseUrl, cookie } });
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
|
||||||
response = await fetch(`${baseUrl}/api/admin/logout`, { method: "POST", headers: { origin: baseUrl, cookie } });
|
response = await fetch(`${baseUrl}/api/admin/logout`, { method: "POST", headers: { origin: baseUrl, cookie } });
|
||||||
assert.equal(response.status, 200);
|
assert.equal(response.status, 200);
|
||||||
response = await fetch(`${baseUrl}/api/admin/session`, { headers: { cookie } });
|
response = await fetch(`${baseUrl}/api/admin/session`, { headers: { cookie } });
|
||||||
|
|
@ -162,6 +321,12 @@ test("production hardening and public routing", async (t) => {
|
||||||
response = await fetch(`${baseUrl}/`);
|
response = await fetch(`${baseUrl}/`);
|
||||||
assert.equal(response.headers.get("x-frame-options"), "DENY");
|
assert.equal(response.headers.get("x-frame-options"), "DENY");
|
||||||
assert.ok(response.headers.get("content-security-policy"));
|
assert.ok(response.headers.get("content-security-policy"));
|
||||||
|
response = await fetch(`${baseUrl}/does-not-exist`);
|
||||||
|
assert.equal(response.status, 404);
|
||||||
|
assert.match(response.headers.get("x-robots-tag") || "", /noindex/);
|
||||||
|
const notFoundBody = await response.text();
|
||||||
|
assert.match(notFoundBody, /Seite nicht gefunden/);
|
||||||
|
assert.equal(notFoundBody.includes("Daniel Hesse"), false);
|
||||||
} finally {
|
} finally {
|
||||||
await stopServer(child);
|
await stopServer(child);
|
||||||
await rm(dataDir, { recursive: true, force: true });
|
await rm(dataDir, { recursive: true, force: true });
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue