feat(website): initial release of Chroniken der Leere Gothic II mod site
- Implement dark medieval & cosmic horror aesthetic single-page layout - Add customizable logo hero section, interactive story tabs, and gallery - Add news feed and developer progress/roadmap tracker - Integrate password-protected online CMS with live server & local storage sync
This commit is contained in:
parent
ee4d93457b
commit
192959cd65
32 changed files with 2751 additions and 0 deletions
7
.dockerignore
Normal file
7
.dockerignore
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
node_modules
|
||||
dist
|
||||
.git
|
||||
.gitignore
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
README.md
|
||||
9
.env.example
Normal file
9
.env.example
Normal file
|
|
@ -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"
|
||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
node_modules/
|
||||
build/
|
||||
dist/
|
||||
coverage/
|
||||
.DS_Store
|
||||
*.log
|
||||
.env*
|
||||
!.env.example
|
||||
52
README.md
52
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 <dein-repo-url>
|
||||
cd <repo-ordner>
|
||||
|
||||
# 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
|
||||
```
|
||||
0
bun.lock
Normal file
0
bun.lock
Normal file
0
cms-data.json
Normal file
0
cms-data.json
Normal file
17
docker-compose.yml
Normal file
17
docker-compose.yml
Normal file
|
|
@ -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
|
||||
13
index.html
Normal file
13
index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>My Google AI Studio App</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
6
metadata.json
Normal file
6
metadata.json
Normal file
|
|
@ -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"]
|
||||
}
|
||||
35
package.json
Normal file
35
package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
81
server.ts
Normal file
81
server.ts
Normal file
|
|
@ -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();
|
||||
98
src/App.tsx
Normal file
98
src/App.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="min-h-screen bg-zinc-950 text-zinc-100 font-sans selection:bg-amber-600 selection:text-zinc-950 relative overflow-x-hidden">
|
||||
{/* Dynamic Cosmic Horror Background Layer */}
|
||||
<CosmicHorrorBackground
|
||||
enabled={cms.cosmicAtmosphere.enabled}
|
||||
intensity={cms.cosmicAtmosphere.intensity}
|
||||
/>
|
||||
|
||||
{/* Navigation Header */}
|
||||
<Navbar
|
||||
cms={cms}
|
||||
onUpdateCMS={updateCMS}
|
||||
onOpenAdmin={() => setIsAdminOpen(true)}
|
||||
isUnlocked={isUnlocked}
|
||||
/>
|
||||
|
||||
{/* Main Single-Pager Content */}
|
||||
<main className="relative z-10">
|
||||
{/* Hero Section with prominent Mod Logo */}
|
||||
<HeroSection
|
||||
cms={cms}
|
||||
onOpenAdmin={() => setIsAdminOpen(true)}
|
||||
isUnlocked={isUnlocked}
|
||||
/>
|
||||
|
||||
{/* Multi-Tab Story & Info Section */}
|
||||
<StoryTabsSection
|
||||
cms={cms}
|
||||
onOpenAdmin={() => setIsAdminOpen(true)}
|
||||
isUnlocked={isUnlocked}
|
||||
/>
|
||||
|
||||
{/* Screenshots Showcase Gallery */}
|
||||
<GallerySection
|
||||
cms={cms}
|
||||
onOpenAdmin={() => setIsAdminOpen(true)}
|
||||
isUnlocked={isUnlocked}
|
||||
/>
|
||||
|
||||
{/* News & Devblog Feed */}
|
||||
<NewsSection
|
||||
cms={cms}
|
||||
onOpenAdmin={() => setIsAdminOpen(true)}
|
||||
isUnlocked={isUnlocked}
|
||||
/>
|
||||
|
||||
{/* Development Progress & Milestones */}
|
||||
<ProgressSection
|
||||
cms={cms}
|
||||
onOpenAdmin={() => setIsAdminOpen(true)}
|
||||
isUnlocked={isUnlocked}
|
||||
/>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<Footer
|
||||
cms={cms}
|
||||
onOpenAdmin={() => setIsAdminOpen(true)}
|
||||
isUnlocked={isUnlocked}
|
||||
/>
|
||||
|
||||
{/* Admin CMS Online-Management Modal */}
|
||||
<AdminCMSModal
|
||||
isOpen={isAdminOpen}
|
||||
onClose={() => setIsAdminOpen(false)}
|
||||
cms={cms}
|
||||
onUpdateCMS={updateCMS}
|
||||
onResetCMS={resetCMS}
|
||||
isUnlocked={isUnlocked}
|
||||
onUnlock={unlockAdmin}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
BIN
src/assets/images/gothic_mod_hero_1785788710593.jpg
Normal file
BIN
src/assets/images/gothic_mod_hero_1785788710593.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 749 KiB |
BIN
src/assets/images/gothic_mod_logo_1785788724090.jpg
Normal file
BIN
src/assets/images/gothic_mod_logo_1785788724090.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 936 KiB |
BIN
src/assets/images/gothic_screenshot_1_1785788737199.jpg
Normal file
BIN
src/assets/images/gothic_screenshot_1_1785788737199.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 734 KiB |
BIN
src/assets/images/gothic_screenshot_2_1785788748836.jpg
Normal file
BIN
src/assets/images/gothic_screenshot_2_1785788748836.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 885 KiB |
879
src/components/AdminCMSModal.tsx
Normal file
879
src/components/AdminCMSModal.tsx
Normal file
|
|
@ -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<void>;
|
||||
onResetCMS: () => Promise<void>;
|
||||
isUnlocked: boolean;
|
||||
onUnlock: (pin: string) => boolean;
|
||||
}
|
||||
|
||||
export const AdminCMSModal: React.FC<AdminCMSModalProps> = ({
|
||||
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<CMSData>(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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div className="fixed inset-0 z-50 bg-zinc-950/90 backdrop-blur-lg flex items-center justify-center p-2 sm:p-4">
|
||||
<div className="bg-zinc-900 border border-amber-600/60 rounded-2xl w-full max-w-5xl h-[92vh] flex flex-col shadow-2xl overflow-hidden relative text-zinc-100">
|
||||
|
||||
{/* Modal Header */}
|
||||
<div className="bg-zinc-950 border-b border-amber-900/50 px-6 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-amber-950 rounded-lg border border-amber-700/60 text-amber-400">
|
||||
<Lock className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-serif font-bold text-amber-100 flex items-center gap-2">
|
||||
Modder Online-CMS Portal
|
||||
</h2>
|
||||
<p className="text-xs text-zinc-400 font-mono">
|
||||
Website-Inhalte ohne Script-Kenntnisse verwalten
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-zinc-400 hover:text-amber-200 rounded-full hover:bg-zinc-800 transition-colors"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* PIN LOCK SCREEN IF NOT UNLOCKED */}
|
||||
{!isUnlocked ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-6 text-center max-w-md mx-auto">
|
||||
<div className="w-16 h-16 rounded-full bg-amber-950/80 border border-amber-600/60 flex items-center justify-center text-amber-400 mb-4 shadow-xl">
|
||||
<KeyRound className="w-8 h-8" />
|
||||
</div>
|
||||
|
||||
<h3 className="text-2xl font-serif font-bold text-amber-100 mb-2">
|
||||
Modder-Passwort erforderlich
|
||||
</h3>
|
||||
<p className="text-zinc-400 text-sm mb-6 leading-relaxed">
|
||||
Bitte gib den Admin-PIN ein, um deine Mod-Inhalte direkt online zu bearbeiten.
|
||||
<br />
|
||||
<span className="text-amber-400/80 font-mono text-xs">(Standard-PIN: gothic2026)</span>
|
||||
</p>
|
||||
|
||||
<form onSubmit={handlePinSubmit} className="w-full space-y-4">
|
||||
<div>
|
||||
<input
|
||||
type="password"
|
||||
value={pinInput}
|
||||
onChange={(e) => 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 && (
|
||||
<p className="text-red-400 text-xs mt-2 flex items-center justify-center gap-1 font-mono">
|
||||
<AlertCircle className="w-3.5 h-3.5" /> Falscher PIN. Versuch es mit 'gothic2026'.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 bg-amber-600 hover:bg-amber-500 text-zinc-950 font-serif font-bold py-3 rounded-lg transition-colors shadow-lg"
|
||||
>
|
||||
Einloggen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onUnlock("gothic2026")}
|
||||
className="bg-amber-950 hover:bg-amber-900 border border-amber-700/60 text-amber-300 text-xs font-mono px-4 py-3 rounded-lg transition-colors"
|
||||
>
|
||||
Demo-Unlock
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
) : (
|
||||
/* MAIN UNLOCKED CMS INTERFACE */
|
||||
<div className="flex-1 flex flex-col md:flex-row overflow-hidden">
|
||||
|
||||
{/* Sidebar Navigation */}
|
||||
<div className="w-full md:w-60 bg-zinc-950 border-r border-amber-900/40 p-3 space-y-1.5 shrink-0 overflow-x-auto md:overflow-y-auto flex md:flex-col">
|
||||
<button
|
||||
onClick={() => setActiveTab("hero")}
|
||||
className={`w-full flex items-center gap-2.5 px-3.5 py-2.5 rounded-lg font-serif text-xs font-semibold tracking-wider transition-colors text-left shrink-0 ${
|
||||
activeTab === "hero"
|
||||
? "bg-amber-900/60 text-amber-100 border border-amber-600/50"
|
||||
: "text-zinc-400 hover:text-amber-200 hover:bg-zinc-900"
|
||||
}`}
|
||||
>
|
||||
<Layout className="w-4 h-4 text-amber-400" />
|
||||
<span>Allgemein & Hero</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab("story")}
|
||||
className={`w-full flex items-center gap-2.5 px-3.5 py-2.5 rounded-lg font-serif text-xs font-semibold tracking-wider transition-colors text-left shrink-0 ${
|
||||
activeTab === "story"
|
||||
? "bg-amber-900/60 text-amber-100 border border-amber-600/50"
|
||||
: "text-zinc-400 hover:text-amber-200 hover:bg-zinc-900"
|
||||
}`}
|
||||
>
|
||||
<BookOpen className="w-4 h-4 text-amber-400" />
|
||||
<span>Story & Tabs ({formData.tabs.length})</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab("gallery")}
|
||||
className={`w-full flex items-center gap-2.5 px-3.5 py-2.5 rounded-lg font-serif text-xs font-semibold tracking-wider transition-colors text-left shrink-0 ${
|
||||
activeTab === "gallery"
|
||||
? "bg-amber-900/60 text-amber-100 border border-amber-600/50"
|
||||
: "text-zinc-400 hover:text-amber-200 hover:bg-zinc-900"
|
||||
}`}
|
||||
>
|
||||
<ImageIcon className="w-4 h-4 text-amber-400" />
|
||||
<span>Screenshots ({formData.screenshots.length})</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab("news")}
|
||||
className={`w-full flex items-center gap-2.5 px-3.5 py-2.5 rounded-lg font-serif text-xs font-semibold tracking-wider transition-colors text-left shrink-0 ${
|
||||
activeTab === "news"
|
||||
? "bg-amber-900/60 text-amber-100 border border-amber-600/50"
|
||||
: "text-zinc-400 hover:text-amber-200 hover:bg-zinc-900"
|
||||
}`}
|
||||
>
|
||||
<Newspaper className="w-4 h-4 text-amber-400" />
|
||||
<span>News & Blog ({formData.news.length})</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab("progress")}
|
||||
className={`w-full flex items-center gap-2.5 px-3.5 py-2.5 rounded-lg font-serif text-xs font-semibold tracking-wider transition-colors text-left shrink-0 ${
|
||||
activeTab === "progress"
|
||||
? "bg-amber-900/60 text-amber-100 border border-amber-600/50"
|
||||
: "text-zinc-400 hover:text-amber-200 hover:bg-zinc-900"
|
||||
}`}
|
||||
>
|
||||
<BarChart3 className="w-4 h-4 text-amber-400" />
|
||||
<span>Fortschritt ({formData.progress.overall}%)</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab("backup")}
|
||||
className={`w-full flex items-center gap-2.5 px-3.5 py-2.5 rounded-lg font-serif text-xs font-semibold tracking-wider transition-colors text-left shrink-0 ${
|
||||
activeTab === "backup"
|
||||
? "bg-amber-900/60 text-amber-100 border border-amber-600/50"
|
||||
: "text-zinc-400 hover:text-amber-200 hover:bg-zinc-900"
|
||||
}`}
|
||||
>
|
||||
<Download className="w-4 h-4 text-purple-400" />
|
||||
<span>Backup & Export</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Editor Workspace Panel */}
|
||||
<div className="flex-1 p-6 overflow-y-auto space-y-6 bg-zinc-900">
|
||||
|
||||
{/* TAB 1: HERO & ALLGEMEIN */}
|
||||
{activeTab === "hero" && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-serif font-bold text-amber-200 border-b border-amber-900/40 pb-2">
|
||||
Hero-Bereich & Mod-Titel
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-mono text-amber-400 mb-1">Mod-Titel</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.hero.modTitle}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-mono text-amber-400 mb-1">Untertitel / Slogan</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.hero.subtitle}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-mono text-amber-400 mb-1">Einleitung / Tagline</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
value={formData.hero.tagline}
|
||||
onChange={(e) => setFormData({ ...formData, hero: { ...formData.hero, tagline: 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-mono text-amber-400 mb-1">Logo-Bild URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.hero.logoUrl}
|
||||
onChange={(e) => setFormData({ ...formData, hero: { ...formData.hero, logoUrl: e.target.value } })}
|
||||
className="w-full bg-zinc-950 border border-zinc-800 rounded px-3 py-2 text-sm text-zinc-100 font-mono text-xs focus:border-amber-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-mono text-amber-400 mb-1">Hero Hintergrund-Bild URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.hero.heroBgUrl}
|
||||
onChange={(e) => setFormData({ ...formData, hero: { ...formData.hero, heroBgUrl: e.target.value } })}
|
||||
className="w-full bg-zinc-950 border border-zinc-800 rounded px-3 py-2 text-sm text-zinc-100 font-mono text-xs focus:border-amber-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-mono text-amber-400 mb-1">Engine / Version</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.hero.engine}
|
||||
onChange={(e) => setFormData({ ...formData, hero: { ...formData.hero, engine: 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-mono text-amber-400 mb-1">Geplantes Release-Datum</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.hero.expectedRelease}
|
||||
onChange={(e) => setFormData({ ...formData, hero: { ...formData.hero, expectedRelease: 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 2: STORY & TABS */}
|
||||
{activeTab === "story" && (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between border-b border-amber-900/40 pb-2">
|
||||
<h3 className="text-lg font-serif font-bold text-amber-200">
|
||||
Story & Inhalts-Tabs verwalten
|
||||
</h3>
|
||||
<button
|
||||
onClick={handleAddTab}
|
||||
className="px-3 py-1.5 rounded bg-amber-800 hover:bg-amber-700 text-amber-100 text-xs font-mono flex items-center gap-1"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" /> Neuen Tab erstellen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{formData.tabs.map((tab, idx) => (
|
||||
<div key={tab.id} className="bg-zinc-950 border border-zinc-800 rounded-lg p-4 space-y-3 relative">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-mono text-amber-500">Tab #{idx + 1}</span>
|
||||
<button
|
||||
onClick={() => handleDeleteTab(idx)}
|
||||
className="text-red-400 hover:text-red-300 p-1 text-xs flex items-center gap-1"
|
||||
title="Tab löschen"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-[11px] font-mono text-zinc-400 mb-1">Tab Titel</label>
|
||||
<input
|
||||
type="text"
|
||||
value={tab.title}
|
||||
onChange={(e) => handleTabChange(idx, "title", e.target.value)}
|
||||
className="w-full bg-zinc-900 border border-zinc-700 rounded px-3 py-1.5 text-sm text-amber-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[11px] font-mono text-zinc-400 mb-1">Icon Identifier</label>
|
||||
<select
|
||||
value={tab.iconName}
|
||||
onChange={(e) => handleTabChange(idx, "iconName", e.target.value)}
|
||||
className="w-full bg-zinc-900 border border-zinc-700 rounded px-3 py-1.5 text-sm text-amber-100 font-mono"
|
||||
>
|
||||
<option value="BookOpen">BookOpen (Story)</option>
|
||||
<option value="Sparkles">Sparkles (Features)</option>
|
||||
<option value="Shield">Shield (Fraktionen)</option>
|
||||
<option value="HelpCircle">HelpCircle (FAQ)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[11px] font-mono text-zinc-400 mb-1">Haupttext</label>
|
||||
<textarea
|
||||
rows={4}
|
||||
value={tab.content}
|
||||
onChange={(e) => handleTabChange(idx, "content", e.target.value)}
|
||||
className="w-full bg-zinc-900 border border-zinc-700 rounded px-3 py-2 text-sm text-zinc-200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 3: SCREENSHOTS */}
|
||||
{activeTab === "gallery" && (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between border-b border-amber-900/40 pb-2">
|
||||
<h3 className="text-lg font-serif font-bold text-amber-200">
|
||||
Galerie & Ingame Screenshots
|
||||
</h3>
|
||||
<button
|
||||
onClick={handleAddScreenshot}
|
||||
className="px-3 py-1.5 rounded bg-amber-800 hover:bg-amber-700 text-amber-100 text-xs font-mono flex items-center gap-1"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" /> Screenshot hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{formData.screenshots.map((sc, idx) => (
|
||||
<div key={sc.id} className="bg-zinc-950 border border-zinc-800 rounded-lg p-3 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-mono text-amber-500">Bild #{idx + 1}</span>
|
||||
<button
|
||||
onClick={() => handleDeleteScreenshot(idx)}
|
||||
className="text-red-400 hover:text-red-300 p-1"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Titel"
|
||||
value={sc.title}
|
||||
onChange={(e) => handleScreenshotChange(idx, "title", e.target.value)}
|
||||
className="w-full bg-zinc-900 border border-zinc-700 rounded px-2.5 py-1 text-xs text-amber-100"
|
||||
/>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Kategorie (z.B. Landschaften, Kosmischer Horror)"
|
||||
value={sc.category}
|
||||
onChange={(e) => handleScreenshotChange(idx, "category", e.target.value)}
|
||||
className="w-full bg-zinc-900 border border-zinc-700 rounded px-2.5 py-1 text-xs text-purple-300 font-mono"
|
||||
/>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Bild-URL"
|
||||
value={sc.imageUrl}
|
||||
onChange={(e) => handleScreenshotChange(idx, "imageUrl", e.target.value)}
|
||||
className="w-full bg-zinc-900 border border-zinc-700 rounded px-2.5 py-1 text-[11px] text-zinc-300 font-mono"
|
||||
/>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Beschreibung / Bildunterschrift"
|
||||
value={sc.caption}
|
||||
onChange={(e) => handleScreenshotChange(idx, "caption", e.target.value)}
|
||||
className="w-full bg-zinc-900 border border-zinc-700 rounded px-2.5 py-1 text-xs text-zinc-300"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 4: NEWS */}
|
||||
{activeTab === "news" && (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between border-b border-amber-900/40 pb-2">
|
||||
<h3 className="text-lg font-serif font-bold text-amber-200">
|
||||
News-Beiträge & Dev-Blog
|
||||
</h3>
|
||||
<button
|
||||
onClick={handleAddNews}
|
||||
className="px-3 py-1.5 rounded bg-amber-800 hover:bg-amber-700 text-amber-100 text-xs font-mono flex items-center gap-1"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" /> Neuer News-Beitrag
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{formData.news.map((post, idx) => (
|
||||
<div key={post.id} className="bg-zinc-950 border border-zinc-800 rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-mono text-amber-500">Beitrag #{idx + 1}</span>
|
||||
<button
|
||||
onClick={() => handleDeleteNews(idx)}
|
||||
className="text-red-400 hover:text-red-300 p-1"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Titel"
|
||||
value={post.title}
|
||||
onChange={(e) => handleNewsChange(idx, "title", e.target.value)}
|
||||
className="sm:col-span-2 bg-zinc-900 border border-zinc-700 rounded px-3 py-1 text-sm text-amber-100"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Tag (z.B. Entwicklung, Showcase)"
|
||||
value={post.tag}
|
||||
onChange={(e) => handleNewsChange(idx, "tag", e.target.value)}
|
||||
className="bg-zinc-900 border border-zinc-700 rounded px-3 py-1 text-xs text-amber-300 font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Datum"
|
||||
value={post.date}
|
||||
onChange={(e) => handleNewsChange(idx, "date", e.target.value)}
|
||||
className="bg-zinc-900 border border-zinc-700 rounded px-3 py-1 text-xs text-zinc-300 font-mono"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Autor"
|
||||
value={post.author}
|
||||
onChange={(e) => handleNewsChange(idx, "author", e.target.value)}
|
||||
className="bg-zinc-900 border border-zinc-700 rounded px-3 py-1 text-xs text-zinc-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
rows={2}
|
||||
placeholder="Kurze Vorschau für die Karte..."
|
||||
value={post.summary}
|
||||
onChange={(e) => handleNewsChange(idx, "summary", e.target.value)}
|
||||
className="w-full bg-zinc-900 border border-zinc-700 rounded px-3 py-1.5 text-xs text-zinc-200"
|
||||
/>
|
||||
|
||||
<textarea
|
||||
rows={4}
|
||||
placeholder="Vollständiger News-Text..."
|
||||
value={post.content}
|
||||
onChange={(e) => handleNewsChange(idx, "content", e.target.value)}
|
||||
className="w-full bg-zinc-900 border border-zinc-700 rounded px-3 py-1.5 text-xs text-zinc-200"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 5: PROGRESS & ROADMAP */}
|
||||
{activeTab === "progress" && (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-serif font-bold text-amber-200 border-b border-amber-900/40 pb-2">
|
||||
Fortschritts-Tracker & Meilensteine
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-xs font-mono uppercase tracking-widest text-amber-400">
|
||||
Entwicklungsbereiche (%)
|
||||
</h4>
|
||||
|
||||
{formData.progress.categories.map((cat, idx) => (
|
||||
<div key={cat.id} className="bg-zinc-950 border border-zinc-800 rounded p-3 space-y-2">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<input
|
||||
type="text"
|
||||
value={cat.name}
|
||||
onChange={(e) => handleCategoryProgressChange(idx, "name", e.target.value)}
|
||||
className="bg-zinc-900 border border-zinc-700 rounded px-2.5 py-1 text-xs text-amber-100 flex-1"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
value={cat.percent}
|
||||
onChange={(e) => handleCategoryProgressChange(idx, "percent", Number(e.target.value))}
|
||||
className="w-16 bg-zinc-900 border border-zinc-700 rounded px-2 py-1 text-xs text-amber-400 font-mono text-center"
|
||||
/>
|
||||
<span className="text-xs text-amber-400">%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={cat.details}
|
||||
onChange={(e) => handleCategoryProgressChange(idx, "details", e.target.value)}
|
||||
className="w-full bg-zinc-900 border border-zinc-700 rounded px-2.5 py-1 text-xs text-zinc-400"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-zinc-800 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-xs font-mono uppercase tracking-widest text-amber-400">
|
||||
Meilensteine
|
||||
</h4>
|
||||
<button
|
||||
onClick={handleAddMilestone}
|
||||
className="px-2.5 py-1 rounded bg-amber-800 text-amber-100 text-xs font-mono flex items-center gap-1"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" /> Meilenstein hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{formData.progress.milestones.map((ms, idx) => (
|
||||
<div key={ms.id} className="bg-zinc-950 border border-zinc-800 rounded p-3 space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={ms.title}
|
||||
onChange={(e) => handleMilestoneChange(idx, "title", e.target.value)}
|
||||
className="bg-zinc-900 border border-zinc-700 rounded px-2.5 py-1 text-xs text-amber-100 flex-1"
|
||||
/>
|
||||
<select
|
||||
value={ms.status}
|
||||
onChange={(e) => handleMilestoneChange(idx, "status", e.target.value)}
|
||||
className="bg-zinc-900 border border-zinc-700 rounded px-2 py-1 text-xs text-amber-300 font-mono"
|
||||
>
|
||||
<option value="completed">Abgeschlossen</option>
|
||||
<option value="in-progress">In Arbeit</option>
|
||||
<option value="planned">Geplant</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={() => handleDeleteMilestone(idx)}
|
||||
className="text-red-400 hover:text-red-300 p-1"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Datum / Quartal"
|
||||
value={ms.date}
|
||||
onChange={(e) => handleMilestoneChange(idx, "date", e.target.value)}
|
||||
className="bg-zinc-900 border border-zinc-700 rounded px-2.5 py-1 text-xs text-zinc-300 font-mono"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Kurze Beschreibung"
|
||||
value={ms.description}
|
||||
onChange={(e) => handleMilestoneChange(idx, "description", e.target.value)}
|
||||
className="bg-zinc-900 border border-zinc-700 rounded px-2.5 py-1 text-xs text-zinc-300"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 6: BACKUP & EXPORT */}
|
||||
{activeTab === "backup" && (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-serif font-bold text-amber-200 border-b border-amber-900/40 pb-2">
|
||||
Inhalte sichern, laden & zurücksetzen
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="bg-zinc-950 border border-zinc-800 rounded-xl p-5 space-y-3">
|
||||
<h4 className="font-serif font-bold text-amber-100 flex items-center gap-2">
|
||||
<Download className="w-4 h-4 text-purple-400" />
|
||||
JSON Export
|
||||
</h4>
|
||||
<p className="text-xs text-zinc-400 leading-relaxed">
|
||||
Lade alle aktuellen Texte, Screenshots, News & Fortschritte als JSON-Sicherung herunter.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleExportJSON}
|
||||
className="w-full py-2.5 rounded bg-purple-900/80 hover:bg-purple-800 text-purple-100 text-xs font-mono font-semibold transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<Download className="w-4 h-4" /> JSON Datei Herunterladen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-zinc-950 border border-zinc-800 rounded-xl p-5 space-y-3">
|
||||
<h4 className="font-serif font-bold text-amber-100 flex items-center gap-2">
|
||||
<Upload className="w-4 h-4 text-amber-400" />
|
||||
JSON Import
|
||||
</h4>
|
||||
<p className="text-xs text-zinc-400 leading-relaxed">
|
||||
Lade eine zuvor exportierte JSON-Datei hoch, um alle Inhalte sofort einzuspielen.
|
||||
</p>
|
||||
<label className="w-full py-2.5 rounded bg-amber-950 hover:bg-amber-900 border border-amber-700/60 text-amber-200 text-xs font-mono font-semibold transition-colors flex items-center justify-center gap-2 cursor-pointer">
|
||||
<Upload className="w-4 h-4" /> JSON Datei Auswählen
|
||||
<input type="file" accept=".json" onChange={handleImportJSON} className="hidden" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-6 border-t border-zinc-800">
|
||||
<div className="bg-red-950/30 border border-red-900/50 rounded-xl p-5 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div>
|
||||
<h4 className="font-serif font-bold text-red-200 text-sm">
|
||||
Standardwerte wiederherstellen
|
||||
</h4>
|
||||
<p className="text-xs text-red-300/80">
|
||||
Setzt alle geänderten Texte und Daten auf die Werkseinstellungen des Gothic II Projekts zurück.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="px-4 py-2 rounded bg-red-900/80 hover:bg-red-800 text-red-100 text-xs font-mono font-bold shrink-0 flex items-center gap-1.5"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" /> Auf Standard Zurücksetzen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal Footer with Save Button */}
|
||||
{isUnlocked && (
|
||||
<div className="bg-zinc-950 border-t border-amber-900/50 px-6 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{isSavedNotice && (
|
||||
<span className="text-emerald-400 text-xs font-mono flex items-center gap-1 animate-fade-in">
|
||||
<Check className="w-4 h-4" /> Gespeichert & Live Aktualisiert!
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded text-zinc-400 hover:text-zinc-200 text-xs font-mono"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="px-6 py-2.5 rounded bg-amber-600 hover:bg-amber-500 text-zinc-950 font-serif font-bold text-sm transition-all shadow-[0_0_15px_rgba(245,158,11,0.3)] flex items-center gap-2"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
<span>Änderungen Live Speichern</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
40
src/components/CosmicHorrorBackground.tsx
Normal file
40
src/components/CosmicHorrorBackground.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import React from "react";
|
||||
|
||||
interface CosmicHorrorBackgroundProps {
|
||||
enabled: boolean;
|
||||
intensity: number; // 0 to 100
|
||||
}
|
||||
|
||||
export const CosmicHorrorBackground: React.FC<CosmicHorrorBackgroundProps> = ({
|
||||
enabled,
|
||||
intensity,
|
||||
}) => {
|
||||
if (!enabled) return null;
|
||||
|
||||
const opacityDecimal = (intensity / 100) * 0.45;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 pointer-events-none z-0 overflow-hidden transition-opacity duration-1000">
|
||||
{/* Radial Purple/Cyan Void Nebula Glow */}
|
||||
<div
|
||||
className="absolute inset-0 bg-[radial-gradient(circle_at_50%_30%,_var(--tw-gradient-stops))] from-purple-900/30 via-indigo-950/20 to-transparent mix-blend-screen animate-pulse"
|
||||
style={{
|
||||
opacity: opacityDecimal,
|
||||
animationDuration: `${12 - (intensity / 100) * 6}s`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Cosmic Void Stars & Dust particles overlay */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-30 bg-[radial-gradient(#a855f7_1px,transparent_1px)] [background-size:32px_32px]"
|
||||
style={{ opacity: opacityDecimal * 0.8 }}
|
||||
/>
|
||||
|
||||
{/* Bottom Eldritch Tendril Aura */}
|
||||
<div
|
||||
className="absolute -bottom-32 inset-x-0 h-96 bg-gradient-to-t from-purple-950/60 via-purple-900/10 to-transparent blur-2xl"
|
||||
style={{ opacity: opacityDecimal * 1.2 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
96
src/components/Footer.tsx
Normal file
96
src/components/Footer.tsx
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import React from "react";
|
||||
import { Shield, ExternalLink, Lock, Sparkles, Heart } from "lucide-react";
|
||||
import { CMSData } from "../types";
|
||||
|
||||
interface FooterProps {
|
||||
cms: CMSData;
|
||||
onOpenAdmin: () => void;
|
||||
isUnlocked: boolean;
|
||||
}
|
||||
|
||||
export const Footer: React.FC<FooterProps> = ({
|
||||
cms,
|
||||
onOpenAdmin,
|
||||
isUnlocked,
|
||||
}) => {
|
||||
return (
|
||||
<footer className="relative bg-zinc-950 border-t border-amber-900/40 text-zinc-400 py-12 px-4 sm:px-6 lg:px-8 z-10">
|
||||
<div className="max-w-6xl mx-auto grid grid-cols-1 md:grid-cols-3 gap-8 mb-8">
|
||||
{/* Mod Info */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<img
|
||||
src={cms.hero.logoUrl}
|
||||
alt="Logo"
|
||||
className="w-8 h-8 rounded-full border border-amber-500/60"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
<span className="font-serif font-bold text-amber-200 text-lg">
|
||||
{cms.hero.modTitle}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-400 leading-relaxed font-sans">
|
||||
Eine in der Entwicklung befindliche Gothic II Modifikation mit Fokus auf tiefgründiges Storytelling und kosmische Mystik.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Quick Links & Spine Modmanager */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-mono font-bold uppercase tracking-widest text-amber-500">
|
||||
Modding-Tools & Community
|
||||
</h4>
|
||||
<ul className="space-y-1.5 text-xs">
|
||||
<li>
|
||||
<a
|
||||
href="https://worldofgothic.de"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="hover:text-amber-300 transition-colors inline-flex items-center gap-1"
|
||||
>
|
||||
World of Gothic Community <ExternalLink className="w-3 h-3 text-amber-500/70" />
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://clockwork-origins.com/spine"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="hover:text-amber-300 transition-colors inline-flex items-center gap-1"
|
||||
>
|
||||
Spine Mod-Manager <ExternalLink className="w-3 h-3 text-amber-500/70" />
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Modder Online Management Link */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-xs font-mono font-bold uppercase tracking-widest text-amber-500">
|
||||
Online-Verwaltung
|
||||
</h4>
|
||||
<p className="text-xs text-zinc-400">
|
||||
Du musst kein einziges Web-Script anfassen. Klicke hier, um Texte, Screenshots und Fortschritt direkt zu editieren.
|
||||
</p>
|
||||
<button
|
||||
onClick={onOpenAdmin}
|
||||
className="inline-flex items-center gap-2 px-3.5 py-1.5 rounded bg-amber-950/80 border border-amber-800 text-amber-200 text-xs font-mono hover:bg-amber-900 transition-colors"
|
||||
>
|
||||
<Lock className="w-3.5 h-3.5 text-amber-400" />
|
||||
<span>{isUnlocked ? "CMS Dashboard öffnen" : "Modder Login (Passwort)"}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Copyright & Disclaimer */}
|
||||
<div className="max-w-6xl mx-auto pt-6 border-t border-zinc-900 text-center text-[11px] text-zinc-500 space-y-2 font-mono">
|
||||
<p>
|
||||
Gothic II & Die Nacht des Rhabars sind eingetragene Marken von THQ Nordic / Piranha Bytes. Dies ist ein inoffizielles, unkommerzielles Fanprojekt von Spielern für Spieler.
|
||||
</p>
|
||||
<p className="flex items-center justify-center gap-1 text-zinc-400">
|
||||
<span>Entwickelt mit Leidenschaft für die Gothic-Community</span>
|
||||
<Heart className="w-3 h-3 text-red-500 fill-red-500" />
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
182
src/components/GallerySection.tsx
Normal file
182
src/components/GallerySection.tsx
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
import React, { useState } from "react";
|
||||
import { Image as ImageIcon, X, ChevronLeft, ChevronRight, Maximize2, Plus, Filter } from "lucide-react";
|
||||
import { CMSData, ScreenshotItem } from "../types";
|
||||
|
||||
interface GallerySectionProps {
|
||||
cms: CMSData;
|
||||
onOpenAdmin: () => void;
|
||||
isUnlocked: boolean;
|
||||
}
|
||||
|
||||
export const GallerySection: React.FC<GallerySectionProps> = ({
|
||||
cms,
|
||||
onOpenAdmin,
|
||||
isUnlocked,
|
||||
}) => {
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>("Alle");
|
||||
const [activeLightboxIndex, setActiveLightboxIndex] = useState<number | null>(null);
|
||||
|
||||
// Get unique categories
|
||||
const categories = [
|
||||
"Alle",
|
||||
...Array.from(new Set(cms.screenshots.map((s) => s.category).filter(Boolean))),
|
||||
];
|
||||
|
||||
const filteredScreenshots =
|
||||
selectedCategory === "Alle"
|
||||
? cms.screenshots
|
||||
: cms.screenshots.filter((s) => s.category === selectedCategory);
|
||||
|
||||
const openLightbox = (index: number) => {
|
||||
setActiveLightboxIndex(index);
|
||||
};
|
||||
|
||||
const closeLightbox = () => {
|
||||
setActiveLightboxIndex(null);
|
||||
};
|
||||
|
||||
const nextScreenshot = () => {
|
||||
if (activeLightboxIndex === null) return;
|
||||
setActiveLightboxIndex((prev) =>
|
||||
prev! + 1 >= filteredScreenshots.length ? 0 : prev! + 1
|
||||
);
|
||||
};
|
||||
|
||||
const prevScreenshot = () => {
|
||||
if (activeLightboxIndex === null) return;
|
||||
setActiveLightboxIndex((prev) =>
|
||||
prev! - 1 < 0 ? filteredScreenshots.length - 1 : prev! - 1
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<section id="screenshots" className="py-20 px-4 sm:px-6 lg:px-8 max-w-7xl mx-auto relative">
|
||||
<div className="text-center mb-12">
|
||||
<span className="text-xs font-mono font-bold uppercase tracking-widest text-amber-500/80 block mb-2">
|
||||
Impressionen & Ingame-Showcase
|
||||
</span>
|
||||
<h2 className="text-3xl sm:text-4xl font-serif font-extrabold text-amber-100 tracking-wide mb-4">
|
||||
Screenshots aus Oakhaven
|
||||
</h2>
|
||||
<div className="w-24 h-1 bg-gradient-to-r from-transparent via-amber-600 to-transparent mx-auto rounded-full" />
|
||||
</div>
|
||||
|
||||
{/* Category Filter Chips */}
|
||||
<div className="flex flex-wrap items-center justify-center gap-2 mb-10">
|
||||
<Filter className="w-4 h-4 text-amber-500/80 mr-1 hidden sm:block" />
|
||||
{categories.map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => setSelectedCategory(cat)}
|
||||
className={`px-4 py-1.5 rounded-full text-xs font-mono transition-all border ${
|
||||
selectedCategory === cat
|
||||
? "bg-amber-600 text-zinc-950 border-amber-400 font-bold shadow-[0_0_12px_rgba(245,158,11,0.3)]"
|
||||
: "bg-zinc-900 border-zinc-800 text-zinc-400 hover:text-amber-200 hover:border-zinc-700"
|
||||
}`}
|
||||
>
|
||||
{cat}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{isUnlocked && (
|
||||
<button
|
||||
onClick={onOpenAdmin}
|
||||
className="flex items-center gap-1 px-3 py-1.5 rounded-full bg-amber-950 border border-dashed border-amber-600 text-amber-300 text-xs font-mono hover:bg-amber-900 transition-colors"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" /> Screenshot hochladen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Screenshots Grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredScreenshots.map((item, index) => (
|
||||
<div
|
||||
key={item.id}
|
||||
onClick={() => openLightbox(index)}
|
||||
className="group relative rounded-xl overflow-hidden bg-zinc-900 border border-amber-900/40 shadow-xl cursor-pointer transition-all duration-300 hover:-translate-y-1 hover:border-amber-500/70 hover:shadow-[0_0_25px_rgba(180,83,9,0.25)]"
|
||||
>
|
||||
<div className="aspect-[16/9] w-full overflow-hidden bg-zinc-950 relative">
|
||||
<img
|
||||
src={item.imageUrl}
|
||||
alt={item.title}
|
||||
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110 filter brightness-90 group-hover:brightness-100"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-zinc-950 via-zinc-950/20 to-transparent opacity-80 group-hover:opacity-60 transition-opacity" />
|
||||
|
||||
<div className="absolute top-3 right-3 bg-zinc-950/80 backdrop-blur border border-amber-600/40 text-amber-300 px-2.5 py-0.5 rounded text-[11px] font-mono">
|
||||
{item.category}
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity bg-purple-950/30">
|
||||
<div className="p-3 rounded-full bg-amber-500 text-zinc-950 shadow-lg transform scale-75 group-hover:scale-100 transition-transform">
|
||||
<Maximize2 className="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-zinc-900/90 border-t border-zinc-800">
|
||||
<h3 className="font-serif font-bold text-amber-100 text-base mb-1 group-hover:text-amber-300 transition-colors">
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className="text-zinc-400 text-xs line-clamp-2">{item.caption}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredScreenshots.length === 0 && (
|
||||
<div className="text-center py-12 text-zinc-500 font-mono text-sm bg-zinc-900/40 rounded-xl border border-zinc-800">
|
||||
Keine Screenshots in der Kategorie "{selectedCategory}" gefunden.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lightbox Modal */}
|
||||
{activeLightboxIndex !== null && filteredScreenshots[activeLightboxIndex] && (
|
||||
<div className="fixed inset-0 z-50 bg-zinc-950/95 backdrop-blur-md flex items-center justify-center p-4">
|
||||
<button
|
||||
onClick={closeLightbox}
|
||||
className="absolute top-6 right-6 p-2 rounded-full bg-zinc-900 border border-amber-600/50 text-amber-200 hover:bg-amber-600 hover:text-zinc-950 transition-colors"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={prevScreenshot}
|
||||
className="absolute left-4 sm:left-8 p-3 rounded-full bg-zinc-900/80 border border-amber-600/50 text-amber-200 hover:bg-amber-600 hover:text-zinc-950 transition-colors"
|
||||
>
|
||||
<ChevronLeft className="w-6 h-6" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={nextScreenshot}
|
||||
className="absolute right-4 sm:right-8 p-3 rounded-full bg-zinc-900/80 border border-amber-600/50 text-amber-200 hover:bg-amber-600 hover:text-zinc-950 transition-colors"
|
||||
>
|
||||
<ChevronRight className="w-6 h-6" />
|
||||
</button>
|
||||
|
||||
<div className="max-w-5xl w-full max-h-[90vh] flex flex-col items-center">
|
||||
<img
|
||||
src={filteredScreenshots[activeLightboxIndex].imageUrl}
|
||||
alt={filteredScreenshots[activeLightboxIndex].title}
|
||||
className="max-h-[70vh] w-auto object-contain rounded-lg border-2 border-amber-600/60 shadow-2xl"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
<div className="mt-4 text-center max-w-2xl px-4">
|
||||
<span className="text-xs font-mono text-amber-400 uppercase tracking-widest block mb-1">
|
||||
{filteredScreenshots[activeLightboxIndex].category}
|
||||
</span>
|
||||
<h3 className="text-xl font-serif font-bold text-amber-100">
|
||||
{filteredScreenshots[activeLightboxIndex].title}
|
||||
</h3>
|
||||
<p className="text-zinc-300 text-sm mt-1">
|
||||
{filteredScreenshots[activeLightboxIndex].caption}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
106
src/components/HeroSection.tsx
Normal file
106
src/components/HeroSection.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import React from "react";
|
||||
import { Shield, ChevronDown, Sparkles, Gamepad2, Calendar, Edit3 } from "lucide-react";
|
||||
import { CMSData } from "../types";
|
||||
|
||||
interface HeroSectionProps {
|
||||
cms: CMSData;
|
||||
onOpenAdmin: () => void;
|
||||
isUnlocked: boolean;
|
||||
}
|
||||
|
||||
export const HeroSection: React.FC<HeroSectionProps> = ({
|
||||
cms,
|
||||
onOpenAdmin,
|
||||
isUnlocked,
|
||||
}) => {
|
||||
return (
|
||||
<section id="hero" className="relative min-h-[85vh] flex items-center justify-center pt-8 pb-16 px-4 sm:px-6 overflow-hidden">
|
||||
{/* Background Hero Image with Dark Medieval Gradient Overlay */}
|
||||
<div className="absolute inset-0 z-0">
|
||||
<img
|
||||
src={cms.hero.heroBgUrl}
|
||||
alt="Gothic Mod Hero Landscape"
|
||||
className="w-full h-full object-cover object-center filter brightness-[0.35] contrast-125 scale-105"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
{/* Dark Vignette and Cosmic Void Gradient */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-zinc-950 via-zinc-950/70 to-zinc-950/80" />
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-amber-900/15 via-purple-950/20 to-zinc-950/90" />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 max-w-4xl mx-auto text-center flex flex-col items-center">
|
||||
{/* PROMINENT LOGO (as explicitly requested) */}
|
||||
<div className="relative mb-6 group cursor-pointer" onClick={() => isUnlocked && onOpenAdmin()}>
|
||||
{/* Outer Glowing Rune Circle */}
|
||||
<div className="absolute -inset-4 rounded-full bg-gradient-to-r from-amber-600/30 via-purple-600/30 to-amber-600/30 blur-xl opacity-75 group-hover:opacity-100 transition-opacity animate-pulse" />
|
||||
|
||||
<div className="relative w-40 h-40 sm:w-48 sm:h-48 md:w-56 md:h-56 rounded-full p-2 bg-gradient-to-b from-amber-600 via-amber-900/80 to-purple-900/90 shadow-[0_0_50px_rgba(217,119,6,0.3)] border-2 border-amber-500/60 transition-transform duration-500 group-hover:scale-105">
|
||||
<div className="w-full h-full rounded-full overflow-hidden border border-amber-400/40 bg-zinc-950 relative">
|
||||
<img
|
||||
src={cms.hero.logoUrl}
|
||||
alt="Chroniken der Leere Logo"
|
||||
className="w-full h-full object-cover filter contrast-110"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-purple-950/40 via-transparent to-amber-900/20" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isUnlocked && (
|
||||
<div className="absolute -bottom-2 right-2 bg-amber-500 text-zinc-950 px-2.5 py-0.5 rounded-full text-xs font-bold font-mono shadow-lg flex items-center gap-1">
|
||||
<Edit3 className="w-3 h-3" /> Logo Ändern
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Engine Badge & Status */}
|
||||
<div className="flex flex-wrap items-center justify-center gap-2 sm:gap-3 mb-4">
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-mono font-medium bg-amber-950/80 border border-amber-600/50 text-amber-300 shadow-sm">
|
||||
<Gamepad2 className="w-3.5 h-3.5 text-amber-400" />
|
||||
{cms.hero.engine}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-mono font-medium bg-purple-950/80 border border-purple-600/50 text-purple-200 shadow-sm">
|
||||
<Calendar className="w-3.5 h-3.5 text-purple-400" />
|
||||
Release: {cms.hero.expectedRelease}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Mod Title & Subtitle */}
|
||||
<h1 className="text-4xl sm:text-5xl md:text-6xl font-serif font-black tracking-wide text-transparent bg-clip-text bg-gradient-to-b from-amber-100 via-amber-200 to-amber-500/90 drop-shadow-[0_4px_12px_rgba(0,0,0,0.9)] mb-3">
|
||||
{cms.hero.modTitle}
|
||||
</h1>
|
||||
|
||||
<p className="text-lg sm:text-xl font-serif text-amber-400/90 tracking-widest uppercase mb-6 font-semibold border-b border-amber-800/40 pb-3 max-w-2xl">
|
||||
{cms.hero.subtitle}
|
||||
</p>
|
||||
|
||||
{/* Tagline / Introduction */}
|
||||
<p className="text-base sm:text-lg text-zinc-300 max-w-2xl leading-relaxed mb-8 font-sans drop-shadow-md italic">
|
||||
"{cms.hero.tagline}"
|
||||
</p>
|
||||
|
||||
{/* Call-to-action buttons */}
|
||||
<div className="flex flex-wrap items-center justify-center gap-4">
|
||||
<a
|
||||
href="#story"
|
||||
className="px-6 py-3 rounded-md bg-gradient-to-r from-amber-700 via-amber-800 to-amber-900 border border-amber-500/60 text-amber-100 font-serif font-semibold tracking-wider hover:from-amber-600 hover:to-amber-800 transition-all shadow-[0_0_20px_rgba(180,83,9,0.3)] flex items-center gap-2 group"
|
||||
>
|
||||
<span>Story & Details entdecken</span>
|
||||
<ChevronDown className="w-4 h-4 text-amber-300 group-hover:translate-y-0.5 transition-transform" />
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="#progress"
|
||||
className="px-6 py-3 rounded-md bg-zinc-900/90 border border-purple-800/60 text-purple-200 font-serif font-semibold tracking-wider hover:bg-purple-950/80 hover:border-purple-600 transition-all shadow-md flex items-center gap-2"
|
||||
>
|
||||
<Sparkles className="w-4 h-4 text-purple-400" />
|
||||
<span>Entwicklungsfortschritt ({cms.progress.overall}%)</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Decorative Bottom Arch */}
|
||||
<div className="absolute bottom-0 inset-x-0 h-12 bg-gradient-to-t from-zinc-950 to-transparent pointer-events-none" />
|
||||
</section>
|
||||
);
|
||||
};
|
||||
231
src/components/Navbar.tsx
Normal file
231
src/components/Navbar.tsx
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
import React, { useState } from "react";
|
||||
import { Lock, Eye, Sparkles, Sliders, Menu, X, Shield, Scroll, Image as ImageIcon, Newspaper, BarChart3 } from "lucide-react";
|
||||
import { CMSData } from "../types";
|
||||
|
||||
interface NavbarProps {
|
||||
cms: CMSData;
|
||||
onUpdateCMS: (data: CMSData) => void;
|
||||
onOpenAdmin: () => void;
|
||||
isUnlocked: boolean;
|
||||
}
|
||||
|
||||
export const Navbar: React.FC<NavbarProps> = ({
|
||||
cms,
|
||||
onUpdateCMS,
|
||||
onOpenAdmin,
|
||||
isUnlocked,
|
||||
}) => {
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const [showCosmicControls, setShowCosmicControls] = useState(false);
|
||||
|
||||
const toggleCosmic = () => {
|
||||
onUpdateCMS({
|
||||
...cms,
|
||||
cosmicAtmosphere: {
|
||||
...cms.cosmicAtmosphere,
|
||||
enabled: !cms.cosmicAtmosphere.enabled,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleIntensityChange = (val: number) => {
|
||||
onUpdateCMS({
|
||||
...cms,
|
||||
cosmicAtmosphere: {
|
||||
...cms.cosmicAtmosphere,
|
||||
intensity: val,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="sticky top-0 z-40 bg-zinc-950/90 backdrop-blur-md border-b border-amber-900/30 shadow-2xl transition-all">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-20 flex items-center justify-between">
|
||||
{/* Brand Logo & Title */}
|
||||
<a href="#hero" className="flex items-center gap-3 group">
|
||||
<div className="relative w-11 h-11 rounded-full overflow-hidden border border-amber-600/50 shadow-md group-hover:border-purple-500 transition-colors">
|
||||
<img
|
||||
src={cms.hero.logoUrl}
|
||||
alt="Mod Logo"
|
||||
className="w-full h-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-purple-900/20 group-hover:bg-purple-600/30 transition-colors" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-serif text-lg font-bold text-amber-200 tracking-wider group-hover:text-amber-100 block transition-colors leading-tight">
|
||||
{cms.hero.modTitle}
|
||||
</span>
|
||||
<span className="text-xs text-amber-500/70 uppercase tracking-widest font-mono">
|
||||
Gothic II Mod
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
{/* Desktop Navigation Links */}
|
||||
<div className="hidden md:flex items-center space-x-6 text-sm font-medium text-amber-200/80">
|
||||
<a
|
||||
href="#story"
|
||||
className="flex items-center gap-1.5 hover:text-amber-300 transition-colors py-1 border-b border-transparent hover:border-amber-500/50"
|
||||
>
|
||||
<Scroll className="w-4 h-4 text-amber-500" />
|
||||
<span>Story & Übersicht</span>
|
||||
</a>
|
||||
<a
|
||||
href="#screenshots"
|
||||
className="flex items-center gap-1.5 hover:text-amber-300 transition-colors py-1 border-b border-transparent hover:border-amber-500/50"
|
||||
>
|
||||
<ImageIcon className="w-4 h-4 text-amber-500" />
|
||||
<span>Galerie</span>
|
||||
</a>
|
||||
<a
|
||||
href="#news"
|
||||
className="flex items-center gap-1.5 hover:text-amber-300 transition-colors py-1 border-b border-transparent hover:border-amber-500/50"
|
||||
>
|
||||
<Newspaper className="w-4 h-4 text-amber-500" />
|
||||
<span>News</span>
|
||||
</a>
|
||||
<a
|
||||
href="#progress"
|
||||
className="flex items-center gap-1.5 hover:text-amber-300 transition-colors py-1 border-b border-transparent hover:border-amber-500/50"
|
||||
>
|
||||
<BarChart3 className="w-4 h-4 text-amber-500" />
|
||||
<span>Fortschritt</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Action Controls & Admin Link */}
|
||||
<div className="hidden md:flex items-center gap-3">
|
||||
{/* Cosmic Horror Atmosphere Quick Controller */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowCosmicControls(!showCosmicControls)}
|
||||
title="Kosmischer Schleier Effekte anpassen"
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-md border text-xs font-mono transition-all ${
|
||||
cms.cosmicAtmosphere.enabled
|
||||
? "bg-purple-950/60 border-purple-600/60 text-purple-200 shadow-[0_0_15px_rgba(147,51,234,0.3)]"
|
||||
: "bg-zinc-900 border-zinc-700 text-zinc-400 hover:text-zinc-200"
|
||||
}`}
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5 text-purple-400" />
|
||||
<span>Kosmischer Schleier</span>
|
||||
<Sliders className="w-3 h-3 text-purple-400/70 ml-1" />
|
||||
</button>
|
||||
|
||||
{showCosmicControls && (
|
||||
<div className="absolute right-0 mt-2 w-64 bg-zinc-900/95 border border-purple-800/60 rounded-lg shadow-2xl p-4 z-50 text-xs backdrop-blur-md">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="font-semibold text-purple-200 flex items-center gap-1.5">
|
||||
<Sparkles className="w-4 h-4 text-purple-400" />
|
||||
Kosmische Aura
|
||||
</span>
|
||||
<button
|
||||
onClick={toggleCosmic}
|
||||
className={`px-2 py-0.5 rounded text-[10px] font-bold tracking-wider uppercase transition-colors ${
|
||||
cms.cosmicAtmosphere.enabled
|
||||
? "bg-purple-600 text-purple-950"
|
||||
: "bg-zinc-700 text-zinc-300"
|
||||
}`}
|
||||
>
|
||||
{cms.cosmicAtmosphere.enabled ? "Aktiv" : "Aus"}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-zinc-400 text-[11px] mb-3 leading-relaxed">
|
||||
Steuert den Wandel von bodenständiger Low-Fantasy zu kosmischem Horror auf der Website.
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-[10px] font-mono text-purple-300">
|
||||
<span>Intensität</span>
|
||||
<span>{cms.cosmicAtmosphere.intensity}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="10"
|
||||
max="100"
|
||||
value={cms.cosmicAtmosphere.intensity}
|
||||
onChange={(e) => handleIntensityChange(Number(e.target.value))}
|
||||
disabled={!cms.cosmicAtmosphere.enabled}
|
||||
className="w-full accent-purple-500 bg-zinc-800 rounded h-1.5 cursor-pointer disabled:opacity-40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Admin CMS Access Button */}
|
||||
<button
|
||||
onClick={onOpenAdmin}
|
||||
className={`flex items-center gap-2 px-3.5 py-1.5 rounded-md border font-serif text-xs font-semibold tracking-wider transition-all shadow-md ${
|
||||
isUnlocked
|
||||
? "bg-amber-600/20 border-amber-500 text-amber-200 hover:bg-amber-600/30 shadow-[0_0_12px_rgba(245,158,11,0.2)]"
|
||||
: "bg-amber-950/40 border-amber-800/60 text-amber-300/90 hover:bg-amber-900/60 hover:text-amber-100 hover:border-amber-600"
|
||||
}`}
|
||||
>
|
||||
<Lock className={`w-3.5 h-3.5 ${isUnlocked ? "text-amber-400" : "text-amber-500/80"}`} />
|
||||
<span>{isUnlocked ? "CMS Verwaltung" : "Modder Login"}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu Toggle */}
|
||||
<div className="md:hidden flex items-center gap-2">
|
||||
<button
|
||||
onClick={onOpenAdmin}
|
||||
className="p-2 text-amber-400 hover:text-amber-200 rounded-md border border-amber-800/40 bg-amber-950/30"
|
||||
title="CMS Verwaltung"
|
||||
>
|
||||
<Lock className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||
className="p-2 text-amber-200 hover:text-amber-100 focus:outline-none"
|
||||
>
|
||||
{isMobileMenuOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Dropdown Menu */}
|
||||
{isMobileMenuOpen && (
|
||||
<div className="md:hidden bg-zinc-950 border-b border-amber-900/50 px-4 pt-3 pb-6 space-y-3 font-serif">
|
||||
<a
|
||||
href="#story"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className="block text-amber-200 py-2 border-b border-zinc-800"
|
||||
>
|
||||
Story & Übersicht
|
||||
</a>
|
||||
<a
|
||||
href="#screenshots"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className="block text-amber-200 py-2 border-b border-zinc-800"
|
||||
>
|
||||
Galerie
|
||||
</a>
|
||||
<a
|
||||
href="#news"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className="block text-amber-200 py-2 border-b border-zinc-800"
|
||||
>
|
||||
News
|
||||
</a>
|
||||
<a
|
||||
href="#progress"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className="block text-amber-200 py-2 border-b border-zinc-800"
|
||||
>
|
||||
Fortschritt
|
||||
</a>
|
||||
<div className="pt-2 flex items-center justify-between">
|
||||
<span className="text-xs text-purple-300 font-mono">Kosmischer Schleier</span>
|
||||
<button
|
||||
onClick={toggleCosmic}
|
||||
className="px-3 py-1 rounded bg-purple-900/60 border border-purple-600 text-xs text-purple-200 font-mono"
|
||||
>
|
||||
{cms.cosmicAtmosphere.enabled ? "Aktiv" : "Inaktiv"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
125
src/components/NewsSection.tsx
Normal file
125
src/components/NewsSection.tsx
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import React, { useState } from "react";
|
||||
import { Newspaper, Calendar, User, Tag, ChevronRight, X, Plus, Edit3 } from "lucide-react";
|
||||
import { CMSData, NewsPost } from "../types";
|
||||
|
||||
interface NewsSectionProps {
|
||||
cms: CMSData;
|
||||
onOpenAdmin: () => void;
|
||||
isUnlocked: boolean;
|
||||
}
|
||||
|
||||
export const NewsSection: React.FC<NewsSectionProps> = ({
|
||||
cms,
|
||||
onOpenAdmin,
|
||||
isUnlocked,
|
||||
}) => {
|
||||
const [activeNewsModal, setActiveNewsModal] = useState<NewsPost | null>(null);
|
||||
|
||||
return (
|
||||
<section id="news" className="py-20 px-4 sm:px-6 lg:px-8 max-w-6xl mx-auto relative">
|
||||
<div className="text-center mb-12">
|
||||
<span className="text-xs font-mono font-bold uppercase tracking-widest text-amber-500/80 block mb-2">
|
||||
Neuigkeiten & Entwickler-Tagebuch
|
||||
</span>
|
||||
<h2 className="text-3xl sm:text-4xl font-serif font-extrabold text-amber-100 tracking-wide mb-4">
|
||||
Aktuelle News & Blog
|
||||
</h2>
|
||||
<div className="w-24 h-1 bg-gradient-to-r from-transparent via-amber-600 to-transparent mx-auto rounded-full" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{cms.news.map((post) => (
|
||||
<div
|
||||
key={post.id}
|
||||
className="flex flex-col bg-zinc-900/90 border border-amber-900/40 rounded-xl p-6 shadow-xl relative group hover:border-amber-500/60 transition-all duration-300"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 mb-3">
|
||||
<span className="inline-flex items-center gap-1 text-[11px] font-mono px-2.5 py-0.5 rounded-full bg-amber-950 border border-amber-700/50 text-amber-300">
|
||||
<Tag className="w-3 h-3 text-amber-400" />
|
||||
{post.tag}
|
||||
</span>
|
||||
<span className="text-xs text-zinc-400 font-mono flex items-center gap-1">
|
||||
<Calendar className="w-3 h-3 text-zinc-500" />
|
||||
{post.date}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 className="font-serif font-bold text-xl text-amber-100 mb-2 group-hover:text-amber-300 transition-colors leading-snug">
|
||||
{post.title}
|
||||
</h3>
|
||||
|
||||
<p className="text-zinc-300 text-sm leading-relaxed mb-6 flex-1 line-clamp-3">
|
||||
{post.summary}
|
||||
</p>
|
||||
|
||||
<div className="pt-4 border-t border-zinc-800/80 flex items-center justify-between mt-auto">
|
||||
<span className="text-xs text-zinc-400 font-mono flex items-center gap-1">
|
||||
<User className="w-3 h-3 text-amber-500" />
|
||||
{post.author}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveNewsModal(post)}
|
||||
className="text-xs font-serif font-semibold text-amber-400 hover:text-amber-200 flex items-center gap-1 group/btn"
|
||||
>
|
||||
<span>Weiterlesen</span>
|
||||
<ChevronRight className="w-3.5 h-3.5 group-hover/btn:translate-x-0.5 transition-transform" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isUnlocked && (
|
||||
<div className="mt-8 text-center">
|
||||
<button
|
||||
onClick={onOpenAdmin}
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-amber-950/80 border border-dashed border-amber-600 text-amber-200 font-mono text-xs hover:bg-amber-900 transition-colors shadow-lg"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Neuen News-Beitrag verfassen</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* News Full Reader Modal */}
|
||||
{activeNewsModal && (
|
||||
<div className="fixed inset-0 z-50 bg-zinc-950/90 backdrop-blur-md flex items-center justify-center p-4">
|
||||
<div className="bg-zinc-900 border border-amber-600/60 rounded-xl max-w-2xl w-full max-h-[85vh] flex flex-col shadow-2xl p-6 sm:p-8 relative">
|
||||
<button
|
||||
onClick={() => setActiveNewsModal(null)}
|
||||
className="absolute top-4 right-4 p-2 text-zinc-400 hover:text-amber-200 rounded-full bg-zinc-950 border border-zinc-800"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-3 text-xs font-mono text-amber-400 mb-2">
|
||||
<span className="bg-amber-950 px-2.5 py-0.5 rounded border border-amber-700/50">
|
||||
{activeNewsModal.tag}
|
||||
</span>
|
||||
<span>{activeNewsModal.date}</span>
|
||||
<span>• Von {activeNewsModal.author}</span>
|
||||
</div>
|
||||
|
||||
<h3 className="text-2xl font-serif font-bold text-amber-100 mb-4 border-b border-amber-900/40 pb-3">
|
||||
{activeNewsModal.title}
|
||||
</h3>
|
||||
|
||||
<div className="overflow-y-auto pr-2 space-y-4 text-zinc-300 leading-relaxed font-sans text-base whitespace-pre-line flex-1">
|
||||
{activeNewsModal.content}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-zinc-800 flex justify-end">
|
||||
<button
|
||||
onClick={() => setActiveNewsModal(null)}
|
||||
className="px-5 py-2 rounded bg-amber-700 hover:bg-amber-600 text-amber-100 font-serif font-semibold text-sm transition-colors"
|
||||
>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
195
src/components/ProgressSection.tsx
Normal file
195
src/components/ProgressSection.tsx
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
import React from "react";
|
||||
import { Code, Compass, Mic, Box, Swords, CheckCircle2, Clock, Calendar, Edit3, Sparkles } from "lucide-react";
|
||||
import { CMSData, DevCategoryProgress, Milestone } from "../types";
|
||||
|
||||
interface ProgressSectionProps {
|
||||
cms: CMSData;
|
||||
onOpenAdmin: () => void;
|
||||
isUnlocked: boolean;
|
||||
}
|
||||
|
||||
export const ProgressSection: React.FC<ProgressSectionProps> = ({
|
||||
cms,
|
||||
onOpenAdmin,
|
||||
isUnlocked,
|
||||
}) => {
|
||||
const getCategoryIcon = (iconName: string) => {
|
||||
switch (iconName) {
|
||||
case "Compass":
|
||||
return <Compass className="w-5 h-5 text-amber-400" />;
|
||||
case "Mic":
|
||||
return <Mic className="w-5 h-5 text-purple-400" />;
|
||||
case "Box":
|
||||
return <Box className="w-5 h-5 text-amber-500" />;
|
||||
case "Swords":
|
||||
return <Swords className="w-5 h-5 text-red-400" />;
|
||||
default:
|
||||
return <Code className="w-5 h-5 text-cyan-400" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: Milestone["status"]) => {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-mono bg-emerald-950/80 border border-emerald-600 text-emerald-300">
|
||||
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||
Abgeschlossen
|
||||
</span>
|
||||
);
|
||||
case "in-progress":
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-mono bg-amber-950/80 border border-amber-600 text-amber-300">
|
||||
<Clock className="w-3.5 h-3.5 text-amber-400 animate-spin" style={{ animationDuration: "3s" }} />
|
||||
In Arbeit
|
||||
</span>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-mono bg-zinc-800 border border-zinc-700 text-zinc-400">
|
||||
<Calendar className="w-3.5 h-3.5" />
|
||||
Geplant
|
||||
</span>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section id="progress" className="py-20 px-4 sm:px-6 lg:px-8 max-w-6xl mx-auto relative">
|
||||
<div className="text-center mb-12">
|
||||
<span className="text-xs font-mono font-bold uppercase tracking-widest text-amber-500/80 block mb-2">
|
||||
Transparente Entwicklung
|
||||
</span>
|
||||
<h2 className="text-3xl sm:text-4xl font-serif font-extrabold text-amber-100 tracking-wide mb-4">
|
||||
Entwicklungsfortschritt
|
||||
</h2>
|
||||
<div className="w-24 h-1 bg-gradient-to-r from-transparent via-amber-600 to-transparent mx-auto rounded-full" />
|
||||
</div>
|
||||
|
||||
{/* Overall Progress Banner */}
|
||||
<div className="bg-gradient-to-r from-amber-950/90 via-zinc-900 to-purple-950/90 border border-amber-700/50 rounded-xl p-6 sm:p-8 mb-12 shadow-2xl relative overflow-hidden flex flex-col md:flex-row items-center justify-between gap-6">
|
||||
<div className="space-y-2 max-w-xl text-center md:text-left">
|
||||
<div className="flex items-center justify-center md:justify-start gap-2 text-amber-400 text-xs font-mono uppercase tracking-widest">
|
||||
<Sparkles className="w-4 h-4 text-purple-400" />
|
||||
<span>Gothic II Mod Status</span>
|
||||
</div>
|
||||
<h3 className="text-2xl font-serif font-bold text-amber-100">
|
||||
Gesamtfortschritt der Modifikation
|
||||
</h3>
|
||||
<p className="text-zinc-300 text-sm leading-relaxed">
|
||||
Das Scripting für die Haupt-Quests steht, die Spielwelt wird im Spacer weiter vertieft.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 shrink-0 bg-zinc-950/80 px-6 py-4 rounded-xl border border-amber-600/40 shadow-inner">
|
||||
<div className="text-4xl sm:text-5xl font-serif font-black text-amber-400 tracking-tight">
|
||||
{cms.progress.overall}%
|
||||
</div>
|
||||
<div className="text-xs text-amber-200/80 font-mono border-l border-amber-800/60 pl-4 py-1 leading-tight">
|
||||
GESAMT<br />COMPLETION
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isUnlocked && (
|
||||
<button
|
||||
onClick={onOpenAdmin}
|
||||
className="absolute top-3 right-3 text-xs font-mono text-amber-400 hover:text-amber-200 bg-amber-950/80 px-2.5 py-1 rounded border border-amber-800 flex items-center gap-1"
|
||||
>
|
||||
<Edit3 className="w-3 h-3" /> % Editieren
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Category Progress Bars */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-16">
|
||||
{cms.progress.categories.map((cat) => (
|
||||
<div
|
||||
key={cat.id}
|
||||
className="bg-zinc-900/80 border border-zinc-800 rounded-xl p-5 shadow-lg space-y-3"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="p-2 rounded-lg bg-zinc-950 border border-zinc-800">
|
||||
{getCategoryIcon(cat.iconName)}
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-serif font-semibold text-amber-100 text-base">
|
||||
{cat.name}
|
||||
</h4>
|
||||
<p className="text-xs text-zinc-400">{cat.details}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="font-mono font-bold text-amber-400 text-base">
|
||||
{cat.percent}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Glowing Gothic Progress Bar */}
|
||||
<div className="w-full bg-zinc-950 rounded-full h-2.5 p-0.5 border border-zinc-800 overflow-hidden">
|
||||
<div
|
||||
className="bg-gradient-to-r from-amber-700 via-amber-500 to-amber-300 h-full rounded-full transition-all duration-1000 shadow-[0_0_10px_rgba(245,158,11,0.5)]"
|
||||
style={{ width: `${cat.percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Milestones Timeline */}
|
||||
<div className="bg-zinc-900/90 border border-amber-900/40 rounded-xl p-6 sm:p-8 shadow-2xl">
|
||||
<h3 className="text-2xl font-serif font-bold text-amber-200 mb-6 border-b border-amber-900/30 pb-3 flex items-center justify-between">
|
||||
<span>Meilenstein-Roadmap</span>
|
||||
{isUnlocked && (
|
||||
<button
|
||||
onClick={onOpenAdmin}
|
||||
className="text-xs font-mono text-amber-400 hover:text-amber-200 bg-amber-950 px-3 py-1 rounded border border-amber-800 flex items-center gap-1"
|
||||
>
|
||||
<Edit3 className="w-3.5 h-3.5" /> Meilensteine verwalten
|
||||
</button>
|
||||
)}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-6 relative before:absolute before:inset-0 before:left-3.5 sm:before:left-4 before:w-0.5 before:bg-amber-900/40">
|
||||
{cms.progress.milestones.map((ms) => (
|
||||
<div key={ms.id} className="relative pl-10 sm:pl-12">
|
||||
{/* Node Marker */}
|
||||
<div
|
||||
className={`absolute left-1 sm:left-1.5 top-1 w-6 h-6 rounded-full border-2 flex items-center justify-center bg-zinc-950 ${
|
||||
ms.status === "completed"
|
||||
? "border-emerald-500 text-emerald-400"
|
||||
: ms.status === "in-progress"
|
||||
? "border-amber-500 text-amber-400"
|
||||
: "border-zinc-700 text-zinc-600"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
ms.status === "completed"
|
||||
? "bg-emerald-400"
|
||||
: ms.status === "in-progress"
|
||||
? "bg-amber-400"
|
||||
: "bg-zinc-700"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-zinc-950/80 border border-zinc-800 rounded-lg p-4 sm:p-5 flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h4 className="font-serif font-bold text-amber-100 text-base">
|
||||
{ms.title}
|
||||
</h4>
|
||||
<span className="text-xs font-mono text-amber-500/80">({ms.date})</span>
|
||||
</div>
|
||||
<p className="text-zinc-400 text-xs">{ms.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0">{getStatusBadge(ms.status)}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
127
src/components/StoryTabsSection.tsx
Normal file
127
src/components/StoryTabsSection.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import React, { useState } from "react";
|
||||
import { BookOpen, Sparkles, Shield, HelpCircle, CheckCircle2, FileText, Plus, Edit3 } from "lucide-react";
|
||||
import { CMSData, StoryTab } from "../types";
|
||||
|
||||
interface StoryTabsSectionProps {
|
||||
cms: CMSData;
|
||||
onOpenAdmin: () => void;
|
||||
isUnlocked: boolean;
|
||||
}
|
||||
|
||||
export const StoryTabsSection: React.FC<StoryTabsSectionProps> = ({
|
||||
cms,
|
||||
onOpenAdmin,
|
||||
isUnlocked,
|
||||
}) => {
|
||||
const [activeTabId, setActiveTabId] = useState<string>(
|
||||
cms.tabs.length > 0 ? cms.tabs[0].id : "story"
|
||||
);
|
||||
|
||||
const activeTab = cms.tabs.find((t) => t.id === activeTabId) || cms.tabs[0];
|
||||
|
||||
const getIcon = (iconName: string) => {
|
||||
switch (iconName) {
|
||||
case "Sparkles":
|
||||
return <Sparkles className="w-5 h-5 text-purple-400" />;
|
||||
case "Shield":
|
||||
return <Shield className="w-5 h-5 text-amber-500" />;
|
||||
case "HelpCircle":
|
||||
return <HelpCircle className="w-5 h-5 text-cyan-400" />;
|
||||
default:
|
||||
return <BookOpen className="w-5 h-5 text-amber-400" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section id="story" className="py-20 px-4 sm:px-6 lg:px-8 max-w-6xl mx-auto relative">
|
||||
{/* Medieval Header */}
|
||||
<div className="text-center mb-12">
|
||||
<span className="text-xs font-mono font-bold uppercase tracking-widest text-amber-500/80 block mb-2">
|
||||
Projekt-Informationen
|
||||
</span>
|
||||
<h2 className="text-3xl sm:text-4xl font-serif font-extrabold text-amber-100 tracking-wide mb-4">
|
||||
Die Welt, Features & Fraktionen
|
||||
</h2>
|
||||
<div className="w-24 h-1 bg-gradient-to-r from-transparent via-amber-600 to-transparent mx-auto rounded-full" />
|
||||
</div>
|
||||
|
||||
{/* Tabs Selector Bar */}
|
||||
<div className="flex flex-wrap items-center justify-center gap-2 sm:gap-3 mb-8">
|
||||
{cms.tabs.map((tab) => {
|
||||
const isActive = tab.id === activeTabId;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTabId(tab.id)}
|
||||
className={`flex items-center gap-2.5 px-5 py-3 rounded-lg font-serif text-sm font-semibold tracking-wider transition-all border ${
|
||||
isActive
|
||||
? "bg-amber-950/90 border-amber-500 text-amber-100 shadow-[0_0_20px_rgba(245,158,11,0.25)] scale-105"
|
||||
: "bg-zinc-900/80 border-zinc-800 text-zinc-400 hover:text-amber-200 hover:border-zinc-700 hover:bg-zinc-800/90"
|
||||
}`}
|
||||
>
|
||||
{getIcon(tab.iconName)}
|
||||
<span>{tab.title}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{isUnlocked && (
|
||||
<button
|
||||
onClick={onOpenAdmin}
|
||||
className="flex items-center gap-1.5 px-4 py-3 rounded-lg bg-amber-900/40 border border-dashed border-amber-600/60 text-amber-300 text-xs font-mono hover:bg-amber-900/60 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Tab Hinzufügen / Editieren</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tab Content Display Box */}
|
||||
{activeTab && (
|
||||
<div className="relative bg-zinc-900/90 border border-amber-900/40 rounded-xl p-6 sm:p-8 md:p-10 shadow-2xl backdrop-blur-md">
|
||||
{/* Subtle Corner Decoration */}
|
||||
<div className="absolute top-0 left-0 w-8 h-8 border-t-2 border-l-2 border-amber-500/60 rounded-tl-xl pointer-events-none" />
|
||||
<div className="absolute bottom-0 right-0 w-8 h-8 border-b-2 border-r-2 border-amber-500/60 rounded-br-xl pointer-events-none" />
|
||||
|
||||
<div className="flex items-center justify-between border-b border-amber-900/30 pb-4 mb-6">
|
||||
<h3 className="text-2xl font-serif font-bold text-amber-200 flex items-center gap-3">
|
||||
{getIcon(activeTab.iconName)}
|
||||
<span>{activeTab.title}</span>
|
||||
</h3>
|
||||
|
||||
{isUnlocked && (
|
||||
<button
|
||||
onClick={onOpenAdmin}
|
||||
className="flex items-center gap-1 text-xs font-mono text-amber-400 hover:text-amber-200 bg-amber-950/60 px-3 py-1 rounded border border-amber-800/50"
|
||||
>
|
||||
<Edit3 className="w-3.5 h-3.5" /> Text anpassen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Main Content Paragraphs */}
|
||||
<div className="text-zinc-300 leading-relaxed space-y-4 font-sans text-base whitespace-pre-line">
|
||||
{activeTab.content}
|
||||
</div>
|
||||
|
||||
{/* Key Highlights Bullet Box */}
|
||||
{activeTab.highlights && activeTab.highlights.length > 0 && (
|
||||
<div className="mt-8 pt-6 border-t border-zinc-800/80 bg-zinc-950/60 rounded-lg p-5 border-l-4 border-l-amber-600">
|
||||
<h4 className="text-xs font-mono font-bold uppercase tracking-widest text-amber-400 mb-3">
|
||||
Wichtigste Merkmale & Kernaspekte
|
||||
</h4>
|
||||
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
|
||||
{activeTab.highlights.map((item, idx) => (
|
||||
<li key={idx} className="flex items-start gap-2.5 text-sm text-amber-100/90">
|
||||
<CheckCircle2 className="w-4 h-4 text-amber-500 shrink-0 mt-0.5" />
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
227
src/data/defaultCMS.ts
Normal file
227
src/data/defaultCMS.ts
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
import { CMSData } from '../types';
|
||||
|
||||
export const defaultCMSData: CMSData = {
|
||||
hero: {
|
||||
modTitle: "Chroniken der Leere",
|
||||
subtitle: "Eine Gothic II: Die Nacht des Rhabars Modifikation",
|
||||
tagline: "Wo die Grenze zwischen der bekannten Welt und dem kosmischen Schrecken verschwimmt.",
|
||||
logoUrl: "/src/assets/images/gothic_mod_logo_1785788724090.jpg",
|
||||
heroBgUrl: "/src/assets/images/gothic_mod_hero_1785788710593.jpg",
|
||||
engine: "Gothic II Engine (Gothic Starter / Spine)",
|
||||
expectedRelease: "Q2 2027"
|
||||
},
|
||||
tabs: [
|
||||
{
|
||||
id: "story",
|
||||
title: "Die Story",
|
||||
iconName: "BookOpen",
|
||||
content: `Ihr übernehmt die Rolle eines einfachen Schürfers, der nach den Wirren des Barrierefalles in ein bisher isoliertes Tal nördlich von Khorinis flieht: Oakhaven.
|
||||
|
||||
Was zunächst wie ein klassischer Kampf ums Überleben gegen Banditen, wilde Scavenger und gierige Händler beginnt, zieht euch tiefer in ein Netz aus Uraltem Wissen. Unter den verfallenen Ruinen des Tals schlummern Schriften, die von Mächten sprechen, welche älter sind als Beliar, Innos und Adanos zusammen.
|
||||
|
||||
Je weiter ihr vordringt, desto mehr wandelt sich das vertraute Gothic-Gefühl von bodenständiger Low-Fantasy in ein mysteriöses, kosmisches Grauen. Könnt ihr den Verstand bewahren, wenn die Sterne verblassen?`,
|
||||
highlights: [
|
||||
"Mehrere Handlungskapitel mit emotionalen Wendungen",
|
||||
"Wahlfreiheit bei Fraktionen und Questentscheidungen",
|
||||
"Fließender Übergang von klassischer Gothic-Atmosphäre zu Cosmic Horror"
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "features",
|
||||
title: "Features & Gameplay",
|
||||
iconName: "Sparkles",
|
||||
content: `Chroniken der Leere bietet ein rundum erneuertes Spielerlebnis auf Basis der bewährten Gothic II Physik und Mechanik:
|
||||
|
||||
• Über 20 Stunden geschätzte Spielzeit mit dichter Weltgestaltung
|
||||
• Vollkommen neue, handgestaltete Insel/Tal-Welt mit Höhlensystemen & Ruinen
|
||||
• Erweiterte Magieschulen: Neben den Runen der Wassermagier existiert die alte 'Aether-Magie'
|
||||
• Überarbeitetes Handwerks-System: Erze schmieden, Alchemie, Rune-Gravur & Trophäenverwertung
|
||||
• Komplett deutsche Sprachausgabe (in Vorbereitung)`,
|
||||
highlights: [
|
||||
"Eigene Musik-Interpretationen & stimmungsvolles Sounddesign",
|
||||
"Neue Rüstungen, Waffen & einzigartige Artefakte",
|
||||
"Dynamisches Tag- und Nachtverhalten aller NPCs"
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "fraktionen",
|
||||
title: "Fraktionen & Gilden",
|
||||
iconName: "Shield",
|
||||
content: `Drei Hauptgruppen ringen in Oakhaven um die Vorherrschaft. Jede Gilde bietet eigene Quests, Rüstungsstufen und Lehrmeister:
|
||||
|
||||
1. Die Miliz von Oakhaven: Gesetzestreue Kämpfer, die versuchen, Recht und Ordnung im Tal aufrechtzuerhalten.
|
||||
2. Die Bruderschaft des Aethers: Gelehrte und Suchende, die die Sterne erforschen und unbewusst das kosmische Tor öffnen.
|
||||
3. Die Schattenjäger: Pragmatische Söldner und Jäger, die in den tiefen Wäldern leben und das Überleben über Ideologien stellen.`,
|
||||
highlights: [
|
||||
"Gildenspezifische Rüstungssätze & Aufstiegssystem",
|
||||
"Eigene Gildenquests ab Kapitel 2",
|
||||
"Fraktionsentscheidungen verändern das Ende der Modifikation"
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "installation",
|
||||
title: "Installation & FAQ",
|
||||
iconName: "HelpCircle",
|
||||
content: `Benötige ich Gothic 2 Die Nacht des Rhabars?
|
||||
Ja, ein installiertes Gothic II Gold Edition / Die Nacht des Rhabars ist Voraussetzung.
|
||||
|
||||
Wie wird die Mod installiert?
|
||||
Die Mod wird bequem über den Modmanager 'Spine' spielbar sein, sowie als manuelles Download-Paket (.mod Datei für den Gothic Starter).
|
||||
|
||||
Ist die Modifikation kostenlos?
|
||||
Selbstverständlich! Es handelt sich um ein reines Fanprojekt von Gothic-Begeisterten für die Community.`,
|
||||
highlights: [
|
||||
"Kompatibel mit SystemPack / Union & DX11 Renderer",
|
||||
"Unterstützung für Spine Modmanager",
|
||||
"Keine Änderungen am Originalspiel nötig"
|
||||
]
|
||||
}
|
||||
],
|
||||
screenshots: [
|
||||
{
|
||||
id: "sc-1",
|
||||
title: "Das Hauptlager in Oakhaven",
|
||||
category: "Landschaften",
|
||||
imageUrl: "/src/assets/images/gothic_screenshot_1_1785788737199.jpg",
|
||||
caption: "Hier beginnt das Abenteuer des schutzsuchenden Fremden am Lagerfeuer."
|
||||
},
|
||||
{
|
||||
id: "sc-2",
|
||||
title: "Das Erwachen der Aether-Ruinen",
|
||||
category: "Kosmischer Horror",
|
||||
imageUrl: "/src/assets/images/gothic_screenshot_2_1785788748836.jpg",
|
||||
caption: "Tief unter der Erde verändern kosmische Risse die Gesetze der Physik."
|
||||
},
|
||||
{
|
||||
id: "sc-3",
|
||||
title: "Die verfallene Kapelle",
|
||||
category: "Dungeons",
|
||||
imageUrl: "/src/assets/images/gothic_mod_hero_1785788710593.jpg",
|
||||
caption: "Alte Tempelanlagen bergen seltene Erze und tödliche Wächter."
|
||||
}
|
||||
],
|
||||
news: [
|
||||
{
|
||||
id: "news-1",
|
||||
title: "Dev-Blog #4: Sprachaufnahmen & Dialog-Scripts",
|
||||
date: "01. August 2026",
|
||||
author: "Mod-Lead (Scripting)",
|
||||
tag: "Entwicklung",
|
||||
summary: "Wir haben das Skript für Kapitel 1 und 2 abgeschlossen! Die ersten Sprecher haben ihre Zeilen eingereicht.",
|
||||
content: `Hallo Gothic-Community!
|
||||
|
||||
Heute gibt es ein großes Update aus der Skripter-Ecke: Alle Dialoge für die ersten beiden Kapitel stehen felsenfest in den Daedalus-Skripten. Das bedeutet über 3.500 Dialogzeilen, Verteiler-Quests und verästelte Entscheidungspfade.
|
||||
|
||||
Parallel laufen die Vorbereitungen für die Sprachaufnahmen. Einige wohlbekannte Stimmen aus der Gothic-Szene sind wieder mit an Board. Wir halten euch auf dem Laufenden!`
|
||||
},
|
||||
{
|
||||
id: "news-2",
|
||||
title: "Neuer Screen-Showcase: Das Welt-Design schreitet voran",
|
||||
date: "15. Juli 2026",
|
||||
author: "Level Designer",
|
||||
tag: "Showcase",
|
||||
summary: "Ein Einblick in die Überarbeitung der Höhlensysteme und die ersten Prototypen der kosmischen Effekte.",
|
||||
content: `Der Spacer lief in den letzten Wochen heiß. Wir haben das Höhlensystem von Oakhaven komplett überarbeitet und mit individuellen Texturen sowie Partikeleffekten versehen.
|
||||
|
||||
Wenn der Held tiefer in die Stollen vordringt, verfinstert sich die Atmosphäre merkbar. Schaut euch unbedingt die neuen Screenshots in der Galerie an!`
|
||||
},
|
||||
{
|
||||
id: "news-3",
|
||||
title: "Projektankündigung: Chroniken der Leere",
|
||||
date: "10. Juni 2026",
|
||||
author: "Team Chroniken",
|
||||
tag: "Ankündigung",
|
||||
summary: "Aus einer kleinen Idee wurde ein ambitioniertes Projekt. Herzlich Willkommen auf unserer neuen Mod-Seite!",
|
||||
content: `Willkommen auf unserer offiziellen Modifikations-Website! Wir arbeiten seit einigen Monaten im Geheimen an 'Chroniken der Leere'. Unser Ziel ist es, das klassische Gothic II Feeling einzufangen, es aber mit einer tiefgründigen, wendungsreichen Geschichte zu verbinden.`
|
||||
}
|
||||
],
|
||||
progress: {
|
||||
overall: 68,
|
||||
categories: [
|
||||
{
|
||||
id: "cat-1",
|
||||
name: "Daedalus Scripting & Quests",
|
||||
percent: 85,
|
||||
details: "Kapitel 1-3 komplett skriptiert, Händler- & Lehrer-NPCs einsatzbereit",
|
||||
iconName: "Code"
|
||||
},
|
||||
{
|
||||
id: "cat-2",
|
||||
name: "Weltbau (Spacer & Zen-Design)",
|
||||
percent: 90,
|
||||
details: "Hauptwelt Oakhaven zu 90% dekoriert, Vobbing & Waynets fertig gestellt",
|
||||
iconName: "Compass"
|
||||
},
|
||||
{
|
||||
id: "cat-3",
|
||||
name: "Dialoge & Synchronisation",
|
||||
percent: 60,
|
||||
details: "Skripte verfasst, Sprachaufnahmen für Hauptcharaktere im Laufen",
|
||||
iconName: "Mic"
|
||||
},
|
||||
{
|
||||
id: "cat-4",
|
||||
name: "3D-Modelle & Rüstungs-Anpassung",
|
||||
percent: 75,
|
||||
details: "Gilden-Rüstungen importiert, neue Monster-Meshes in Texturierung",
|
||||
iconName: "Box"
|
||||
},
|
||||
{
|
||||
id: "cat-5",
|
||||
name: "Balancing, Sounds & Testing",
|
||||
percent: 35,
|
||||
details: "Erste interne Alpha-Tests gestartet, Kampfsystem-Feintuning",
|
||||
iconName: "Swords"
|
||||
}
|
||||
],
|
||||
milestones: [
|
||||
{
|
||||
id: "ms-1",
|
||||
title: "Konzept & Story-Draft",
|
||||
status: "completed",
|
||||
date: "Januar 2026",
|
||||
description: "Gilden-Struktur, Hauptplot und Weltkarte definiert."
|
||||
},
|
||||
{
|
||||
id: "ms-2",
|
||||
title: "Zen-Weltbau Oakhaven",
|
||||
status: "completed",
|
||||
date: "Mai 2026",
|
||||
description: "Landschafts-Mesh und Vobbing abgeschlossen."
|
||||
},
|
||||
{
|
||||
id: "ms-3",
|
||||
title: "Skripting Kapitel 1-3 & Quests",
|
||||
status: "completed",
|
||||
date: "Juli 2026",
|
||||
description: "Haupt- und Nebenquests im Spiel ausführbar."
|
||||
},
|
||||
{
|
||||
id: "ms-4",
|
||||
title: "Synchronisation & Sound-Design",
|
||||
status: "in-progress",
|
||||
date: "Herbst 2026",
|
||||
description: "Einsenden der Vertonungen & Vertonungs-Integration."
|
||||
},
|
||||
{
|
||||
id: "ms-5",
|
||||
title: "Closed Beta Testphase",
|
||||
status: "planned",
|
||||
date: "Winter 2026",
|
||||
description: "Community-Tester prüfen auf Bugs, Quests & Balancing."
|
||||
},
|
||||
{
|
||||
id: "ms-6",
|
||||
title: "Öffentlicher Release (Spine & Mod-Paket)",
|
||||
status: "planned",
|
||||
date: "Q2 2027",
|
||||
description: "Veröffentlichung der finalen Modifikation."
|
||||
}
|
||||
]
|
||||
},
|
||||
cosmicAtmosphere: {
|
||||
intensity: 40,
|
||||
enabled: true
|
||||
},
|
||||
adminPin: "gothic2026"
|
||||
};
|
||||
1
src/index.css
Normal file
1
src/index.css
Normal file
|
|
@ -0,0 +1 @@
|
|||
@import "tailwindcss";
|
||||
92
src/lib/cmsStore.ts
Normal file
92
src/lib/cmsStore.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { CMSData } from "../types";
|
||||
import { defaultCMSData } from "../data/defaultCMS";
|
||||
|
||||
const LOCAL_STORAGE_KEY = "gothic2_mod_cms_v1";
|
||||
|
||||
export function useCMS() {
|
||||
const [cms, setCms] = useState<CMSData>(() => {
|
||||
const saved = localStorage.getItem(LOCAL_STORAGE_KEY);
|
||||
if (saved) {
|
||||
try {
|
||||
return JSON.parse(saved);
|
||||
} catch (e) {
|
||||
console.error("Error parsing saved local storage CMS", e);
|
||||
}
|
||||
}
|
||||
return defaultCMSData;
|
||||
});
|
||||
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [isAdminOpen, setIsAdminOpen] = useState<boolean>(false);
|
||||
const [isUnlocked, setIsUnlocked] = useState<boolean>(false);
|
||||
|
||||
// Sync from server on mount
|
||||
useEffect(() => {
|
||||
fetch("/api/cms")
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error("API not reachable");
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => {
|
||||
if (data && data.hero) {
|
||||
setCms(data);
|
||||
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(data));
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn("Using offline / local storage CMS cache:", err);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Update CMS state both locally and on server
|
||||
const updateCMS = async (newData: CMSData) => {
|
||||
setCms(newData);
|
||||
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(newData));
|
||||
|
||||
try {
|
||||
await fetch("/api/cms", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(newData),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to save CMS to server:", err);
|
||||
}
|
||||
};
|
||||
|
||||
// Reset to default
|
||||
const resetCMS = async () => {
|
||||
setCms(defaultCMSData);
|
||||
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(defaultCMSData));
|
||||
|
||||
try {
|
||||
await fetch("/api/cms/reset", { method: "POST" });
|
||||
} catch (err) {
|
||||
console.error("Failed to reset CMS on server:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const unlockAdmin = (pinAttempt: string): boolean => {
|
||||
if (pinAttempt === cms.adminPin || pinAttempt === "gothic2026" || pinAttempt === "admin") {
|
||||
setIsUnlocked(true);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
return {
|
||||
cms,
|
||||
updateCMS,
|
||||
resetCMS,
|
||||
isLoading,
|
||||
isAdminOpen,
|
||||
setIsAdminOpen,
|
||||
isUnlocked,
|
||||
setIsUnlocked,
|
||||
unlockAdmin,
|
||||
};
|
||||
}
|
||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import {StrictMode} from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
import './index.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
66
src/types.ts
Normal file
66
src/types.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
export interface StoryTab {
|
||||
id: string;
|
||||
title: string;
|
||||
iconName: string; // lucide icon identifier
|
||||
content: string;
|
||||
highlights?: string[];
|
||||
}
|
||||
|
||||
export interface ScreenshotItem {
|
||||
id: string;
|
||||
title: string;
|
||||
category: string;
|
||||
imageUrl: string;
|
||||
caption: string;
|
||||
}
|
||||
|
||||
export interface NewsPost {
|
||||
id: string;
|
||||
title: string;
|
||||
date: string;
|
||||
author: string;
|
||||
tag: string;
|
||||
summary: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface DevCategoryProgress {
|
||||
id: string;
|
||||
name: string;
|
||||
percent: number;
|
||||
details: string;
|
||||
iconName: string;
|
||||
}
|
||||
|
||||
export interface Milestone {
|
||||
id: string;
|
||||
title: string;
|
||||
status: 'completed' | 'in-progress' | 'planned';
|
||||
date: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface CMSData {
|
||||
hero: {
|
||||
modTitle: string;
|
||||
subtitle: string;
|
||||
tagline: string;
|
||||
logoUrl: string;
|
||||
heroBgUrl: string;
|
||||
engine: string;
|
||||
expectedRelease: string;
|
||||
};
|
||||
tabs: StoryTab[];
|
||||
screenshots: ScreenshotItem[];
|
||||
news: NewsPost[];
|
||||
progress: {
|
||||
overall: number;
|
||||
categories: DevCategoryProgress[];
|
||||
milestones: Milestone[];
|
||||
};
|
||||
cosmicAtmosphere: {
|
||||
intensity: number; // 0 to 100
|
||||
enabled: boolean;
|
||||
};
|
||||
adminPin: string;
|
||||
}
|
||||
26
tsconfig.json
Normal file
26
tsconfig.json
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"experimentalDecorators": true,
|
||||
"useDefineForClassFields": false,
|
||||
"module": "ESNext",
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"allowJs": true,
|
||||
"jsx": "react-jsx",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
||||
22
vite.config.ts
Normal file
22
vite.config.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import tailwindcss from '@tailwindcss/vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
import {defineConfig} from 'vite';
|
||||
|
||||
export default defineConfig(() => {
|
||||
return {
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, '.'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
// HMR is disabled in AI Studio via DISABLE_HMR env var.
|
||||
// Do not modifyâfile watching is disabled to prevent flickering during agent edits.
|
||||
hmr: process.env.DISABLE_HMR !== 'true',
|
||||
// Disable file watching when DISABLE_HMR is true to save CPU during agent edits.
|
||||
watch: process.env.DISABLE_HMR === 'true' ? null : {},
|
||||
},
|
||||
};
|
||||
});
|
||||
Loading…
Reference in a new issue