diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..81e0ffe --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +node_modules +dist +.git +.gitignore +Dockerfile +docker-compose.yml +README.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7a550fe --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +# GEMINI_API_KEY: Required for Gemini AI API calls. +# AI Studio automatically injects this at runtime from user secrets. +# Users configure this via the Secrets panel in the AI Studio UI. +GEMINI_API_KEY="MY_GEMINI_API_KEY" + +# 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. +APP_URL="MY_APP_URL" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5a86d2a --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +build/ +dist/ +coverage/ +.DS_Store +*.log +.env* +!.env.example diff --git a/README.md b/README.md index e69de29..8e76e13 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,52 @@ +# Chroniken der Leere - Gothic II Modifikation Website + +Offizielle Website für die Gothic II Modifikation **"Chroniken der Leere"** mit düsterer Low-Fantasy und Cosmic-Horror-Ästhetik sowie integriertem Online-CMS zur einfacheren Inhaltspflege ohne Code-Anpassungen. + +--- + +## 🚀 Schnellstart mit Docker Compose + +Die Anwendung ist als produktionsbereiter Docker-Stack konfiguriert. Alle Inhaltsänderungen, die du über das Online-CMS vornimmst, werden automatisch in `cms-data.json` gesichert und über den Volume-Mount auch bei Container-Neustarts dauerhaft gespeichert. + +### 1. Repository klonen & Container starten +```bash +git clone +cd + +# Stack im Hintergrund starten +docker compose up -d --build +``` + +Die Website ist danach im Browser unter `http://localhost:3000` erreichbar! + +### 2. Container stoppen +```bash +docker compose down +``` + +--- + +## 🔐 Modder Online-CMS Portal + +Du kannst alle Texte, Screenshots, News-Beiträge und Fortschritts-Balken direkt im Browser verwalten: + +1. Klicke oben rechts auf **"Modder Login"** (oder unten im Footer). +2. Gib das Passwort ein: `gothic2026` +3. Ändere beliebige Inhalte & klicke auf **"Änderungen Live Speichern"**. +4. Über das Dashboard kannst du deine Inhalte auch als **JSON exportieren & sichern**. + +--- + +## 🛠️ Lokale Entwicklung ohne Docker + +```bash +# Abhängigkeiten installieren +npm install + +# Entwicklungs-Server starten +npm run dev + +# Produktions-Build testen +npm run build +npm start +``` diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..e69de29 diff --git a/cms-data.json b/cms-data.json new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6d262ee --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +version: '3.8' + +services: + gothic2-mod-website: + build: + context: . + dockerfile: Dockerfile + container_name: gothic2_chroniken_leere + ports: + - "3000:3000" + environment: + - NODE_ENV=production + - PORT=3000 + volumes: + # Mount cms-data.json to persist content changes made via the online CMS portal + - ./cms-data.json:/app/cms-data.json + restart: unless-stopped diff --git a/index.html b/index.html new file mode 100644 index 0000000..21dfe69 --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + My Google AI Studio App + + +
+ + + + diff --git a/metadata.json b/metadata.json new file mode 100644 index 0000000..9efb227 --- /dev/null +++ b/metadata.json @@ -0,0 +1,6 @@ +{ + "name": "Chroniken der Leere - Gothic II Modifikation", + "description": "Offizielle Website für das Gothic 2 Modifikationsprojekt mit düsterer Low-Fantasy & Cosmic-Horror-Ästhetik. Enthält Story-Tabs, Screenshots, News, Fortschritts-Tracker und eine Online-Verwaltung (Admin-CMS).", + "requestFramePermissions": [], + "majorCapabilities": ["MAJOR_CAPABILITY_SERVER_SIDE_GEMINI_API"] +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..55128b8 --- /dev/null +++ b/package.json @@ -0,0 +1,35 @@ +{ + "name": "react-example", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "tsx server.ts", + "build": "vite build && esbuild server.ts --bundle --platform=node --format=cjs --packages=external --sourcemap --outfile=dist/server.cjs", + "start": "node dist/server.cjs", + "clean": "rm -rf dist server.js", + "lint": "tsc --noEmit" + }, + "dependencies": { + "@google/genai": "^2.4.0", + "@tailwindcss/vite": "^4.1.14", + "@vitejs/plugin-react": "^5.0.4", + "lucide-react": "^0.546.0", + "react": "^19.0.1", + "react-dom": "^19.0.1", + "vite": "^6.2.3", + "express": "^4.21.2", + "dotenv": "^17.2.3", + "motion": "^12.23.24" + }, + "devDependencies": { + "@types/node": "^22.14.0", + "autoprefixer": "^10.4.21", + "esbuild": "^0.25.0", + "tailwindcss": "^4.1.14", + "tsx": "^4.21.0", + "typescript": "~5.8.2", + "vite": "^6.2.3", + "@types/express": "^4.17.21" + } +} diff --git a/server.ts b/server.ts new file mode 100644 index 0000000..a62fca1 --- /dev/null +++ b/server.ts @@ -0,0 +1,81 @@ +import express from "express"; +import path from "path"; +import fs from "fs"; +import { createServer as createViteServer } from "vite"; +import { defaultCMSData } from "./src/data/defaultCMS.js"; + +const app = express(); +const PORT = 3000; +const DATA_FILE = path.join(process.cwd(), "cms-data.json"); + +app.use(express.json({ limit: "10mb" })); + +// Helper to read persistent CMS data +function loadCMSData() { + try { + if (fs.existsSync(DATA_FILE)) { + const raw = fs.readFileSync(DATA_FILE, "utf-8"); + return JSON.parse(raw); + } + } catch (err) { + console.error("Failed to load CMS file, using defaults:", err); + } + return defaultCMSData; +} + +// Helper to save CMS data +function saveCMSData(data: any) { + try { + fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2), "utf-8"); + } catch (err) { + console.error("Failed to save CMS file:", err); + } +} + +// Initialize file if not existing +if (!fs.existsSync(DATA_FILE)) { + saveCMSData(defaultCMSData); +} + +// CMS API Routes +app.get("/api/cms", (req, res) => { + const data = loadCMSData(); + res.json(data); +}); + +app.post("/api/cms", (req, res) => { + const newCMS = req.body; + if (!newCMS || typeof newCMS !== "object") { + return res.status(400).json({ error: "Invalid CMS data payload" }); + } + saveCMSData(newCMS); + res.json({ success: true, data: newCMS }); +}); + +app.post("/api/cms/reset", (req, res) => { + saveCMSData(defaultCMSData); + res.json({ success: true, data: defaultCMSData }); +}); + +// Start Express + Vite Dev or Production static server +async function start() { + if (process.env.NODE_ENV !== "production") { + const vite = await createViteServer({ + server: { middlewareMode: true }, + appType: "spa", + }); + app.use(vite.middlewares); + } else { + const distPath = path.join(process.cwd(), "dist"); + app.use(express.static(distPath)); + app.get("*", (req, res) => { + res.sendFile(path.join(distPath, "index.html")); + }); + } + + app.listen(PORT, "0.0.0.0", () => { + console.log(`[Gothic2 Mod Server] Running on http://0.0.0.0:${PORT}`); + }); +} + +start(); diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..c9ea930 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,98 @@ +import React from "react"; +import { useCMS } from "./lib/cmsStore"; +import { Navbar } from "./components/Navbar"; +import { HeroSection } from "./components/HeroSection"; +import { StoryTabsSection } from "./components/StoryTabsSection"; +import { GallerySection } from "./components/GallerySection"; +import { NewsSection } from "./components/NewsSection"; +import { ProgressSection } from "./components/ProgressSection"; +import { AdminCMSModal } from "./components/AdminCMSModal"; +import { CosmicHorrorBackground } from "./components/CosmicHorrorBackground"; +import { Footer } from "./components/Footer"; + +export default function App() { + const { + cms, + updateCMS, + resetCMS, + isLoading, + isAdminOpen, + setIsAdminOpen, + isUnlocked, + unlockAdmin, + } = useCMS(); + + return ( +
+ {/* Dynamic Cosmic Horror Background Layer */} + + + {/* Navigation Header */} + setIsAdminOpen(true)} + isUnlocked={isUnlocked} + /> + + {/* Main Single-Pager Content */} +
+ {/* Hero Section with prominent Mod Logo */} + setIsAdminOpen(true)} + isUnlocked={isUnlocked} + /> + + {/* Multi-Tab Story & Info Section */} + setIsAdminOpen(true)} + isUnlocked={isUnlocked} + /> + + {/* Screenshots Showcase Gallery */} + setIsAdminOpen(true)} + isUnlocked={isUnlocked} + /> + + {/* News & Devblog Feed */} + setIsAdminOpen(true)} + isUnlocked={isUnlocked} + /> + + {/* Development Progress & Milestones */} + setIsAdminOpen(true)} + isUnlocked={isUnlocked} + /> +
+ + {/* Footer */} +
setIsAdminOpen(true)} + isUnlocked={isUnlocked} + /> + + {/* Admin CMS Online-Management Modal */} + setIsAdminOpen(false)} + cms={cms} + onUpdateCMS={updateCMS} + onResetCMS={resetCMS} + isUnlocked={isUnlocked} + onUnlock={unlockAdmin} + /> +
+ ); +} diff --git a/src/assets/images/gothic_mod_hero_1785788710593.jpg b/src/assets/images/gothic_mod_hero_1785788710593.jpg new file mode 100644 index 0000000..a2591d9 Binary files /dev/null and b/src/assets/images/gothic_mod_hero_1785788710593.jpg differ diff --git a/src/assets/images/gothic_mod_logo_1785788724090.jpg b/src/assets/images/gothic_mod_logo_1785788724090.jpg new file mode 100644 index 0000000..9f51553 Binary files /dev/null and b/src/assets/images/gothic_mod_logo_1785788724090.jpg differ diff --git a/src/assets/images/gothic_screenshot_1_1785788737199.jpg b/src/assets/images/gothic_screenshot_1_1785788737199.jpg new file mode 100644 index 0000000..2224ebf Binary files /dev/null and b/src/assets/images/gothic_screenshot_1_1785788737199.jpg differ diff --git a/src/assets/images/gothic_screenshot_2_1785788748836.jpg b/src/assets/images/gothic_screenshot_2_1785788748836.jpg new file mode 100644 index 0000000..70a2adb Binary files /dev/null and b/src/assets/images/gothic_screenshot_2_1785788748836.jpg differ diff --git a/src/components/AdminCMSModal.tsx b/src/components/AdminCMSModal.tsx new file mode 100644 index 0000000..55eb7a7 --- /dev/null +++ b/src/components/AdminCMSModal.tsx @@ -0,0 +1,879 @@ +import React, { useState } from "react"; +import { X, Lock, Save, RotateCcw, Download, Upload, Plus, Trash2, Check, Sparkles, Layout, BookOpen, Image as ImageIcon, Newspaper, BarChart3, KeyRound, AlertCircle } from "lucide-react"; +import { CMSData, StoryTab, ScreenshotItem, NewsPost, DevCategoryProgress, Milestone } from "../types"; + +interface AdminCMSModalProps { + isOpen: boolean; + onClose: () => void; + cms: CMSData; + onUpdateCMS: (data: CMSData) => Promise; + onResetCMS: () => Promise; + isUnlocked: boolean; + onUnlock: (pin: string) => boolean; +} + +export const AdminCMSModal: React.FC = ({ + isOpen, + onClose, + cms, + onUpdateCMS, + onResetCMS, + isUnlocked, + onUnlock, +}) => { + if (!isOpen) return null; + + const [pinInput, setPinInput] = useState(""); + const [pinError, setPinError] = useState(false); + const [activeTab, setActiveTab] = useState<"hero" | "story" | "gallery" | "news" | "progress" | "backup">("hero"); + + // Local draft state for editing + const [formData, setFormData] = useState(cms); + const [isSavedNotice, setIsSavedNotice] = useState(false); + + const handlePinSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (onUnlock(pinInput)) { + setPinError(false); + } else { + setPinError(true); + } + }; + + const handleSave = async () => { + await onUpdateCMS(formData); + setIsSavedNotice(true); + setTimeout(() => setIsSavedNotice(false), 3000); + }; + + const handleReset = async () => { + if (window.confirm("Möchtest du wirklich alle Inhalte auf die Standardwerte zurücksetzen?")) { + await onResetCMS(); + onClose(); + } + }; + + // Export JSON + const handleExportJSON = () => { + const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(formData, null, 2)); + const downloadAnchor = document.createElement("a"); + downloadAnchor.setAttribute("href", dataStr); + downloadAnchor.setAttribute("download", `gothic2_mod_cms_export_${Date.now()}.json`); + document.body.appendChild(downloadAnchor); + downloadAnchor.click(); + downloadAnchor.remove(); + }; + + // Import JSON + const handleImportJSON = (e: React.ChangeEvent) => { + const fileReader = new FileReader(); + if (e.target.files && e.target.files[0]) { + fileReader.readAsText(e.target.files[0], "UTF-8"); + fileReader.onload = (event) => { + try { + const parsed = JSON.parse(event.target?.result as string); + if (parsed && parsed.hero) { + setFormData(parsed); + alert("Inhalte erfolgreich importiert!"); + } + } catch (err) { + alert("Fehler beim Lesen der JSON-Datei."); + } + }; + } + }; + + // Handlers for dynamic lists + // Story Tabs + const handleTabChange = (index: number, field: keyof StoryTab, val: any) => { + const newTabs = [...formData.tabs]; + newTabs[index] = { ...newTabs[index], [field]: val }; + setFormData({ ...formData, tabs: newTabs }); + }; + + const handleAddTab = () => { + const newTab: StoryTab = { + id: `tab-${Date.now()}`, + title: "Neuer Tab", + iconName: "BookOpen", + content: "Hier den Beschreibungstext für diesen Abschnitt eingeben...", + highlights: ["Punkt 1", "Punkt 2"] + }; + setFormData({ ...formData, tabs: [...formData.tabs, newTab] }); + }; + + const handleDeleteTab = (index: number) => { + const newTabs = formData.tabs.filter((_, i) => i !== index); + setFormData({ ...formData, tabs: newTabs }); + }; + + // Screenshots + const handleScreenshotChange = (index: number, field: keyof ScreenshotItem, val: any) => { + const newScreens = [...formData.screenshots]; + newScreens[index] = { ...newScreens[index], [field]: val }; + setFormData({ ...formData, screenshots: newScreens }); + }; + + const handleAddScreenshot = () => { + const newScreen: ScreenshotItem = { + id: `sc-${Date.now()}`, + title: "Neuer Screenshot", + category: "Landschaften", + imageUrl: "/src/assets/images/gothic_screenshot_1_1785788737199.jpg", + caption: "Kurze Beschreibung des Bildes..." + }; + setFormData({ ...formData, screenshots: [...formData.screenshots, newScreen] }); + }; + + const handleDeleteScreenshot = (index: number) => { + const newScreens = formData.screenshots.filter((_, i) => i !== index); + setFormData({ ...formData, screenshots: newScreens }); + }; + + // News + const handleNewsChange = (index: number, field: keyof NewsPost, val: any) => { + const newNews = [...formData.news]; + newNews[index] = { ...newNews[index], [field]: val }; + setFormData({ ...formData, news: newNews }); + }; + + const handleAddNews = () => { + const today = new Date().toLocaleDateString("de-DE", { day: "2-digit", month: "long", year: "numeric" }); + const newPost: NewsPost = { + id: `news-${Date.now()}`, + title: "Neue Entwickler-News", + date: today, + author: "Mod-Lead", + tag: "Entwicklung", + summary: "Kurze Zusammenfassung für die Kartenansicht...", + content: "Ausführlicher Text des News-Beitrags..." + }; + setFormData({ ...formData, news: [newPost, ...formData.news] }); + }; + + const handleDeleteNews = (index: number) => { + const newNews = formData.news.filter((_, i) => i !== index); + setFormData({ ...formData, news: newNews }); + }; + + // Progress Categories + const handleCategoryProgressChange = (index: number, field: keyof DevCategoryProgress, val: any) => { + const newCategories = [...formData.progress.categories]; + newCategories[index] = { ...newCategories[index], [field]: val }; + + // Recalculate average + const avg = Math.round(newCategories.reduce((acc, c) => acc + Number(c.percent), 0) / newCategories.length); + + setFormData({ + ...formData, + progress: { + ...formData.progress, + overall: avg, + categories: newCategories + } + }); + }; + + // Milestones + const handleMilestoneChange = (index: number, field: keyof Milestone, val: any) => { + const newMilestones = [...formData.progress.milestones]; + newMilestones[index] = { ...newMilestones[index], [field]: val }; + setFormData({ + ...formData, + progress: { + ...formData.progress, + milestones: newMilestones + } + }); + }; + + const handleAddMilestone = () => { + const newMs: Milestone = { + id: `ms-${Date.now()}`, + title: "Neuer Meilenstein", + status: "planned", + date: "2027", + description: "Beschreibung der Meilenstein-Aufgabe..." + }; + setFormData({ + ...formData, + progress: { + ...formData.progress, + milestones: [...formData.progress.milestones, newMs] + } + }); + }; + + const handleDeleteMilestone = (index: number) => { + const newMs = formData.progress.milestones.filter((_, i) => i !== index); + setFormData({ + ...formData, + progress: { + ...formData.progress, + milestones: newMs + } + }); + }; + + return ( +
+
+ + {/* Modal Header */} +
+
+
+ +
+
+

+ Modder Online-CMS Portal +

+

+ Website-Inhalte ohne Script-Kenntnisse verwalten +

+
+
+ + +
+ + {/* PIN LOCK SCREEN IF NOT UNLOCKED */} + {!isUnlocked ? ( +
+
+ +
+ +

+ Modder-Passwort erforderlich +

+

+ Bitte gib den Admin-PIN ein, um deine Mod-Inhalte direkt online zu bearbeiten. +
+ (Standard-PIN: gothic2026) +

+ +
+
+ setPinInput(e.target.value)} + placeholder="Passwort eingeben..." + className="w-full bg-zinc-950 border border-amber-900/60 rounded-lg px-4 py-3 text-center text-amber-100 font-mono tracking-widest text-lg focus:outline-none focus:border-amber-500" + autoFocus + /> + {pinError && ( +

+ Falscher PIN. Versuch es mit 'gothic2026'. +

+ )} +
+ +
+ + +
+
+
+ ) : ( + /* MAIN UNLOCKED CMS INTERFACE */ +
+ + {/* Sidebar Navigation */} +
+ + + + + + + + + + + +
+ + {/* Editor Workspace Panel */} +
+ + {/* TAB 1: HERO & ALLGEMEIN */} + {activeTab === "hero" && ( +
+

+ Hero-Bereich & Mod-Titel +

+ +
+
+ + setFormData({ ...formData, hero: { ...formData.hero, modTitle: e.target.value } })} + className="w-full bg-zinc-950 border border-zinc-800 rounded px-3 py-2 text-sm text-zinc-100 focus:border-amber-500" + /> +
+ +
+ + setFormData({ ...formData, hero: { ...formData.hero, subtitle: e.target.value } })} + className="w-full bg-zinc-950 border border-zinc-800 rounded px-3 py-2 text-sm text-zinc-100 focus:border-amber-500" + /> +
+
+ +
+ +