feat: harden persistence and enrich portfolio projects #11

Merged
Dada1981 merged 1 commit from codex/persistence-seo-hardening into main 2026-08-14 20:46:59 +00:00
12 changed files with 403 additions and 68 deletions
Showing only changes of commit 69392c850a - Show all commits

View file

@ -3,6 +3,13 @@
# Users configure this via the Secrets panel in the AI Studio UI. # Users configure this via the Secrets panel in the AI Studio UI.
GEMINI_API_KEY="MY_GEMINI_API_KEY" 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. # APP_URL: The URL where this applet is hosted.
# AI Studio automatically injects this at runtime with the Cloud Run service URL. # AI Studio automatically injects this at runtime with the Cloud Run service URL.
# Used for self-referential links, OAuth callbacks, and API endpoints. # Used for self-referential links, OAuth callbacks, and API endpoints.

View file

@ -1,6 +1,6 @@
# 📚 Dual Identity Autoren-Zentrale (Selbstgehostet) # 📚 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**. 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. 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). 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 ## 🌌 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. - **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 ## 🧱 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` `./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. 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`. - **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. - **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.

View file

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

288
server.ts
View file

@ -1,7 +1,6 @@
import express from "express"; import express from "express";
import path from "path"; import path from "path";
import fs from "fs/promises"; import fs from "fs/promises";
import { fileURLToPath } from "url";
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";
@ -9,33 +8,84 @@ import { AuthorData, AuthorProfile } from "./src/types.js";
dotenv.config(); 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 app = express();
const PORT = 3000; const PORT = 3000;
// Path to durable local database file // 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 DATA_FILE = path.join(DATA_DIR, "database.json");
const BACKUP_DIR = path.join(DATA_DIR, "backups");
const CURRENT_SCHEMA_VERSION = 1;
let writeQueue: Promise<void> = 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<string, unknown>;
return profileKeys.every((key) => {
const profile = candidate[key] as Partial<AuthorProfile> | 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<void> {
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<AuthorData> {
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<void> {
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 // Establish initial database of authors
async function initDatabase(): Promise<AuthorData> { async function initDatabase(): Promise<AuthorData> {
try {
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 });
try { try {
const existingData = await fs.readFile(DATA_FILE, "utf-8"); 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; let modified = false;
// Ensure all dynamic static text and design properties exist for all profiles // 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) { for (const pKey of profileKeys) {
if (!parsed[pKey]) { if (!parsed[pKey]) {
parsed[pKey] = defaultAuthorData[pKey]; parsed[pKey] = defaultAuthorData[pKey];
@ -79,29 +129,39 @@ async function initDatabase(): Promise<AuthorData> {
modified = true; 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) { if (modified) {
await fs.writeFile(DATA_FILE, JSON.stringify(parsed, null, 2), "utf-8"); await createMigrationBackup(existingData);
await writeFileAtomically(parsed);
} }
return parsed; return parsed;
} catch { } catch (error: any) {
// If file doesn't exist, seed it with default data if (error?.code !== "ENOENT") throw error;
await fs.writeFile(DATA_FILE, JSON.stringify(defaultAuthorData, null, 2), "utf-8"); const initialData: AuthorData = {
return defaultAuthorData; ...defaultAuthorData,
} schemaVersion: CURRENT_SCHEMA_VERSION,
} catch (err) { revision: 0,
console.error("Database storage initialization failed, using default data in-memory:", err); };
return defaultAuthorData; await writeFileAtomically(initialData);
return initialData;
} }
} }
// In-memory runtime data cache, synced to active storage file // In-memory runtime data cache, synced to active storage file
let dbCache: AuthorData; let dbCache: AuthorData;
// Initialize on boot
initDatabase().then((data) => {
dbCache = data;
});
// Configure middleware // Configure middleware
app.use(express.json({ limit: "10mb" })); 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<ProfileKey, string[]> = {
erotica: ["/sensual-moments", "/annie-slone", "/marc-velvet"],
clara: ["/clara-finch", "/clara"],
renee: ["/renee-heart", "/renee"],
scifi: ["/sci-fi", "/daniel-hesse"],
};
const standardDomains: Record<ProfileKey, string[]> = {
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) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&#39;",
})[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(/</g, "\\u003c");
return [
`<title>${escapeHtml(title)}</title>`,
`<meta name="description" content="${escapeHtml(description)}" />`,
`<meta name="robots" content="${robots}" />`,
`<link rel="canonical" href="${escapeHtml(canonical)}" />`,
`<meta property="og:type" content="profile" />`,
`<meta property="og:locale" content="de_DE" />`,
`<meta property="og:title" content="${escapeHtml(title)}" />`,
`<meta property="og:description" content="${escapeHtml(description)}" />`,
`<meta property="og:url" content="${escapeHtml(canonical)}" />`,
image ? `<meta property="og:image" content="${escapeHtml(image)}" />` : "",
`<meta name="twitter:card" content="${image ? "summary_large_image" : "summary"}" />`,
`<script type="application/ld+json">${structuredData}</script>`,
].filter(Boolean).join("\n ");
}
// --- API ROUTES --- // --- API ROUTES ---
// 1. Get entire public author setup // 1. Get entire public author setup
app.get("/api/author-data", (req, res) => { 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 // 2. Manage Admin Login
@ -174,9 +334,16 @@ app.post("/api/admin/save-profile", verifyToken, async (req, res) => {
} }
try { try {
dbCache[profileKey] = profileData; if (!profileData || typeof profileData !== "object" || !Array.isArray(profileData.books) || !Array.isArray(profileData.projects)) {
await fs.writeFile(DATA_FILE, JSON.stringify(dbCache, null, 2), "utf-8"); res.status(400).json({ error: "Profildaten besitzen nicht die erwartete Struktur." });
res.json({ success: true, message: "Profil erfolgreich gespeichert." }); 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) { } catch (err: any) {
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." });
@ -192,9 +359,12 @@ app.post("/api/admin/save-legal", verifyToken, async (req, res) => {
} }
try { try {
dbCache.legalDocuments = legalDocuments; const nextData = await updateDatabase((current) => ({
await fs.writeFile(DATA_FILE, JSON.stringify(dbCache, null, 2), "utf-8"); ...current,
res.json({ success: true, message: "Rechtliche Dokumente erfolgreich gespeichert." }); legalDocuments,
revision: (current.revision ?? 0) + 1,
}));
res.json({ success: true, revision: nextData.revision, message: "Rechtliche Dokumente erfolgreich gespeichert." });
} catch (err: any) { } catch (err: any) {
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." });
@ -285,7 +455,7 @@ app.get("/api/admin/list-uploads", verifyToken, async (req, res) => {
// 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
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") { if (process.env.NODE_ENV !== "production") {
const { createServer: createViteServer } = await import("vite"); const { createServer: createViteServer } = await import("vite");
@ -296,15 +466,53 @@ async function startServer() {
app.use(vite.middlewares); app.use(vite.middlewares);
} else { } else {
const distPath = path.join(process.cwd(), "dist"); const distPath = path.join(process.cwd(), "dist");
app.use(express.static(distPath)); const indexTemplate = await fs.readFile(path.join(distPath, "index.html"), "utf-8");
app._router.get("*", (req: express.Request, res: express.Response) => {
res.sendFile(path.join(distPath, "index.html")); 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(`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"><url><loc>${escapeHtml(location)}</loc></url></urlset>`);
});
app.use(express.static(distPath, { index: false }));
app.get("*", (req: express.Request, res: express.Response) => {
const html = indexTemplate.replace("<!-- SEO_META -->\n <title>Autoren-Portfolio</title>", 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}`); 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);
});

View file

@ -1,11 +1,21 @@
import { useState, useEffect } from "react"; import { lazy, Suspense, useState, useEffect } from "react";
import { AuthorData } from "./types"; import { AuthorData } from "./types";
import SciFiPortfolio from "./components/SciFiPortfolio"; import { Lock, RefreshCw } from "lucide-react";
import EroticaPortfolio from "./components/EroticaPortfolio";
import ClaraPortfolio from "./components/ClaraPortfolio"; const SciFiPortfolio = lazy(() => import("./components/SciFiPortfolio"));
import ReneePortfolio from "./components/ReneePortfolio"; const EroticaPortfolio = lazy(() => import("./components/EroticaPortfolio"));
import AdminPanel from "./components/AdminPanel"; const ClaraPortfolio = lazy(() => import("./components/ClaraPortfolio"));
import { Lock, Eye, Sparkles, RefreshCw, Leaf } from "lucide-react"; const ReneePortfolio = lazy(() => import("./components/ReneePortfolio"));
const AdminPanel = lazy(() => import("./components/AdminPanel"));
function LoadingScreen() {
return (
<div className="min-h-screen bg-slate-950 text-slate-100 flex flex-col items-center justify-center space-y-4">
<RefreshCw className="w-8 h-8 animate-spin text-cyan-400" />
<p className="font-mono text-xs text-slate-500 uppercase tracking-widest">Autoren-Portfolio wird geladen...</p>
</div>
);
}
export default function App() { export default function App() {
const [authorData, setAuthorData] = useState<AuthorData | null>(null); const [authorData, setAuthorData] = useState<AuthorData | null>(null);
@ -123,12 +133,7 @@ export default function App() {
}; };
if (loading) { if (loading) {
return ( return <LoadingScreen />;
<div className="min-h-screen bg-slate-950 text-slate-100 flex flex-col items-center justify-center space-y-4">
<RefreshCw className="w-8 h-8 animate-spin text-cyan-400" />
<p className="font-mono text-xs text-slate-500 uppercase tracking-widest">Kryptografisches Terminal bootet...</p>
</div>
);
} }
if (!authorData) { if (!authorData) {
@ -147,7 +152,7 @@ export default function App() {
// Purely route-based render isolation structure // Purely route-based render isolation structure
// 1. Administration panel // 1. Administration panel
if (currentPath === "/admin" || currentPath.startsWith("/admin/")) { if (currentPath === "/admin" || currentPath.startsWith("/admin/")) {
return <AdminPanel onLogout={handleAdminLogout} />; return <Suspense fallback={<LoadingScreen />}><AdminPanel onLogout={handleAdminLogout} /></Suspense>;
} }
// Render active portfolio // Render active portfolio
@ -167,7 +172,7 @@ export default function App() {
}; };
return ( return (
<> <Suspense fallback={<LoadingScreen />}>
{renderActivePortfolio()} {renderActivePortfolio()}
{/* Absolute floating administration bypass link hidden on the bottom left (hover to show) */} {/* Absolute floating administration bypass link hidden on the bottom left (hover to show) */}
@ -185,6 +190,6 @@ export default function App() {
<span>Admin-Zentrale</span> <span>Admin-Zentrale</span>
</a> </a>
</div> </div>
</> </Suspense>
); );
} }

View file

@ -163,7 +163,9 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
title: projectForm.title || "Unbenanntes Projekt", title: projectForm.title || "Unbenanntes Projekt",
status: projectForm.status || "Konzept", status: projectForm.status || "Konzept",
progress: projectForm.progress || 0, progress: projectForm.progress || 0,
description: projectForm.description || "" description: projectForm.description || "",
imageUrl: projectForm.imageUrl || "",
spotifyPlaylistId: projectForm.spotifyPlaylistId || ""
}; };
updatedProjects.push(newProj); updatedProjects.push(newProj);
} else { } else {
@ -1288,7 +1290,7 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
<button <button
onClick={() => { onClick={() => {
setEditingProjectId("new"); 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" 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" 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 font-bold text-white">Detailbild</label>
<ImagePicker
value={projectForm.imageUrl || ""}
onChange={(url) => setProjectForm({ ...projectForm, imageUrl: url })}
token={token}
label="Projekt-Detailbild"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-mono text-slate-400 uppercase flex items-center gap-1">
<Music className="w-3.5 h-3.5" />
<span>Spotify Playlist ID</span>
</label>
<input
type="text"
value={projectForm.spotifyPlaylistId || ""}
onChange={(e) => 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"
/>
<span className="text-[9px] text-slate-500">Playlist-ID oder vollständige Spotify-Playlist-URL.</span>
</div>
</div> </div>
<div className="flex items-center gap-3 pt-2"> <div className="flex items-center gap-3 pt-2">

View file

@ -4,6 +4,7 @@ import { Book, Project, AuthorProfile, LegalDocument } from "../types";
import { Leaf, Feather, Star, Sparkles, BookOpen, ExternalLink, ArrowRight } from "lucide-react"; import { Leaf, Feather, Star, Sparkles, BookOpen, ExternalLink, ArrowRight } from "lucide-react";
import SpotlightSection from "./SpotlightSection"; import SpotlightSection from "./SpotlightSection";
import LegalModal from "./LegalModal"; import LegalModal from "./LegalModal";
import ProjectDetailMedia from "./ProjectDetailMedia";
interface ClaraPortfolioProps { interface ClaraPortfolioProps {
data: AuthorProfile; data: AuthorProfile;
@ -442,6 +443,8 @@ export default function ClaraPortfolio({ data, legalDocuments = [] }: ClaraPortf
</div> </div>
</div> </div>
<ProjectDetailMedia project={selectedProject} accentColor={accentColor} />
<div className="bg-[#4A5D50]/5 p-5 rounded-xl border border-[#4A5D50]/20 relative overflow-hidden"> <div className="bg-[#4A5D50]/5 p-5 rounded-xl border border-[#4A5D50]/20 relative overflow-hidden">
{data.projectExcerptBadge && ( {data.projectExcerptBadge && (
<div className="absolute top-0 right-0 p-2 text-stone-700 text-[10px] font-mono select-none"> <div className="absolute top-0 right-0 p-2 text-stone-700 text-[10px] font-mono select-none">

View file

@ -4,6 +4,7 @@ import { Book, Project, AuthorProfile, LegalDocument } from "../types";
import { Heart, Key, Star, Sparkles, BookOpen, ExternalLink, ArrowRight } from "lucide-react"; import { Heart, Key, Star, Sparkles, BookOpen, ExternalLink, ArrowRight } from "lucide-react";
import SpotlightSection from "./SpotlightSection"; import SpotlightSection from "./SpotlightSection";
import LegalModal from "./LegalModal"; import LegalModal from "./LegalModal";
import ProjectDetailMedia from "./ProjectDetailMedia";
interface EroticaPortfolioProps { interface EroticaPortfolioProps {
data: AuthorProfile; data: AuthorProfile;
@ -442,6 +443,8 @@ export default function EroticaPortfolio({ data, legalDocuments = [] }: EroticaP
</div> </div>
</div> </div>
<ProjectDetailMedia project={selectedProject} accentColor={accentColor} />
<div className="bg-orange-950/10 p-5 rounded-xl border border-orange-950/20 relative overflow-hidden"> <div className="bg-orange-950/10 p-5 rounded-xl border border-orange-950/20 relative overflow-hidden">
{data.projectExcerptBadge && ( {data.projectExcerptBadge && (
<div className="absolute top-0 right-0 p-2 text-neutral-700 text-[10px] font-mono select-none"> <div className="absolute top-0 right-0 p-2 text-neutral-700 text-[10px] font-mono select-none">

View file

@ -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 (
<div className="space-y-5">
{project.imageUrl && (
<div className="overflow-hidden rounded-xl border border-white/10 bg-black/20 shadow-lg">
<img
src={project.imageUrl}
alt={`Motiv zum Projekt ${project.title}`}
className="w-full max-h-[28rem] object-cover"
loading="lazy"
referrerPolicy="no-referrer"
/>
</div>
)}
{playlistId && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wider" style={{ color: accentColor }}>
<Music className="h-4 w-4" />
<span>Playlist zum Schreibprojekt</span>
</div>
<iframe
title={`Spotify-Playlist zu ${project.title}`}
src={`https://open.spotify.com/embed/playlist/${playlistId}?utm_source=generator&theme=0`}
width="100%"
height="152"
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
loading="lazy"
className="rounded-xl border-0"
/>
</div>
)}
</div>
);
}

View file

@ -6,6 +6,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import SpotlightSection from "./SpotlightSection"; import SpotlightSection from "./SpotlightSection";
import LegalModal from "./LegalModal"; import LegalModal from "./LegalModal";
import ProjectDetailMedia from "./ProjectDetailMedia";
interface ReneePortfolioProps { interface ReneePortfolioProps {
data: AuthorProfile; data: AuthorProfile;
@ -425,6 +426,8 @@ export default function ReneePortfolio({ data, legalDocuments = [] }: ReneePortf
/> />
</div> </div>
<ProjectDetailMedia project={selectedProject} accentColor={accentColor} />
<div className="bg-slate-900/60 p-5 rounded-xl border border-slate-800 relative overflow-hidden"> <div className="bg-slate-900/60 p-5 rounded-xl border border-slate-800 relative overflow-hidden">
{data.projectExcerptBadge && ( {data.projectExcerptBadge && (
<div className="absolute top-0 right-0 p-2 text-slate-700 text-[10px] font-mono select-none"> <div className="absolute top-0 right-0 p-2 text-slate-700 text-[10px] font-mono select-none">

View file

@ -4,6 +4,7 @@ import { Book, Project, AuthorProfile, LegalDocument } from "../types";
import { BookOpen, Star, HelpCircle, Award, Compass, ArrowRight, ExternalLink, Moon } from "lucide-react"; import { BookOpen, Star, HelpCircle, Award, Compass, ArrowRight, ExternalLink, Moon } from "lucide-react";
import SpotlightSection from "./SpotlightSection"; import SpotlightSection from "./SpotlightSection";
import LegalModal from "./LegalModal"; import LegalModal from "./LegalModal";
import ProjectDetailMedia from "./ProjectDetailMedia";
interface SciFiPortfolioProps { interface SciFiPortfolioProps {
data: AuthorProfile; data: AuthorProfile;
@ -467,6 +468,8 @@ export default function SciFiPortfolio({ data, legalDocuments = [] }: SciFiPortf
</div> </div>
</div> </div>
<ProjectDetailMedia project={selectedProject} accentColor={accentColor} />
<div className="bg-slate-950/60 p-5 rounded-xl border border-slate-800/60 relative overflow-hidden"> <div className="bg-slate-950/60 p-5 rounded-xl border border-slate-800/60 relative overflow-hidden">
{data.projectExcerptBadge && ( {data.projectExcerptBadge && (
<div className="absolute top-0 right-0 p-2 text-slate-700 text-[10px] font-mono select-none"> <div className="absolute top-0 right-0 p-2 text-slate-700 text-[10px] font-mono select-none">

View file

@ -4,6 +4,8 @@ export interface Project {
status: string; // e.g., "Konzept", "In Arbeit", "Lektorat", "Abgeschlossen" status: string; // e.g., "Konzept", "In Arbeit", "Lektorat", "Abgeschlossen"
progress: number; // 0 to 100 progress: number; // 0 to 100
description: string; description: string;
imageUrl?: string;
spotifyPlaylistId?: string;
} }
export interface Book { export interface Book {
@ -65,6 +67,12 @@ export interface AuthorProfile {
cardBgColor?: string; cardBgColor?: string;
fontFamily?: "sans" | "serif" | "mono"; fontFamily?: "sans" | "serif" | "mono";
heroBannerUrl?: string; heroBannerUrl?: string;
// Optional search/social metadata. Existing profiles work without these fields.
seoTitle?: string;
seoDescription?: string;
socialImageUrl?: string;
noIndex?: boolean;
} }
export interface LegalDocument { export interface LegalDocument {
@ -82,6 +90,9 @@ export interface AuthorData {
clara: AuthorProfile; clara: AuthorProfile;
renee: AuthorProfile; renee: AuthorProfile;
legalDocuments?: LegalDocument[]; legalDocuments?: LegalDocument[];
// Additive storage metadata used for safe migrations and conflict detection.
schemaVersion?: number;
revision?: number;
} }
export interface AdminSettings { export interface AdminSettings {