diff --git a/.env.example b/.env.example
index 7a550fe..f93640d 100644
--- a/.env.example
+++ b/.env.example
@@ -3,6 +3,13 @@
# Users configure this via the Secrets panel in the AI Studio UI.
GEMINI_API_KEY="MY_GEMINI_API_KEY"
+# Required in production. There is a development fallback, but it must not be
+# used for an internet-facing deployment.
+ADMIN_PASSWORD="CHANGE_ME_TO_A_LONG_RANDOM_PASSWORD"
+
+# Optional location for database.json, uploads and migration backups.
+# DATA_DIR="./data"
+
# APP_URL: The URL where this applet is hosted.
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
# Used for self-referential links, OAuth callbacks, and API endpoints.
diff --git a/README.md b/README.md
index b0186f4..864ec1e 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# 📚 Dual Identity Autoren-Zentrale (Selbstgehostet)
-Willkommen in Ihrer modernen, reaktiven Autoren-Website für das 21. Jahrhundert! Dieses System ermöglicht es Ihnen, zwei völlig eigenständige Autorenprofile mit individuellen Themen, Biografien, Schreibprojekten und Bücherregalen über eine einzige, passwortgeschützte Administrationsoberfläche zu pflegen.
+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.
---
@@ -10,16 +10,16 @@ Bisher liefen klassische Portfolios oft auf einem standardmäßigen **LAMP-Stack
1. **Vollständige Kapselung (Kein Server-Wildwuchs):** Bei einem LAMP-Stack müssen Sie Apache/Nginx, PHP-Laufzeiten und eine MySQL-Datenbank auf dem Host installieren und managen. Unser Docker-Stack bündelt das gesamte Frontend, das Express-Backend und die Speicherrelevanz in einem **einzigen, autarken Container**.
2. **Blitzschnelle, reaktive Benutzeroberfläche (SPA):** Das Frontend nutzt modernste Webtechnologie (React & Tailwind CSS). Seitenwechsel und Buchdetails laden augenblicklich im Browser des Nutzers, ohne das klassische, träge Neuladen von PHP-Seiten.
-3. **Einfachheit bei Datensicherungen (Backups):** Anstatt komplexe MySQL-Dumps durchzuführen, speichert unsere Anwendung alle Buchtitel, Projekte, Cover und Biografien in einer schlanken, robusten JSON-Datenbankdatei (`/data/database.json`). Ein Backup lässt sich durch einfaches Kopieren dieser Datei anfertigen!
+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 Trennung der Profile
-Das System ist von Grund auf so strukturiert, dass Besucher der Website **keine Verbindung** zwischen Ihren beiden Identitäten herstellen können:
+Das System ist von Grund auf so strukturiert, dass Besucher der Website **keine Verbindung** zwischen den gepflegten Identitäten herstellen müssen. Der Code enthält derzeit vier getrennt auflösbare Profile; Domains und Pfade können im Adminbereich gepflegt werden. Unter anderem:
- **Echte Identität (Daniel Hesse — Sci-Fi & Fantasy):** Erreichbar auf der Standard-Startseite (`/`). Das Design kombiniert dunkelblaue Weltraum-Energie, Monospace-Einflüsse und kosmische Magie-Akzente.
-- **Pseudonym (M. S. Velvet — Sinnliche Literatur):** Erreichbar **ausschließlich** über den Pfad `/sensual-moments`. Sie verwendet ein warmes, edles "Champagner-Gold & Samt-Bordeaux"-Thema mit klassischer und anspruchsvoller Serif-Asthetik. Es gibt keinerlei gegenseitige Verlinkungen!
+- **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!
---
@@ -84,9 +84,21 @@ Sollten Sie die Software direkt auf Ihrem Server (ohne Docker) starten wollen:
## đź§± Datensicherung / Backups
-Alle Ihre wertvollen Inhalte (Biografien, Projektfortschritte und alle Bücherregal-Einträge) liegen verschlüsselt strukturiert in Ihrem Projektordner unter:
+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.
diff --git a/index.html b/index.html
index 21dfe69..7cfd1be 100644
--- a/index.html
+++ b/index.html
@@ -1,13 +1,13 @@
-
+
- My Google AI Studio App
+
+ Autoren-Portfolio
-
diff --git a/server.ts b/server.ts
index 89f963d..8110f32 100644
--- a/server.ts
+++ b/server.ts
@@ -1,7 +1,6 @@
import express from "express";
import path from "path";
import fs from "fs/promises";
-import { fileURLToPath } from "url";
import dotenv from "dotenv";
import { GoogleGenAI } from "@google/genai";
import { defaultAuthorData } from "./src/defaultData.js";
@@ -9,33 +8,84 @@ import { AuthorData, AuthorProfile } from "./src/types.js";
dotenv.config();
-const currentFilename = typeof import.meta !== "undefined" && import.meta.url
- ? fileURLToPath(import.meta.url)
- : (typeof __filename !== "undefined" ? __filename : "");
-
-const currentDirname = typeof import.meta !== "undefined" && import.meta.url
- ? path.dirname(currentFilename)
- : (typeof __dirname !== "undefined" ? __dirname : "");
-
const app = express();
const PORT = 3000;
// Path to durable local database file
-const 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 BACKUP_DIR = path.join(DATA_DIR, "backups");
+const CURRENT_SCHEMA_VERSION = 1;
+let writeQueue: Promise = Promise.resolve();
+let serverReady = false;
+
+const profileKeys = ["scifi", "erotica", "clara", "renee"] as const;
+type ProfileKey = typeof profileKeys[number];
+
+function isAuthorData(value: unknown): value is AuthorData {
+ if (!value || typeof value !== "object") return false;
+ const candidate = value as Record;
+ return profileKeys.every((key) => {
+ const profile = candidate[key] as Partial | undefined;
+ return !!profile && typeof profile.name === "string" &&
+ typeof profile.bio === "string" && Array.isArray(profile.books) &&
+ Array.isArray(profile.projects);
+ });
+}
+
+async function writeFileAtomically(data: AuthorData): Promise {
+ const temporaryFile = `${DATA_FILE}.${process.pid}.${Date.now()}.tmp`;
+ try {
+ await fs.writeFile(temporaryFile, JSON.stringify(data, null, 2), "utf-8");
+ await fs.rename(temporaryFile, DATA_FILE);
+ } finally {
+ await fs.unlink(temporaryFile).catch(() => undefined);
+ }
+}
+
+function updateDatabase(update: (current: AuthorData) => AuthorData): Promise {
+ let savedData: AuthorData;
+ const operation = writeQueue.then(async () => {
+ const nextData = update(dbCache);
+ await writeFileAtomically(nextData);
+ dbCache = nextData;
+ savedData = nextData;
+ });
+ writeQueue = operation.catch(() => undefined);
+ return operation.then(() => savedData!);
+}
+
+async function createMigrationBackup(rawData: string): Promise {
+ await fs.mkdir(BACKUP_DIR, { recursive: true });
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
+ await fs.writeFile(path.join(BACKUP_DIR, `database-before-migration-${timestamp}.json`), rawData, "utf-8");
+}
// Establish initial database of authors
async function initDatabase(): Promise {
+ await fs.mkdir(DATA_DIR, { recursive: true });
+ await fs.mkdir(path.join(DATA_DIR, "uploads"), { recursive: true });
try {
- await fs.mkdir(DATA_DIR, { recursive: true });
- await fs.mkdir(path.join(DATA_DIR, "uploads"), { recursive: true });
- try {
const existingData = await fs.readFile(DATA_FILE, "utf-8");
- const parsed = JSON.parse(existingData) as AuthorData;
+ if (!existingData.trim()) {
+ const initialData: AuthorData = {
+ ...defaultAuthorData,
+ schemaVersion: CURRENT_SCHEMA_VERSION,
+ revision: 0,
+ };
+ await createMigrationBackup(existingData);
+ await writeFileAtomically(initialData);
+ return initialData;
+ }
+ const rawParsed = JSON.parse(existingData) as unknown;
+ if (!rawParsed || typeof rawParsed !== "object" || Array.isArray(rawParsed)) {
+ throw new Error("database.json besitzt nicht die erwartete Grundstruktur.");
+ }
+ // Legacy files may not contain all newer profiles yet; migrations below add them.
+ const parsed = rawParsed as AuthorData;
let modified = false;
// Ensure all dynamic static text and design properties exist for all profiles
- const profileKeys: ("scifi" | "erotica" | "clara" | "renee")[] = ["scifi", "erotica", "clara", "renee"];
for (const pKey of profileKeys) {
if (!parsed[pKey]) {
parsed[pKey] = defaultAuthorData[pKey];
@@ -79,29 +129,39 @@ async function initDatabase(): Promise {
modified = true;
}
+ if (parsed.schemaVersion === undefined) {
+ parsed.schemaVersion = CURRENT_SCHEMA_VERSION;
+ modified = true;
+ }
+ if (parsed.revision === undefined) {
+ parsed.revision = 0;
+ modified = true;
+ }
+
+ if (!isAuthorData(parsed)) {
+ throw new Error("database.json ist auch nach der Migration nicht vollständig gültig.");
+ }
+
if (modified) {
- await fs.writeFile(DATA_FILE, JSON.stringify(parsed, null, 2), "utf-8");
+ await createMigrationBackup(existingData);
+ await writeFileAtomically(parsed);
}
return parsed;
- } catch {
- // If file doesn't exist, seed it with default data
- await fs.writeFile(DATA_FILE, JSON.stringify(defaultAuthorData, null, 2), "utf-8");
- return defaultAuthorData;
- }
- } catch (err) {
- console.error("Database storage initialization failed, using default data in-memory:", err);
- return defaultAuthorData;
+ } catch (error: any) {
+ if (error?.code !== "ENOENT") throw error;
+ const initialData: AuthorData = {
+ ...defaultAuthorData,
+ schemaVersion: CURRENT_SCHEMA_VERSION,
+ revision: 0,
+ };
+ await writeFileAtomically(initialData);
+ return initialData;
}
}
// In-memory runtime data cache, synced to active storage file
let dbCache: AuthorData;
-// Initialize on boot
-initDatabase().then((data) => {
- dbCache = data;
-});
-
// Configure middleware
app.use(express.json({ limit: "10mb" }));
@@ -142,11 +202,111 @@ const verifyToken = (req: express.Request, res: express.Response, next: express.
}
};
+function cleanDomain(value: string): string {
+ return value.toLowerCase().trim().replace(/^https?:\/\//, "").replace(/^www\./, "").split("/")[0];
+}
+
+function profileForRequest(hostname: string, pathname: string): ProfileKey {
+ const normalizedHost = cleanDomain(hostname);
+ const standardPaths: Record = {
+ erotica: ["/sensual-moments", "/annie-slone", "/marc-velvet"],
+ clara: ["/clara-finch", "/clara"],
+ renee: ["/renee-heart", "/renee"],
+ scifi: ["/sci-fi", "/daniel-hesse"],
+ };
+ const standardDomains: Record = {
+ erotica: ["annieslone.de", "marcvelvet.de"],
+ clara: ["clarafinch.de", "clara-finch.de"],
+ renee: ["reneeheart.de", "renee-heart.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) {
+ const configuredDomains = (dbCache[key].customDomain || "").split(",").map(cleanDomain).filter(Boolean);
+ for (const domain of [...configuredDomains, ...standardDomains[key]]) {
+ if (normalizedHost === domain || normalizedHost.endsWith(`.${domain}`)) return key;
+ }
+ }
+ return "scifi";
+}
+
+function escapeHtml(value: string): string {
+ return value.replace(/[&<>"']/g, (character) => ({
+ "&": "&", "<": "<", ">": ">", "\"": """, "'": "'",
+ })[character]!);
+}
+
+function absoluteUrl(value: string | undefined, origin: string): string | undefined {
+ if (!value) return undefined;
+ try {
+ return new URL(value, origin).toString();
+ } catch {
+ return undefined;
+ }
+}
+
+function seoMeta(req: express.Request): string {
+ const key = profileForRequest(req.hostname, req.path);
+ const profile = dbCache[key];
+ const forwardedProtocol = req.header("x-forwarded-proto")?.split(",")[0].trim();
+ const protocol = forwardedProtocol === "https" ? "https" : req.protocol;
+ const origin = `${protocol}://${req.get("host")}`;
+ const configuredDomain = cleanDomain(profile.customDomain?.split(",")[0] || "");
+ const canonical = configuredDomain ? `https://${configuredDomain}` : origin;
+ const title = profile.seoTitle?.trim() || `${profile.name} – ${profile.heroSubtitle || "Autor"}`;
+ const description = (profile.seoDescription?.trim() || profile.bio).replace(/\s+/g, " ").slice(0, 160);
+ const image = absoluteUrl(profile.socialImageUrl || profile.heroBannerUrl || profile.avatarUrl, origin);
+ const robots = profile.noIndex || req.path.startsWith("/admin") ? "noindex, nofollow, noarchive" : "index, follow";
+ const structuredData = JSON.stringify({
+ "@context": "https://schema.org",
+ "@type": "Person",
+ name: profile.name,
+ url: canonical,
+ image,
+ jobTitle: "Autor",
+ knowsAbout: profile.books.flatMap((book) => book.genres || []),
+ }).replace(/${escapeHtml(title)}`,
+ ` `,
+ ` `,
+ ` `,
+ ` `,
+ ` `,
+ ` `,
+ ` `,
+ ` `,
+ image ? ` ` : "",
+ ` `,
+ ``,
+ ].filter(Boolean).join("\n ");
+}
+
// --- API ROUTES ---
// 1. Get entire public author setup
app.get("/api/author-data", (req, res) => {
- res.json(dbCache || defaultAuthorData);
+ res.json(dbCache);
+});
+
+app.get("/health/live", (_req, res) => {
+ res.json({ status: "ok" });
+});
+
+app.get("/health/ready", (_req, res) => {
+ if (!serverReady || !dbCache) {
+ res.status(503).json({ status: "not_ready" });
+ return;
+ }
+ res.json({ status: "ok", revision: dbCache.revision ?? 0 });
});
// 2. Manage Admin Login
@@ -174,9 +334,16 @@ app.post("/api/admin/save-profile", verifyToken, async (req, res) => {
}
try {
- dbCache[profileKey] = profileData;
- await fs.writeFile(DATA_FILE, JSON.stringify(dbCache, null, 2), "utf-8");
- res.json({ success: true, message: "Profil erfolgreich gespeichert." });
+ if (!profileData || typeof profileData !== "object" || !Array.isArray(profileData.books) || !Array.isArray(profileData.projects)) {
+ res.status(400).json({ error: "Profildaten besitzen nicht die erwartete Struktur." });
+ return;
+ }
+ const nextData = await updateDatabase((current) => ({
+ ...current,
+ [profileKey]: profileData,
+ revision: (current.revision ?? 0) + 1,
+ }));
+ res.json({ success: true, revision: nextData.revision, message: "Profil erfolgreich gespeichert." });
} catch (err: any) {
console.error("Failed to write to database.json:", err);
res.status(500).json({ error: "Fehler beim persistenten Speichern der Formulardaten." });
@@ -192,9 +359,12 @@ app.post("/api/admin/save-legal", verifyToken, async (req, res) => {
}
try {
- dbCache.legalDocuments = legalDocuments;
- await fs.writeFile(DATA_FILE, JSON.stringify(dbCache, null, 2), "utf-8");
- res.json({ success: true, message: "Rechtliche Dokumente erfolgreich gespeichert." });
+ const nextData = await updateDatabase((current) => ({
+ ...current,
+ legalDocuments,
+ revision: (current.revision ?? 0) + 1,
+ }));
+ res.json({ success: true, revision: nextData.revision, message: "Rechtliche Dokumente erfolgreich gespeichert." });
} catch (err: any) {
console.error("Failed to write legal documents to database.json:", err);
res.status(500).json({ error: "Fehler beim Speichern der rechtlichen Dokumente." });
@@ -285,7 +455,7 @@ app.get("/api/admin/list-uploads", verifyToken, async (req, res) => {
// Configure Vite middleware or static serve
async function startServer() {
// Serve the dynamic uploads directory statically
- app.use("/uploads", express.static(path.join(process.cwd(), "data", "uploads")));
+ app.use("/uploads", express.static(path.join(DATA_DIR, "uploads")));
if (process.env.NODE_ENV !== "production") {
const { createServer: createViteServer } = await import("vite");
@@ -296,15 +466,53 @@ async function startServer() {
app.use(vite.middlewares);
} else {
const distPath = path.join(process.cwd(), "dist");
- app.use(express.static(distPath));
- app._router.get("*", (req: express.Request, res: express.Response) => {
- res.sendFile(path.join(distPath, "index.html"));
+ const indexTemplate = await fs.readFile(path.join(distPath, "index.html"), "utf-8");
+
+ app.get("/robots.txt", (req, res) => {
+ const key = profileForRequest(req.hostname, req.path);
+ const domain = cleanDomain(dbCache[key].customDomain?.split(",")[0] || req.get("host") || "");
+ res.type("text/plain").send(`User-agent: *\nAllow: /\nDisallow: /admin\n\nSitemap: https://${domain}/sitemap.xml\n`);
+ });
+
+ app.get("/sitemap.xml", (req, res) => {
+ const key = profileForRequest(req.hostname, req.path);
+ const profile = dbCache[key];
+ const domain = cleanDomain(profile.customDomain?.split(",")[0] || req.get("host") || "");
+ const location = `https://${domain}/`;
+ res.type("application/xml").send(`\n${escapeHtml(location)} `);
+ });
+
+ app.use(express.static(distPath, { index: false }));
+ app.get("*", (req: express.Request, res: express.Response) => {
+ const html = indexTemplate.replace("\n Autoren-Portfolio ", seoMeta(req));
+ if (req.path.startsWith("/admin")) res.setHeader("X-Robots-Tag", "noindex, nofollow, noarchive");
+ res.type("html").send(html);
});
}
- app.listen(PORT, "0.0.0.0", () => {
+ const server = app.listen(PORT, "0.0.0.0", () => {
+ serverReady = true;
console.log(`Server running on http://0.0.0.0:${PORT}`);
});
+
+ const shutdown = (signal: string) => {
+ serverReady = false;
+ console.log(`${signal} received, shutting down gracefully.`);
+ server.close(() => {
+ writeQueue.finally(() => process.exit(0));
+ });
+ setTimeout(() => process.exit(1), 10_000).unref();
+ };
+ process.once("SIGTERM", () => shutdown("SIGTERM"));
+ process.once("SIGINT", () => shutdown("SIGINT"));
}
-startServer();
+async function main() {
+ dbCache = await initDatabase();
+ await startServer();
+}
+
+main().catch((error) => {
+ console.error("Server startup failed; existing data was not overwritten:", error);
+ process.exit(1);
+});
diff --git a/src/App.tsx b/src/App.tsx
index d97e8c6..b5e292c 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,11 +1,21 @@
-import { useState, useEffect } from "react";
+import { lazy, Suspense, useState, useEffect } from "react";
import { AuthorData } from "./types";
-import SciFiPortfolio from "./components/SciFiPortfolio";
-import EroticaPortfolio from "./components/EroticaPortfolio";
-import ClaraPortfolio from "./components/ClaraPortfolio";
-import ReneePortfolio from "./components/ReneePortfolio";
-import AdminPanel from "./components/AdminPanel";
-import { Lock, Eye, Sparkles, RefreshCw, Leaf } from "lucide-react";
+import { Lock, RefreshCw } from "lucide-react";
+
+const SciFiPortfolio = lazy(() => import("./components/SciFiPortfolio"));
+const EroticaPortfolio = lazy(() => import("./components/EroticaPortfolio"));
+const ClaraPortfolio = lazy(() => import("./components/ClaraPortfolio"));
+const ReneePortfolio = lazy(() => import("./components/ReneePortfolio"));
+const AdminPanel = lazy(() => import("./components/AdminPanel"));
+
+function LoadingScreen() {
+ return (
+
+
+
Autoren-Portfolio wird geladen...
+
+ );
+}
export default function App() {
const [authorData, setAuthorData] = useState(null);
@@ -123,12 +133,7 @@ export default function App() {
};
if (loading) {
- return (
-
-
-
Kryptografisches Terminal bootet...
-
- );
+ return ;
}
if (!authorData) {
@@ -147,7 +152,7 @@ export default function App() {
// Purely route-based render isolation structure
// 1. Administration panel
if (currentPath === "/admin" || currentPath.startsWith("/admin/")) {
- return ;
+ return }> ;
}
// Render active portfolio
@@ -167,7 +172,7 @@ export default function App() {
};
return (
- <>
+ }>
{renderActivePortfolio()}
{/* Absolute floating administration bypass link hidden on the bottom left (hover to show) */}
@@ -185,6 +190,6 @@ export default function App() {
Admin-Zentrale
- >
+
);
}
diff --git a/src/components/AdminPanel.tsx b/src/components/AdminPanel.tsx
index 8d4406d..82dd123 100644
--- a/src/components/AdminPanel.tsx
+++ b/src/components/AdminPanel.tsx
@@ -163,7 +163,9 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
title: projectForm.title || "Unbenanntes Projekt",
status: projectForm.status || "Konzept",
progress: projectForm.progress || 0,
- description: projectForm.description || ""
+ description: projectForm.description || "",
+ imageUrl: projectForm.imageUrl || "",
+ spotifyPlaylistId: projectForm.spotifyPlaylistId || ""
};
updatedProjects.push(newProj);
} else {
@@ -1288,7 +1290,7 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
{
setEditingProjectId("new");
- setProjectForm({ title: "", status: "Konzept", progress: 0, description: "" });
+ setProjectForm({ title: "", status: "Konzept", progress: 0, description: "", imageUrl: "", spotifyPlaylistId: "" });
}}
className="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-white rounded-lg text-xs font-bold transition-all flex items-center gap-1.5 cursor-pointer"
>
@@ -1393,6 +1395,29 @@ 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"
/>
+
+ Detailbild
+ setProjectForm({ ...projectForm, imageUrl: url })}
+ token={token}
+ label="Projekt-Detailbild"
+ />
+
+
+
+
+ Spotify Playlist ID
+
+ setProjectForm({ ...projectForm, spotifyPlaylistId: e.target.value })}
+ placeholder="z.B. 47R6ZAdk7Xf6M4i9mR0fP1"
+ 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"
+ />
+ Playlist-ID oder vollständige Spotify-Playlist-URL.
+
diff --git a/src/components/ClaraPortfolio.tsx b/src/components/ClaraPortfolio.tsx
index 68202a4..480b483 100644
--- a/src/components/ClaraPortfolio.tsx
+++ b/src/components/ClaraPortfolio.tsx
@@ -4,6 +4,7 @@ import { Book, Project, AuthorProfile, LegalDocument } from "../types";
import { Leaf, Feather, Star, Sparkles, BookOpen, ExternalLink, ArrowRight } from "lucide-react";
import SpotlightSection from "./SpotlightSection";
import LegalModal from "./LegalModal";
+import ProjectDetailMedia from "./ProjectDetailMedia";
interface ClaraPortfolioProps {
data: AuthorProfile;
@@ -442,6 +443,8 @@ export default function ClaraPortfolio({ data, legalDocuments = [] }: ClaraPortf
+
+
{data.projectExcerptBadge && (
diff --git a/src/components/EroticaPortfolio.tsx b/src/components/EroticaPortfolio.tsx
index da9219f..efc462d 100644
--- a/src/components/EroticaPortfolio.tsx
+++ b/src/components/EroticaPortfolio.tsx
@@ -4,6 +4,7 @@ import { Book, Project, AuthorProfile, LegalDocument } from "../types";
import { Heart, Key, Star, Sparkles, BookOpen, ExternalLink, ArrowRight } from "lucide-react";
import SpotlightSection from "./SpotlightSection";
import LegalModal from "./LegalModal";
+import ProjectDetailMedia from "./ProjectDetailMedia";
interface EroticaPortfolioProps {
data: AuthorProfile;
@@ -442,6 +443,8 @@ export default function EroticaPortfolio({ data, legalDocuments = [] }: EroticaP
+
+
{data.projectExcerptBadge && (
diff --git a/src/components/ProjectDetailMedia.tsx b/src/components/ProjectDetailMedia.tsx
new file mode 100644
index 0000000..db092a6
--- /dev/null
+++ b/src/components/ProjectDetailMedia.tsx
@@ -0,0 +1,55 @@
+import { Music } from "lucide-react";
+import { Project } from "../types";
+
+interface ProjectDetailMediaProps {
+ project: Project;
+ accentColor: string;
+}
+
+function normalizeSpotifyPlaylistId(value?: string): string | null {
+ if (!value) return null;
+ const trimmed = value.trim();
+ const urlMatch = trimmed.match(/open\.spotify\.com\/playlist\/([a-zA-Z0-9]+)/);
+ const candidate = urlMatch?.[1] || trimmed;
+ return /^[a-zA-Z0-9]+$/.test(candidate) ? candidate : null;
+}
+
+export default function ProjectDetailMedia({ project, accentColor }: ProjectDetailMediaProps) {
+ const playlistId = normalizeSpotifyPlaylistId(project.spotifyPlaylistId);
+
+ if (!project.imageUrl && !playlistId) return null;
+
+ return (
+
+ {project.imageUrl && (
+
+
+
+ )}
+
+ {playlistId && (
+
+
+
+ Playlist zum Schreibprojekt
+
+
+
+ )}
+
+ );
+}
diff --git a/src/components/ReneePortfolio.tsx b/src/components/ReneePortfolio.tsx
index aed5195..bd3ec82 100644
--- a/src/components/ReneePortfolio.tsx
+++ b/src/components/ReneePortfolio.tsx
@@ -6,6 +6,7 @@ import {
} from "lucide-react";
import SpotlightSection from "./SpotlightSection";
import LegalModal from "./LegalModal";
+import ProjectDetailMedia from "./ProjectDetailMedia";
interface ReneePortfolioProps {
data: AuthorProfile;
@@ -425,6 +426,8 @@ export default function ReneePortfolio({ data, legalDocuments = [] }: ReneePortf
/>
+
+
{data.projectExcerptBadge && (
diff --git a/src/components/SciFiPortfolio.tsx b/src/components/SciFiPortfolio.tsx
index 0171728..bcf2dd7 100644
--- a/src/components/SciFiPortfolio.tsx
+++ b/src/components/SciFiPortfolio.tsx
@@ -4,6 +4,7 @@ import { Book, Project, AuthorProfile, LegalDocument } from "../types";
import { BookOpen, Star, HelpCircle, Award, Compass, ArrowRight, ExternalLink, Moon } from "lucide-react";
import SpotlightSection from "./SpotlightSection";
import LegalModal from "./LegalModal";
+import ProjectDetailMedia from "./ProjectDetailMedia";
interface SciFiPortfolioProps {
data: AuthorProfile;
@@ -467,6 +468,8 @@ export default function SciFiPortfolio({ data, legalDocuments = [] }: SciFiPortf
+
+
{data.projectExcerptBadge && (
diff --git a/src/types.ts b/src/types.ts
index 7372443..dc2296b 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -4,6 +4,8 @@ export interface Project {
status: string; // e.g., "Konzept", "In Arbeit", "Lektorat", "Abgeschlossen"
progress: number; // 0 to 100
description: string;
+ imageUrl?: string;
+ spotifyPlaylistId?: string;
}
export interface Book {
@@ -65,6 +67,12 @@ export interface AuthorProfile {
cardBgColor?: string;
fontFamily?: "sans" | "serif" | "mono";
heroBannerUrl?: string;
+
+ // Optional search/social metadata. Existing profiles work without these fields.
+ seoTitle?: string;
+ seoDescription?: string;
+ socialImageUrl?: string;
+ noIndex?: boolean;
}
export interface LegalDocument {
@@ -82,6 +90,9 @@ export interface AuthorData {
clara: AuthorProfile;
renee: AuthorProfile;
legalDocuments?: LegalDocument[];
+ // Additive storage metadata used for safe migrations and conflict detection.
+ schemaVersion?: number;
+ revision?: number;
}
export interface AdminSettings {