feat: harden persistence and enrich portfolio projects

This commit is contained in:
Daniel Heße 2026-08-14 22:43:40 +02:00
parent 7b639941b3
commit 69392c850a
12 changed files with 403 additions and 68 deletions

View file

@ -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.

View file

@ -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.

View file

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

292
server.ts
View file

@ -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<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
async function initDatabase(): Promise<AuthorData> {
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<AuthorData> {
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<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 ---
// 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(`<?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}`);
});
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 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 (
<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() {
const [authorData, setAuthorData] = useState<AuthorData | null>(null);
@ -123,12 +133,7 @@ export default function App() {
};
if (loading) {
return (
<div className="min-h-screen bg-slate-950 text-slate-100 flex flex-col items-center justify-center space-y-4">
<RefreshCw className="w-8 h-8 animate-spin text-cyan-400" />
<p className="font-mono text-xs text-slate-500 uppercase tracking-widest">Kryptografisches Terminal bootet...</p>
</div>
);
return <LoadingScreen />;
}
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 <AdminPanel onLogout={handleAdminLogout} />;
return <Suspense fallback={<LoadingScreen />}><AdminPanel onLogout={handleAdminLogout} /></Suspense>;
}
// Render active portfolio
@ -167,7 +172,7 @@ export default function App() {
};
return (
<>
<Suspense fallback={<LoadingScreen />}>
{renderActivePortfolio()}
{/* 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>
</a>
</div>
</>
</Suspense>
);
}

View file

@ -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) {
<button
onClick={() => {
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"
/>
</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 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 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
</div>
</div>
<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">

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 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
</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">
{data.projectExcerptBadge && (
<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";
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
/>
</div>
<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">

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 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
</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">
{data.projectExcerptBadge && (
<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"
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 {