feat: harden persistence and enrich portfolio projects
This commit is contained in:
parent
7b639941b3
commit
69392c850a
12 changed files with 403 additions and 68 deletions
|
|
@ -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.
|
||||
|
|
|
|||
22
README.md
22
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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
288
server.ts
288
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<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> {
|
||||
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) => ({
|
||||
"&": "&", "<": "<", ">": ">", "\"": """, "'": "'",
|
||||
})[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);
|
||||
});
|
||||
|
|
|
|||
37
src/App.tsx
37
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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
55
src/components/ProjectDetailMedia.tsx
Normal file
55
src/components/ProjectDetailMedia.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
11
src/types.ts
11
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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue