Merge pull request #1 from ceratic/ff

feat: Add DatabaseManager and LinkPlayer components, implement authen…
This commit is contained in:
Lars Behrends
2025-12-28 16:53:28 +01:00
committed by GitHub
40 changed files with 5967 additions and 102 deletions

5
.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
# .dockerignore
node_modules
database
*/node_modules*

14
.env Normal file
View File

@@ -0,0 +1,14 @@
# Discord OAuth Configuration
DISCORD_CLIENT_ID=1454649755513655491
DISCORD_CLIENT_SECRET=TqGBxbyE3NBoJCp1riuu2eJe6Y_0zwtu
# Session Security
SESSION_SECRET=dhu2rb9gt82vrn9th2847t2nv45t8v29n4g5tu4gtib
# Note: Replace the placeholder values above with your actual Discord application credentials
# Get these from: https://discord.com/developers/applications
DB_HOST=db
DB_USER=obsidian_user
DB_PASS=obsidian_pass
DB_NAME=obsidian_db

77
App.tsx
View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import Layout from './components/Layout';
import Dashboard from './pages/Dashboard';
import PlayerProfile from './pages/PlayerProfile';
@@ -9,7 +9,12 @@ import Cities from './pages/Cities';
import CityProfile from './pages/CityProfile';
import ProjectProfile from './pages/ProjectProfile';
import DatapackGenerator from './pages/DatapackGenerator';
import { MOCK_PLAYERS, MOCK_ORGS, MOCK_PROJECTS } from './constants';
import DatabaseManager from './pages/DatabaseManager'; // Ensure this file exists or remove import if not
import LinkPlayer from './pages/LinkPlayer';
import AdminPage from './pages/Admin';
import { dbService } from './services/DatabaseService';
import { authService } from './services/AuthService';
import { DiscordUser } from './types';
import { Icons } from './components/IconSet';
function App() {
@@ -19,6 +24,44 @@ function App() {
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
const [selectedOrgId, setSelectedOrgId] = useState<string | null>(null);
// Auth state
const [user, setUser] = useState<DiscordUser | null>(null);
const [authChecked, setAuthChecked] = useState(false);
// State for data from DB (Async)
const [players, setPlayers] = useState(dbService.getPlayers());
const [orgs, setOrgs] = useState(dbService.getOrgs());
const [projects, setProjects] = useState(dbService.getProjects());
// Subscribe to DB updates (when fetch completes or edits happen)
useEffect(() => {
const unsub = dbService.subscribe(() => {
setPlayers([...dbService.getPlayers()]);
setOrgs([...dbService.getOrgs()]);
setProjects([...dbService.getProjects()]);
});
return unsub;
}, []);
// Subscribe to auth updates
useEffect(() => {
const unsub = authService.subscribe((currentUser) => {
setUser(currentUser);
setAuthChecked(true);
// If user is logged in but not linked, redirect to link page
if (currentUser && !currentUser.linkedPlayerUuid) {
setActiveTab('link-player');
} else if (currentUser && currentUser.linkedPlayerUuid) {
// User is fully authenticated, redirect to dashboard if on link page
if (activeTab === 'link-player') {
setActiveTab('dashboard');
}
}
});
return unsub;
}, [activeTab]);
const handleNavigate = (tab: string) => {
setActiveTab(tab);
if (tab !== 'players') setSelectedPlayerId(null);
@@ -27,21 +70,18 @@ function App() {
if (tab !== 'organizations') setSelectedOrgId(null);
};
// Helper to jump to a player from another view
const navigateToPlayer = (id: string) => {
setSelectedPlayerId(id);
setActiveTab('players');
};
// Helper to jump to a project from another view
const navigateToProject = (id: string) => {
setSelectedProjectId(id);
setActiveTab('projects');
};
// Helper to jump to an org/city
const navigateToOrg = (id: string) => {
const org = MOCK_ORGS.find(o => o.id === id);
const org = orgs.find(o => o.id === id);
if (org?.type === 'City') {
setSelectedCityId(id);
setActiveTab('cities');
@@ -52,11 +92,16 @@ function App() {
};
const renderContent = () => {
// Show link player page if user is logged in but not linked
if (activeTab === 'link-player') {
return <LinkPlayer />;
}
if (activeTab === 'dashboard') return <Dashboard />;
if (activeTab === 'projects') {
if (selectedProjectId) {
const project = MOCK_PROJECTS.find(p => p.id === selectedProjectId);
const project = projects.find(p => p.id === selectedProjectId);
if (project) return (
<ProjectProfile
project={project}
@@ -71,7 +116,7 @@ function App() {
if (activeTab === 'organizations') {
if (selectedOrgId) {
const org = MOCK_ORGS.find(o => o.id === selectedOrgId);
const org = orgs.find(o => o.id === selectedOrgId);
if (org) return (
<CityProfile
city={org}
@@ -91,7 +136,7 @@ function App() {
if (activeTab === 'cities') {
if (selectedCityId) {
const city = MOCK_ORGS.find(o => o.id === selectedCityId);
const city = orgs.find(o => o.id === selectedCityId);
if (city) return (
<CityProfile
city={city}
@@ -107,7 +152,7 @@ function App() {
if (activeTab === 'players') {
if (selectedPlayerId) {
const player = MOCK_PLAYERS.find(p => p.uuid === selectedPlayerId);
const player = players.find(p => p.uuid === selectedPlayerId);
if (player) return <PlayerProfile player={player} onBack={() => setSelectedPlayerId(null)} />;
}
@@ -126,7 +171,7 @@ function App() {
</div>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{MOCK_PLAYERS.map(player => (
{players.map(player => (
<div
key={player.uuid}
onClick={() => setSelectedPlayerId(player.uuid)}
@@ -150,6 +195,16 @@ function App() {
);
}
// Admin panel (only for admins)
if (activeTab === 'admin') {
return <AdminPage onBack={() => setActiveTab('dashboard')} />;
}
// Placeholder for database manager if user navigates there (needs explicit page or component)
if (activeTab === 'database') {
return <div className="p-10 text-center text-textMuted">Datenbank wird jetzt über das Backend (SQLite) verwaltet.</div>
}
return (
<div className="flex flex-col items-center justify-center h-[50vh] text-textMuted">
<Icons.Box className="w-12 h-12 mb-4 opacity-20" />

210
DOCKER-SETUP.md Normal file
View File

@@ -0,0 +1,210 @@
# Docker Setup for Vollidioten Project
This document describes the Docker setup for the Vollidioten project, including frontend, backend, and database services with Traefik integration.
## 🚀 Quick Start
1. **Create Traefik network** (if not already exists):
```bash
docker network create traefik_network
```
2. **Set up environment variables**:
Create a `.env` file with the following variables:
```env
DISCORD_CLIENT_ID=your_discord_client_id
DISCORD_CLIENT_SECRET=your_discord_client_secret
SESSION_SECRET=your_session_secret
```
3. **Build and start the containers**:
```bash
docker-compose up -d --build
```
4. **Access the application**:
- Frontend: `https://vollidioten.ceraticsoft.de`
- phpMyAdmin: `http://localhost:8081`
## 📁 Project Structure
```
.
├── Dockerfile.frontend # Frontend Dockerfile
├── Dockerfile.backend # Backend Dockerfile
├── docker-compose.yml # Docker Compose configuration
├── nginx.conf # Nginx configuration with fallback
├── public/
│ ├── mock/ # Mock data files
│ │ ├── players.json # Mock player data
│ │ ├── orgs.json # Mock organization data
│ │ └── projects.json # Mock project data
│ └── auth-unavailable.html # Auth unavailable page
└── test-docker-setup.sh # Test script
```
## 🔧 Services
### Frontend
- **Image**: Custom built from `Dockerfile.frontend`
- **Port**: 80 (internal, exposed via Traefik)
- **Traefik Configuration**:
- Host: `vollidioten.ceraticsoft.de`
- EntryPoint: `websecure` (HTTPS)
- TLS: Let's Encrypt
- **Features**:
- Automatic fallback to mock data when backend is unavailable
- Nginx proxy with error handling
- Static file serving with caching
### Backend
- **Image**: Custom built from `Dockerfile.backend`
- **Port**: 3000 (internal only)
- **Environment Variables**:
- `DISCORD_CLIENT_ID`: Discord OAuth client ID
- `DISCORD_CLIENT_SECRET`: Discord OAuth client secret
- `SESSION_SECRET`: Session encryption secret
- `CALLBACK_URL`: OAuth callback URL
- `FRONTEND_URL`: Frontend base URL
- `DB_*`: Database connection details
### Database (MySQL)
- **Image**: `mysql:8.0`
- **Port**: 3306 (internal only)
- **Credentials**:
- User: `obsidian_user`
- Password: `obsidian_pass`
- Database: `obsidian_db`
- **Persistent Storage**: Docker volume (`db_data`)
### phpMyAdmin
- **Image**: `phpmyadmin/phpmyadmin`
- **Port**: 8081 (localhost only)
- **Access**: `http://localhost:8081`
## 🛡️ Fallback Mechanism
The setup includes a robust fallback mechanism:
1. **Nginx Proxy Fallback**:
- When the backend is unavailable (502/503/504 errors), nginx automatically serves mock data
- Mock data is served from `/public/mock/` directory
- Response headers indicate mock data usage:
- `X-Mock-Data: true`
- `X-Backend-Status: unavailable`
2. **Frontend Detection**:
- The frontend checks for the `X-Mock-Data` header
- Console warnings indicate when mock data is being used
- Users see appropriate messages in the UI
3. **Auth Handling**:
- Auth endpoints fail gracefully when backend is down
- Users see a friendly error page (`auth-unavailable.html`)
- Application remains usable in read-only mode
## 🔄 Traefik Integration
The frontend service is configured with Traefik labels:
```yaml
labels:
- "traefik.enable=true"
- "traefik.http.routers.vollidioten.rule=Host(`vollidioten.ceraticsoft.de`)"
- "traefik.http.routers.vollidioten.entrypoints=websecure"
- "traefik.http.routers.vollidioten.tls.certresolver=lets-encrypt"
- "traefik.http.services.vollidioten.loadbalancer.server.port=80"
```
## 🧪 Testing
Run the test script to verify the setup:
```bash
./test-docker-setup.sh
```
The script checks:
- Docker is running
- Traefik network exists
- All required files are present
- Docker containers can be built
## 📝 Environment Variables
Create a `.env` file in the project root:
```env
# Discord OAuth
DISCORD_CLIENT_ID=your_client_id
DISCORD_CLIENT_SECRET=your_client_secret
# Session
SESSION_SECRET=your_strong_secret_here
```
## 🔧 Customization
### Adding More Mock Data
Add JSON files to the `public/mock/` directory following the same structure as existing files.
### Updating Nginx Configuration
Edit `nginx.conf` to modify:
- Proxy settings
- Cache headers
- Error handling
- Fallback behavior
### Scaling
The setup supports horizontal scaling:
- Frontend: Can be scaled to multiple instances
- Backend: Can be scaled with proper session handling
- Database: Use external managed database for production
## 🚨 Troubleshooting
**Docker Network Issues**:
```bash
docker network create traefik_network
```
**Permission Issues**:
```bash
chmod +x test-docker-setup.sh
```
**Port Conflicts**:
- Ensure port 8081 is available for phpMyAdmin
- Check Traefik is running and listening on port 443
**SSL Issues**:
- Ensure Traefik is configured with Let's Encrypt
- Check DNS records for `vollidioten.ceraticsoft.de`
## 🎯 Production Notes
1. **Security**:
- Use strong secrets for all environment variables
- Configure proper CORS settings
- Set up database backups
2. **Performance**:
- Configure proper caching headers
- Optimize database queries
- Use CDN for static assets
3. **Monitoring**:
- Set up health checks
- Configure logging
- Monitor backend availability
4. **Updates**:
- Regularly update Docker images
- Test updates in staging first
- Monitor after deployments
## 📚 References
- [Traefik Documentation](https://doc.traefik.io/traefik/)
- [Docker Compose Reference](https://docs.docker.com/compose/)
- [Nginx Configuration Guide](https://nginx.org/en/docs/)

20
Dockerfile.backend Normal file
View File

@@ -0,0 +1,20 @@
# Backend Dockerfile
FROM node:20-alpine
# Set working directory
WORKDIR /app
# Copy package files
COPY backend/package.json .
# Install dependencies
RUN npm install
# Copy source files
COPY backend/ /app/
# Expose port
EXPOSE 3000
# Start the application
CMD ["node", "server.js"]

33
Dockerfile.frontend Normal file
View File

@@ -0,0 +1,33 @@
# Frontend Dockerfile
FROM node:20-alpine AS builder
# Set working directory
WORKDIR /app
# Copy package files
COPY package.json .
# Install dependencies
RUN npm install
# Copy source files
COPY . .
COPY public /app/public
# Build the application
RUN npm run build
# Production stage
FROM nginx:alpine
# Copy built files from builder
COPY --from=builder /app/dist /usr/share/nginx/html
# Copy custom nginx configuration
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Expose port
EXPOSE 80
# Start nginx
CMD ["nginx", "-g", "daemon off;"]

226
backend/database.js Normal file
View File

@@ -0,0 +1,226 @@
const mysql = require('mysql2');
// Database Config from Env
const dbConfig = {
host: process.env.DB_HOST || 'localhost',
user: process.env.DB_USER || 'root',
password: process.env.DB_PASS || '',
database: process.env.DB_NAME || 'obsidian_db',
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0
};
let pool;
// Mock Data for Seeding
const SEED_PLAYERS = [
{
uuid: '80301bff-74df-4579-bcfc-082ac8d26b5b',
username: 'kaiwastoshort',
isOnline: 1,
isNpc: 0,
isAdmin: 0,
tags: JSON.stringify(['#Bürger', '#Händler']),
stats: JSON.stringify({ playtimeHours: 482, level: 45, role: 'Bürger', organizationId: 'org-3' }),
inventory: JSON.stringify([]),
storyMarkdown: '# Der Bauplan von V\n\n> "Stein erinnert sich..."'
},
{
uuid: '8984c0b5-d912-4462-b189-c864fba4a1af',
username: 'DrKButz',
isOnline: 0,
isNpc: 0,
isAdmin: 1, // DrKButz is admin for testing
tags: JSON.stringify(['#Bauunternehmer']),
stats: JSON.stringify({ playtimeHours: 120, level: 12, role: 'Unternehmer', organizationId: 'org-4' }),
inventory: JSON.stringify([]),
storyMarkdown: '# Forschungslogbuch:\n\nSpezialisiert auf...'
}
];
const SEED_ORGS = [
{
id: 'org-3',
name: 'Provisorium Null',
type: 'City',
description: 'Die erste Siedlung...',
memberCount: 6,
status: 'active',
mayor: '',
establishedYear: 'Day 0',
bannerUrl: 'images/screenshots/2025-12-28_01.11.10.png',
gallery: JSON.stringify(['images/screenshots/2025-12-28_01.12.07.png']),
cityStats: JSON.stringify({ taxRate: 3.5, biome: 'Ebene', defenseRating: 8, government: 'Feudal', specialty: 'Handel' })
},
{
id: 'org-4',
name: 'Sakura',
type: 'City',
description: 'Eine dunkle, biolumineszente Hafenstadt...',
memberCount: 2,
status: 'active',
mayor: 'Kampfzwerk',
establishedYear: 'Ära 2',
bannerUrl: 'images/screenshots/2025-12-28_01.11.32.png',
gallery: JSON.stringify([]),
cityStats: JSON.stringify({ taxRate: 15.0, biome: 'Deep Dark', defenseRating: 4, government: 'Syndikat', specialty: 'Schmuggel' })
}
];
const SEED_PROJECTS = [
{
id: 'ven-1',
title: 'DrkButz Architektur',
description: 'Führendes Architekturbüro...',
category: 'Enterprise',
status: 'active',
progress: 85,
owner: 'DrKButz',
employees: JSON.stringify([]),
hiring: 0,
foundedDate: 'Zyklus 12',
associatedOrgId: 'org-4',
bannerUrl: 'images/screenshots/2025-12-28_01.11.49.png',
shopCatalog: JSON.stringify([])
}
];
// Retry connection logic for Docker
function init() {
const tryConnect = () => {
console.log("Attempting to connect to MySQL...");
const tempPool = mysql.createPool(dbConfig);
tempPool.getConnection((err, connection) => {
if (err) {
console.error("Database connection failed. Retrying in 5s...", err.code);
setTimeout(tryConnect, 5000);
} else {
console.log("MySQL Connected!");
pool = tempPool;
connection.release();
setupTables();
}
});
};
tryConnect();
}
function setupTables() {
const queries = [
`CREATE TABLE IF NOT EXISTS players (
uuid VARCHAR(36) PRIMARY KEY,
username VARCHAR(255),
isOnline TINYINT,
isNpc TINYINT DEFAULT 0,
isAdmin TINYINT DEFAULT 0,
tags JSON,
stats JSON,
inventory JSON,
storyMarkdown TEXT,
discordId VARCHAR(255)
)`,
`CREATE TABLE IF NOT EXISTS orgs (
id VARCHAR(50) PRIMARY KEY,
name VARCHAR(255),
type VARCHAR(50),
description TEXT,
memberCount INTEGER,
status VARCHAR(50),
mayor VARCHAR(255),
establishedYear VARCHAR(100),
bannerUrl VARCHAR(255),
gallery JSON,
cityStats JSON
)`,
`CREATE TABLE IF NOT EXISTS projects (
id VARCHAR(50) PRIMARY KEY,
title VARCHAR(255),
description TEXT,
category VARCHAR(50),
status VARCHAR(50),
progress INTEGER,
owner VARCHAR(255),
employees JSON,
hiring TINYINT,
foundedDate VARCHAR(100),
associatedOrgId VARCHAR(50),
bannerUrl VARCHAR(255),
shopCatalog JSON,
gallery JSON
)`
];
queries.forEach(q => {
pool.query(q, (err) => {
if (err) console.error("Error creating table:", err);
});
});
seedData();
}
function seedData() {
// Check if players exist
pool.query("SELECT COUNT(*) as count FROM players", (err, rows) => {
if (!err && rows[0].count === 0) {
console.log("Seeding Players...");
SEED_PLAYERS.forEach(p => {
pool.query("INSERT INTO players VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
[p.uuid, p.username, p.isOnline, p.isNpc, p.isAdmin, p.tags, p.stats, p.inventory, p.storyMarkdown, null]);
});
}
});
pool.query("SELECT COUNT(*) as count FROM orgs", (err, rows) => {
if (!err && rows[0].count === 0) {
console.log("Seeding Orgs...");
SEED_ORGS.forEach(o => {
pool.query("INSERT INTO orgs VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
[o.id, o.name, o.type, o.description, o.memberCount, o.status, o.mayor, o.establishedYear, o.bannerUrl, o.gallery, o.cityStats]);
});
}
});
pool.query("SELECT COUNT(*) as count FROM projects", (err, rows) => {
if (!err && rows[0].count === 0) {
console.log("Seeding Projects...");
SEED_PROJECTS.forEach(p => {
pool.query("INSERT INTO projects VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
[p.id, p.title, p.description, p.category, p.status, p.progress, p.owner, p.employees, p.hiring, p.foundedDate, p.associatedOrgId, p.bannerUrl, p.shopCatalog, '[]']);
});
}
});
}
// Wrapper to mimic SQLite API for easy migration in server.js
const dbWrapper = {
get: (sql, params, cb) => {
if (!pool) return cb(new Error("DB not ready"));
pool.query(sql, params, (err, rows) => {
if (err) return cb(err);
cb(null, rows[0]);
});
},
all: (sql, params, cb) => {
// Handle case where params is omitted and cb is second parameter
if (typeof params === 'function') {
cb = params;
params = [];
}
if (!pool) return cb(new Error("DB not ready"));
pool.query(sql, params || [], (err, rows) => {
cb(err, rows);
});
},
run: (sql, params, cb) => {
if (!pool) return cb(new Error("DB not ready"));
pool.query(sql, params, function(err, result) {
// "this" context in sqlite callback contains changes, mock it if needed
if (cb) cb(err);
});
}
};
module.exports = { db: dbWrapper, init };

17
backend/package.json Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "obsidian-backend",
"version": "1.0.0",
"description": "Backend for Obsidian RP Platform",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^4.18.2",
"express-session": "^1.17.3",
"passport": "^0.6.0",
"passport-discord": "^0.1.4",
"cors": "^2.8.5",
"mysql2": "^3.6.0"
}
}

923
backend/server.js Normal file
View File

@@ -0,0 +1,923 @@
const express = require('express');
const session = require('express-session');
const passport = require('passport');
const DiscordStrategy = require('passport-discord').Strategy;
const cors = require('cors');
const { db, init } = require('./database');
const app = express();
const PORT = 3000;
// Env variables (provided via Docker/Environment)
const CLIENT_ID = process.env.DISCORD_CLIENT_ID || 'mock_id';
const CLIENT_SECRET = process.env.DISCORD_CLIENT_SECRET || 'mock_secret';
const CALLBACK_URL = process.env.CALLBACK_URL || 'http://localhost:3000/auth/discord/callback';
const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:8000';
init(); // Initialize DB
// Middleware
app.use(express.json());
app.use(cors({ origin: FRONTEND_URL, credentials: true }));
app.use(session({
secret: process.env.SESSION_SECRET || 'dev_secret',
resave: false,
saveUninitialized: false,
cookie: { secure: false } // Set true if using https
}));
app.use(passport.initialize());
app.use(passport.session());
// Passport Setup
passport.serializeUser((user, done) => done(null, user));
passport.deserializeUser((obj, done) => {
// If user has a linked player, fetch their Minecraft username and admin status from database
if (obj.linkedPlayerUuid) {
db.get("SELECT username, isAdmin FROM players WHERE uuid = ?", [obj.linkedPlayerUuid], (err, row) => {
if (!err && row) {
// Replace Discord username with Minecraft username for easier API usage
obj.username = row.username;
obj.isAdmin = !!row.isAdmin;
}
done(null, obj);
});
} else {
obj.isAdmin = false;
done(null, obj);
}
});
passport.use(new DiscordStrategy({
clientID: CLIENT_ID,
clientSecret: CLIENT_SECRET,
callbackURL: CALLBACK_URL,
scope: ['identify']
},
function(accessToken, refreshToken, profile, cb) {
// In a real app, you might map the Discord ID to a player in the DB here
// For this demo, we just pass the profile through
// We try to find a player who has this discordId linked, or return the raw profile
db.get("SELECT uuid FROM players WHERE discordId = ?", [profile.id], (err, row) => {
const user = {
id: profile.id,
username: profile.username,
discriminator: profile.discriminator,
avatarUrl: `https://cdn.discordapp.com/avatars/${profile.id}/${profile.avatar}.png`,
linkedPlayerUuid: row ? row.uuid : null
};
// Hack for demo: If user is "DrKButz" (or you), make them admin of that player
// For testing, we just link the first login to DrKButz if not taken
if (!row && profile.username === 'YourDiscordUsername') { // Replace logic as needed
user.linkedPlayerUuid = '8984c0b5-d912-4462-b189-c864fba4a1af';
}
return cb(null, user);
});
}
));
app.get('/api/status', (req, res) => {
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
version: '1.0.0',
database: 'connected'
});
});
// --- ROUTES ---
// Auth
app.get('/auth/discord', passport.authenticate('discord'));
app.get('/auth/discord/callback', passport.authenticate('discord', {
failureRedirect: FRONTEND_URL + '?error=login_failed'
}), (req, res) => {
res.redirect(FRONTEND_URL);
});
app.get('/auth/me', (req, res) => {
if (req.isAuthenticated()) {
res.json(req.user);
} else {
res.status(401).json({ error: 'Not authenticated' });
}
});
app.get('/auth/logout', (req, res) => {
req.logout(() => {
res.redirect(FRONTEND_URL);
});
});
// API: Players
app.get('/api/players', (req, res) => {
db.all("SELECT * FROM players", (err, rows) => {
if (err) return res.status(500).json({error: err.message});
// Parse JSON fields
const parsed = rows.map(r => ({
...r,
tags: JSON.parse(r.tags),
stats: JSON.parse(r.stats),
inventory: JSON.parse(r.inventory),
isOnline: !!r.isOnline
}));
res.json(parsed);
});
});
// API: Unlinked Players (for Discord account linking)
app.get('/api/unlinked-players', (req, res) => {
db.all("SELECT uuid, username, tags, stats FROM players WHERE discordId IS NULL OR discordId = ''", (err, rows) => {
if (err) return res.status(500).json({error: err.message});
// Parse JSON fields
const parsed = rows.map(r => ({
...r,
tags: JSON.parse(r.tags),
stats: JSON.parse(r.stats)
}));
res.json(parsed);
});
});
app.put('/api/players/:uuid', (req, res) => {
if (!req.isAuthenticated()) return res.status(401).send();
// Check if user owns this player profile OR if it's an admin creating/updating NPCs
const isOwner = req.user.linkedPlayerUuid === req.params.uuid;
const isAdmin = req.user.isAdmin;
if (!isOwner && !isAdmin) {
return res.status(403).json({error: 'Keine Berechtigung zum Bearbeiten'});
}
const updates = req.body;
const allowedFields = ['storyMarkdown', 'tags'];
const updateFields = [];
const values = [];
// Handle organizationId specially - it goes into stats JSON
if (updates.organizationId !== undefined) {
// First get current player data
db.get("SELECT stats FROM players WHERE uuid = ?", [req.params.uuid], (err, row) => {
if (err) return res.status(500).json({error: err.message});
if (!row) return res.status(404).json({error: 'Spieler nicht gefunden'});
try {
const currentStats = JSON.parse(row.stats || '{}');
const updatedStats = { ...currentStats, organizationId: updates.organizationId };
updateFields.push('stats = ?');
values.push(JSON.stringify(updatedStats));
} catch (e) {
return res.status(500).json({error: 'Fehler beim Verarbeiten der Stats'});
}
// Continue with other fields
for (const field of allowedFields) {
if (updates[field] !== undefined) {
if (field === 'tags') {
// Tags are stored as JSON
updateFields.push(`${field} = ?`);
values.push(JSON.stringify(updates[field]));
} else {
updateFields.push(`${field} = ?`);
values.push(updates[field]);
}
}
}
if (updateFields.length === 0) {
return res.status(400).json({error: 'Keine gültigen Felder zum Aktualisieren'});
}
const query = `UPDATE players SET ${updateFields.join(', ')} WHERE uuid = ?`;
values.push(req.params.uuid);
db.run(query, values, function(err) {
if (err) {
console.error('Error updating player:', err);
return res.status(500).json({error: 'Fehler beim Aktualisieren'});
}
res.json({success: true, message: 'Spieler erfolgreich aktualisiert'});
});
});
return; // Exit early since we're handling this asynchronously
}
// Handle other fields normally
for (const field of allowedFields) {
if (updates[field] !== undefined) {
if (field === 'tags') {
// Tags are stored as JSON
updateFields.push(`${field} = ?`);
values.push(JSON.stringify(updates[field]));
} else {
updateFields.push(`${field} = ?`);
values.push(updates[field]);
}
}
}
if (updateFields.length === 0) {
return res.status(400).json({error: 'Keine gültigen Felder zum Aktualisieren'});
}
const query = `UPDATE players SET ${updateFields.join(', ')} WHERE uuid = ?`;
values.push(req.params.uuid);
db.run(query, values, function(err) {
if (err) {
console.error('Error updating player:', err);
return res.status(500).json({error: 'Fehler beim Aktualisieren'});
}
res.json({success: true, message: 'Spieler erfolgreich aktualisiert'});
});
});
// API: Orgs
app.get('/api/orgs', (req, res) => {
db.all("SELECT * FROM orgs", (err, rows) => {
if (err) return res.status(500).json({error: err.message});
const parsed = rows.map(r => ({
...r,
gallery: JSON.parse(r.gallery),
cityStats: r.cityStats ? JSON.parse(r.cityStats) : undefined
}));
res.json(parsed);
});
});
// API: Projects
app.get('/api/projects', (req, res) => {
db.all("SELECT * FROM projects", (err, rows) => {
if (err) return res.status(500).json({error: err.message});
const parsed = rows.map(r => ({
...r,
employees: JSON.parse(r.employees),
shopCatalog: JSON.parse(r.shopCatalog),
gallery: JSON.parse(r.gallery),
hiring: !!r.hiring
}));
res.json(parsed);
});
});
// Create new project
app.post('/api/projects', (req, res) => {
if (!req.isAuthenticated()) return res.status(401).send();
console.log('Received project creation request');
console.log('Request body:', req.body);
console.log('Authenticated user:', req.user);
const { title, description, category, associatedOrgId, owner } = req.body;
if (!title || !description) {
return res.status(400).json({ error: 'Titel und Beschreibung sind erforderlich' });
}
// Use the owner from request body (Minecraft character name) or fallback to Discord username
const projectOwner = owner || req.user.username;
console.log('Using project owner:', projectOwner);
// Generate unique ID
const projectId = 'proj_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
db.run(`INSERT INTO projects
(id, title, description, category, status, progress, owner, employees, hiring, foundedDate, associatedOrgId, bannerUrl, shopCatalog, gallery)
VALUES (?, ?, ?, ?, 'active', 0, ?, '[]', 0, ?, ?, '', '[]', '[]')`,
[projectId, title, description, category || 'Enterprise', projectOwner,
new Date().toISOString().split('T')[0], associatedOrgId || null],
function(err) {
if (err) {
console.error('Error creating project:', err);
return res.status(500).json({error: 'Fehler beim Erstellen des Projekts'});
}
res.json({
success: true,
projectId: projectId,
message: 'Projekt erfolgreich erstellt'
});
}
);
});
// Update project (with ownership check)
app.put('/api/projects/:id', (req, res) => {
if (!req.isAuthenticated()) return res.status(401).send();
const projectId = req.params.id;
const updates = req.body;
// First check ownership
db.get("SELECT owner FROM projects WHERE id = ?", [projectId], (err, row) => {
if (err) return res.status(500).json({error: err.message});
if (!row) return res.status(404).json({error: 'Projekt nicht gefunden'});
if (row.owner !== req.user.username) {
return res.status(403).json({error: 'Keine Berechtigung zum Bearbeiten'});
}
// Build dynamic update query
const allowedFields = ['title', 'description', 'category', 'status', 'progress', 'hiring', 'bannerUrl'];
const updateFields = [];
const values = [];
for (const field of allowedFields) {
if (updates[field] !== undefined) {
if (field === 'hiring') {
updateFields.push(`${field} = ?`);
values.push(updates[field] ? 1 : 0);
} else {
updateFields.push(`${field} = ?`);
values.push(updates[field]);
}
}
}
if (updateFields.length === 0) {
return res.status(400).json({error: 'Keine gültigen Felder zum Aktualisieren'});
}
const query = `UPDATE projects SET ${updateFields.join(', ')} WHERE id = ?`;
values.push(projectId);
db.run(query, values, function(err) {
if (err) {
console.error('Error updating project:', err);
return res.status(500).json({error: 'Fehler beim Aktualisieren'});
}
res.json({success: true, message: 'Projekt erfolgreich aktualisiert'});
});
});
});
// Delete project (with ownership check)
app.delete('/api/projects/:id', (req, res) => {
if (!req.isAuthenticated()) return res.status(401).send();
const projectId = req.params.id;
// Check ownership
db.get("SELECT owner FROM projects WHERE id = ?", [projectId], (err, row) => {
if (err) return res.status(500).json({error: err.message});
if (!row) return res.status(404).json({error: 'Projekt nicht gefunden'});
if (row.owner !== req.user.username) {
return res.status(403).json({error: 'Keine Berechtigung zum Löschen'});
}
db.run("DELETE FROM projects WHERE id = ?", [projectId], function(err) {
if (err) {
console.error('Error deleting project:', err);
return res.status(500).json({error: 'Fehler beim Löschen'});
}
res.json({success: true, message: 'Projekt erfolgreich gelöscht'});
});
});
});
// Link Discord to Player (Admin/Dev helper)
app.post('/api/link-user', (req, res) => {
if (!req.isAuthenticated()) return res.status(401).send();
const { playerUuid } = req.body;
db.run("UPDATE players SET discordId = ? WHERE uuid = ?", [req.user.id, playerUuid], (err) => {
if (err) return res.status(500).json({error: err});
res.json({success: true});
});
});
// === NPC MANAGEMENT API (Admin Only) ===
// Create NPC Citizen
app.post('/api/admin/npc-citizen', (req, res) => {
if (!req.isAuthenticated()) return res.status(401).send();
// Check if user is admin
if (!req.user.isAdmin) {
return res.status(403).json({error: 'Admin-Berechtigung erforderlich'});
}
const { username, tags, role, organizationId, storyMarkdown } = req.body;
if (!username) {
return res.status(400).json({error: 'NPC-Name erforderlich'});
}
// Generate UUID for NPC
const uuid = 'npc_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
const npcData = {
uuid,
username,
isOnline: 0,
isNpc: 1,
tags: JSON.stringify(tags || []),
stats: JSON.stringify({
playtimeHours: Math.floor(Math.random() * 1000),
level: Math.floor(Math.random() * 50) + 1,
role: role || 'Bürger',
organizationId: organizationId || null
}),
inventory: JSON.stringify([]),
storyMarkdown: storyMarkdown || `# ${username}\n\nEin NPC-Bürger im Obsidian-Tal.`,
discordId: null
};
db.run(`INSERT INTO players (uuid, username, isOnline, isNpc, tags, stats, inventory, storyMarkdown, discordId)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[npcData.uuid, npcData.username, npcData.isOnline, npcData.isNpc, npcData.tags,
npcData.stats, npcData.inventory, npcData.storyMarkdown, npcData.discordId],
function(err) {
if (err) {
console.error('Error creating NPC citizen:', err);
return res.status(500).json({error: 'Fehler beim Erstellen des NPC-Bürgers'});
}
res.json({
success: true,
npc: { ...npcData, tags: JSON.parse(npcData.tags), stats: JSON.parse(npcData.stats) },
message: 'NPC-Bürger erfolgreich erstellt'
});
}
);
});
// Create NPC Company
app.post('/api/admin/npc-company', (req, res) => {
if (!req.isAuthenticated()) return res.status(401).send();
// Check if user is admin
if (!req.user.isAdmin) {
return res.status(403).json({error: 'Admin-Berechtigung erforderlich'});
}
const { title, description, category, owner, associatedOrgId, shopCatalog } = req.body;
if (!title || !owner) {
return res.status(400).json({error: 'Titel und NPC-Eigentümer erforderlich'});
}
// Generate unique ID
const projectId = 'npc_proj_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
db.run(`INSERT INTO projects
(id, title, description, category, status, progress, owner, employees, hiring, foundedDate, associatedOrgId, bannerUrl, shopCatalog, gallery)
VALUES (?, ?, ?, ?, 'active', ?, ?, '[]', 0, ?, ?, '', ?, '[]')`,
[projectId, title, description || `${title} - Eine NPC-Firma im Obsidian-Tal.`, category || 'Enterprise',
Math.floor(Math.random() * 100), owner, new Date().toISOString().split('T')[0],
associatedOrgId || null, JSON.stringify(shopCatalog || [])],
function(err) {
if (err) {
console.error('Error creating NPC company:', err);
return res.status(500).json({error: 'Fehler beim Erstellen der NPC-Firma'});
}
res.json({
success: true,
projectId: projectId,
message: 'NPC-Firma erfolgreich erstellt'
});
}
);
});
// Grant admin rights to a player (Admin only)
app.post('/api/admin/grant-admin/:uuid', (req, res) => {
if (!req.isAuthenticated()) return res.status(401).send();
// Check if user is admin
if (!req.user.isAdmin) {
return res.status(403).json({error: 'Admin-Berechtigung erforderlich'});
}
const playerUuid = req.params.uuid;
db.run("UPDATE players SET isAdmin = 1 WHERE uuid = ?", [playerUuid], function(err) {
if (err) {
console.error('Error granting admin rights:', err);
return res.status(500).json({error: 'Fehler beim Vergeben der Admin-Rechte'});
}
if (this.changes === 0) {
return res.status(404).json({error: 'Spieler nicht gefunden'});
}
res.json({success: true, message: 'Admin-Rechte erfolgreich vergeben'});
});
});
// Revoke admin rights from a player (Admin only)
app.post('/api/admin/revoke-admin/:uuid', (req, res) => {
if (!req.isAuthenticated()) return res.status(401).send();
// Check if user is admin
if (!req.user.isAdmin) {
return res.status(403).json({error: 'Admin-Berechtigung erforderlich'});
}
const playerUuid = req.params.uuid;
// Prevent revoking own admin rights
if (req.user.linkedPlayerUuid === playerUuid) {
return res.status(400).json({error: 'Sie können Ihre eigenen Admin-Rechte nicht entziehen'});
}
db.run("UPDATE players SET isAdmin = 0 WHERE uuid = ?", [playerUuid], function(err) {
if (err) {
console.error('Error revoking admin rights:', err);
return res.status(500).json({error: 'Fehler beim Entziehen der Admin-Rechte'});
}
if (this.changes === 0) {
return res.status(404).json({error: 'Spieler nicht gefunden'});
}
res.json({success: true, message: 'Admin-Rechte erfolgreich entzogen'});
});
});
// Get all NPCs (for admin overview)
app.get('/api/admin/npcs', (req, res) => {
if (!req.isAuthenticated()) return res.status(401).send();
// Check if user is admin
if (!req.user.isAdmin) {
return res.status(403).json({error: 'Admin-Berechtigung erforderlich'});
}
// First get all players with isNpc flag
db.all("SELECT * FROM players WHERE isNpc = 1", (err, npcPlayers) => {
if (err) return res.status(500).json({error: err.message});
// Get all projects where owner is an NPC (starts with npc_)
db.all("SELECT * FROM projects WHERE owner LIKE 'npc_%'", (err, npcOwnedProjects) => {
if (err) return res.status(500).json({error: err.message});
// Also get all projects with NPC-like IDs (npc_proj_ prefix)
db.all("SELECT * FROM projects WHERE id LIKE 'npc_proj_%'", (err, npcCreatedProjects) => {
if (err) return res.status(500).json({error: err.message});
// Combine and deduplicate projects
const allNpcProjects = [...npcOwnedProjects, ...npcCreatedProjects];
const uniqueProjects = allNpcProjects.filter((project, index, self) =>
index === self.findIndex(p => p.id === project.id)
);
console.log('NPC Debug Info:');
console.log('NPC Players found:', npcPlayers.length);
console.log('NPC Owned Projects:', npcOwnedProjects.length);
console.log('NPC Created Projects:', npcCreatedProjects.length);
console.log('Total Unique Projects:', uniqueProjects.length);
// Parse JSON fields for players
const parsedPlayers = npcPlayers.map(r => ({
...r,
tags: JSON.parse(r.tags || '[]'),
stats: JSON.parse(r.stats || '{}'),
inventory: JSON.parse(r.inventory || '[]'),
isOnline: !!r.isOnline,
isNpc: !!r.isNpc
}));
// Parse JSON fields for projects
const parsedProjects = uniqueProjects.map(r => ({
...r,
employees: JSON.parse(r.employees || '[]'),
shopCatalog: JSON.parse(r.shopCatalog || '[]'),
gallery: JSON.parse(r.gallery || '[]'),
hiring: !!r.hiring
}));
res.json({
citizens: parsedPlayers,
companies: parsedProjects,
debug: {
npcPlayersCount: npcPlayers.length,
npcOwnedProjectsCount: npcOwnedProjects.length,
npcCreatedProjectsCount: npcCreatedProjects.length,
totalUniqueProjects: uniqueProjects.length
}
});
});
});
});
});
// === SHOP MANAGEMENT API ===
// Get shop items for a project
app.get('/api/projects/:projectId/shop', (req, res) => {
const projectId = req.params.projectId;
db.get("SELECT shopCatalog FROM projects WHERE id = ?", [projectId], (err, row) => {
if (err) return res.status(500).json({error: err.message});
if (!row) return res.status(404).json({error: 'Projekt nicht gefunden'});
try {
const shopCatalog = JSON.parse(row.shopCatalog || '[]');
res.json(shopCatalog);
} catch (e) {
res.status(500).json({error: 'Fehler beim Laden des Shops'});
}
});
});
// Add shop item
app.post('/api/projects/:projectId/shop', (req, res) => {
if (!req.isAuthenticated()) return res.status(401).send();
const projectId = req.params.projectId;
const { name, description, price, currency, stock, type, materialsRequired } = req.body;
// Check ownership
db.get("SELECT owner, shopCatalog FROM projects WHERE id = ?", [projectId], (err, row) => {
if (err) return res.status(500).json({error: err.message});
if (!row) return res.status(404).json({error: 'Projekt nicht gefunden'});
if (row.owner !== req.user.username && req.user.username !== 'admin') {
return res.status(403).json({error: 'Keine Berechtigung'});
}
try {
const shopCatalog = JSON.parse(row.shopCatalog || '[]');
const newItem = {
id: `item_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
name,
description,
price: parseFloat(price),
currency: currency || 'Gold',
stock: parseInt(stock) || 0,
type: type || 'item',
materialsRequired: materialsRequired || null
};
shopCatalog.push(newItem);
db.run("UPDATE projects SET shopCatalog = ? WHERE id = ?", [JSON.stringify(shopCatalog), projectId], function(err) {
if (err) {
console.error('Error adding shop item:', err);
return res.status(500).json({error: 'Fehler beim Hinzufügen'});
}
res.json({success: true, item: newItem});
});
} catch (e) {
res.status(500).json({error: 'Fehler beim Verarbeiten der Daten'});
}
});
});
// Update shop item
app.put('/api/projects/:projectId/shop/:itemId', (req, res) => {
if (!req.isAuthenticated()) return res.status(401).send();
const { projectId, itemId } = req.params;
const updates = req.body;
// Check ownership
db.get("SELECT owner, shopCatalog FROM projects WHERE id = ?", [projectId], (err, row) => {
if (err) return res.status(500).json({error: err.message});
if (!row) return res.status(404).json({error: 'Projekt nicht gefunden'});
if (row.owner !== req.user.username && req.user.username !== 'admin') {
return res.status(403).json({error: 'Keine Berechtigung'});
}
try {
const shopCatalog = JSON.parse(row.shopCatalog || '[]');
const itemIndex = shopCatalog.findIndex(item => item.id === itemId);
if (itemIndex === -1) {
return res.status(404).json({error: 'Artikel nicht gefunden'});
}
// Update item
shopCatalog[itemIndex] = { ...shopCatalog[itemIndex], ...updates };
db.run("UPDATE projects SET shopCatalog = ? WHERE id = ?", [JSON.stringify(shopCatalog), projectId], function(err) {
if (err) {
console.error('Error updating shop item:', err);
return res.status(500).json({error: 'Fehler beim Aktualisieren'});
}
res.json({success: true, item: shopCatalog[itemIndex]});
});
} catch (e) {
res.status(500).json({error: 'Fehler beim Verarbeiten der Daten'});
}
});
});
// Delete shop item
app.delete('/api/projects/:projectId/shop/:itemId', (req, res) => {
if (!req.isAuthenticated()) return res.status(401).send();
const { projectId, itemId } = req.params;
// Check ownership
db.get("SELECT owner, shopCatalog FROM projects WHERE id = ?", [projectId], (err, row) => {
if (err) return res.status(500).json({error: err.message});
if (!row) return res.status(404).json({error: 'Projekt nicht gefunden'});
if (row.owner !== req.user.username && req.user.username !== 'admin') {
return res.status(403).json({error: 'Keine Berechtigung'});
}
try {
const shopCatalog = JSON.parse(row.shopCatalog || '[]');
const filteredCatalog = shopCatalog.filter(item => item.id !== itemId);
if (filteredCatalog.length === shopCatalog.length) {
return res.status(404).json({error: 'Artikel nicht gefunden'});
}
db.run("UPDATE projects SET shopCatalog = ? WHERE id = ?", [JSON.stringify(filteredCatalog), projectId], function(err) {
if (err) {
console.error('Error deleting shop item:', err);
return res.status(500).json({error: 'Fehler beim Löschen'});
}
res.json({success: true});
});
} catch (e) {
res.status(500).json({error: 'Fehler beim Verarbeiten der Daten'});
}
});
});
// === EMPLOYEE MANAGEMENT API ===
// Get project employees
app.get('/api/projects/:projectId/employees', (req, res) => {
const projectId = req.params.projectId;
db.get("SELECT employees FROM projects WHERE id = ?", [projectId], (err, row) => {
if (err) return res.status(500).json({error: err.message});
if (!row) return res.status(404).json({error: 'Projekt nicht gefunden'});
try {
const employees = JSON.parse(row.employees || '[]');
res.json(employees);
} catch (e) {
res.status(500).json({error: 'Fehler beim Laden der Mitarbeiter'});
}
});
});
// Add employee
app.post('/api/projects/:projectId/employees', (req, res) => {
if (!req.isAuthenticated()) return res.status(401).send();
const projectId = req.params.projectId;
const { employeeName } = req.body;
if (!employeeName) {
return res.status(400).json({error: 'Mitarbeiter-Name erforderlich'});
}
// Check ownership
db.get("SELECT owner, employees FROM projects WHERE id = ?", [projectId], (err, row) => {
if (err) return res.status(500).json({error: err.message});
if (!row) return res.status(404).json({error: 'Projekt nicht gefunden'});
if (row.owner !== req.user.username && req.user.username !== 'admin') {
return res.status(403).json({error: 'Keine Berechtigung'});
}
try {
const employees = JSON.parse(row.employees || '[]');
if (employees.includes(employeeName)) {
return res.status(400).json({error: 'Mitarbeiter bereits hinzugefügt'});
}
employees.push(employeeName);
db.run("UPDATE projects SET employees = ? WHERE id = ?", [JSON.stringify(employees), projectId], function(err) {
if (err) {
console.error('Error adding employee:', err);
return res.status(500).json({error: 'Fehler beim Hinzufügen'});
}
res.json({success: true, employees});
});
} catch (e) {
res.status(500).json({error: 'Fehler beim Verarbeiten der Daten'});
}
});
});
// Remove employee
app.delete('/api/projects/:projectId/employees/:employeeName', (req, res) => {
if (!req.isAuthenticated()) return res.status(401).send();
const { projectId, employeeName } = req.params;
// Check ownership
db.get("SELECT owner, employees FROM projects WHERE id = ?", [projectId], (err, row) => {
if (err) return res.status(500).json({error: err.message});
if (!row) return res.status(404).json({error: 'Projekt nicht gefunden'});
if (row.owner !== req.user.username && req.user.username !== 'admin') {
return res.status(403).json({error: 'Keine Berechtigung'});
}
try {
const employees = JSON.parse(row.employees || '[]');
const filteredEmployees = employees.filter(emp => emp !== employeeName);
if (filteredEmployees.length === employees.length) {
return res.status(404).json({error: 'Mitarbeiter nicht gefunden'});
}
db.run("UPDATE projects SET employees = ? WHERE id = ?", [JSON.stringify(filteredEmployees), projectId], function(err) {
if (err) {
console.error('Error removing employee:', err);
return res.status(500).json({error: 'Fehler beim Entfernen'});
}
res.json({success: true, employees: filteredEmployees});
});
} catch (e) {
res.status(500).json({error: 'Fehler beim Verarbeiten der Daten'});
}
});
});
// === GALLERY MANAGEMENT API ===
// Get project gallery
app.get('/api/projects/:projectId/gallery', (req, res) => {
const projectId = req.params.projectId;
db.get("SELECT gallery FROM projects WHERE id = ?", [projectId], (err, row) => {
if (err) return res.status(500).json({error: err.message});
if (!row) return res.status(404).json({error: 'Projekt nicht gefunden'});
try {
const gallery = JSON.parse(row.gallery || '[]');
res.json(gallery);
} catch (e) {
res.status(500).json({error: 'Fehler beim Laden der Galerie'});
}
});
});
// Add gallery image (for now just URL-based)
app.post('/api/projects/:projectId/gallery', (req, res) => {
if (!req.isAuthenticated()) return res.status(401).send();
const projectId = req.params.projectId;
const { imageUrl } = req.body;
if (!imageUrl) {
return res.status(400).json({error: 'Bild-URL erforderlich'});
}
// Check ownership
db.get("SELECT owner, gallery FROM projects WHERE id = ?", [projectId], (err, row) => {
if (err) return res.status(500).json({error: err.message});
if (!row) return res.status(404).json({error: 'Projekt nicht gefunden'});
if (row.owner !== req.user.username && req.user.username !== 'admin') {
return res.status(403).json({error: 'Keine Berechtigung'});
}
try {
const gallery = JSON.parse(row.gallery || '[]');
gallery.push(imageUrl);
db.run("UPDATE projects SET gallery = ? WHERE id = ?", [JSON.stringify(gallery), projectId], function(err) {
if (err) {
console.error('Error adding gallery image:', err);
return res.status(500).json({error: 'Fehler beim Hinzufügen'});
}
res.json({success: true, gallery});
});
} catch (e) {
res.status(500).json({error: 'Fehler beim Verarbeiten der Daten'});
}
});
});
// Delete gallery image
app.delete('/api/projects/:projectId/gallery/:imageIndex', (req, res) => {
if (!req.isAuthenticated()) return res.status(401).send();
const { projectId, imageIndex } = req.params;
const index = parseInt(imageIndex);
// Check ownership
db.get("SELECT owner, gallery FROM projects WHERE id = ?", [projectId], (err, row) => {
if (err) return res.status(500).json({error: err.message});
if (!row) return res.status(404).json({error: 'Projekt nicht gefunden'});
if (row.owner !== req.user.username && req.user.username !== 'admin') {
return res.status(403).json({error: 'Keine Berechtigung'});
}
try {
const gallery = JSON.parse(row.gallery || '[]');
if (index < 0 || index >= gallery.length) {
return res.status(404).json({error: 'Bild nicht gefunden'});
}
gallery.splice(index, 1);
db.run("UPDATE projects SET gallery = ? WHERE id = ?", [JSON.stringify(gallery), projectId], function(err) {
if (err) {
console.error('Error deleting gallery image:', err);
return res.status(500).json({error: 'Fehler beim Löschen'});
}
res.json({success: true, gallery});
});
} catch (e) {
res.status(500).json({error: 'Fehler beim Verarbeiten der Daten'});
}
});
});
app.listen(PORT, () => {
console.log(`Backend running on http://localhost:${PORT}`);
});

View File

@@ -0,0 +1,230 @@
import React, { useState, useEffect } from 'react';
import { Icons } from './IconSet';
interface BannerManagementModalProps {
isOpen: boolean;
onClose: () => void;
projectId: string;
currentBannerUrl: string;
onUpdate: () => void;
}
const BannerManagementModal: React.FC<BannerManagementModalProps> = ({
isOpen,
onClose,
projectId,
currentBannerUrl,
onUpdate
}) => {
const [bannerUrl, setBannerUrl] = useState(currentBannerUrl);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
useEffect(() => {
if (isOpen) {
setBannerUrl(currentBannerUrl);
setError(null);
}
}, [isOpen, currentBannerUrl]);
const updateBanner = async () => {
if (!bannerUrl.trim()) {
setError('Banner-URL ist erforderlich');
return;
}
try {
setLoading(true);
setError(null);
const response = await fetch(`https://vollidioten.ceraticsoft.de/api/projects/${projectId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ bannerUrl: bannerUrl.trim() })
});
if (response.ok) {
onUpdate();
onClose();
} else {
const errorData = await response.json();
setError(errorData.error || 'Fehler beim Aktualisieren des Banners');
}
} catch (err) {
console.error('Error updating banner:', err);
setError('Netzwerkfehler');
} finally {
setLoading(false);
}
};
const handleImageLoad = () => {
setPreviewLoading(false);
};
const handleImageError = () => {
setPreviewLoading(false);
setError('Bild konnte nicht geladen werden');
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm animate-in fade-in duration-200">
<div className="bg-surface border border-border rounded-xl w-full max-w-2xl shadow-2xl flex flex-col max-h-[90vh]">
<div className="p-4 border-b border-border flex justify-between items-center bg-surfaceHighlight/20">
<h3 className="font-bold text-textMain flex items-center gap-2">
<Icons.Layers className="w-5 h-5" />
Banner bearbeiten
</h3>
<button onClick={onClose} className="text-textMuted hover:text-white transition-colors text-xl leading-none">&times;</button>
</div>
<div className="p-6 flex-1 overflow-y-auto">
{error && (
<div className="bg-red-500/10 border border-red-500/20 rounded-lg p-4 mb-6">
<p className="text-red-400">{error}</p>
</div>
)}
{/* Current Banner Preview */}
<div className="mb-6">
<h4 className="font-semibold text-textMain mb-3">Aktuelles Banner</h4>
<div className="relative h-32 rounded-lg overflow-hidden border border-border bg-surfaceHighlight/30">
{currentBannerUrl ? (
<>
{previewLoading && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-accentInfo"></div>
</div>
)}
<img
src={currentBannerUrl}
alt="Current banner"
className="w-full h-full object-cover"
onLoad={handleImageLoad}
onError={handleImageError}
/>
</>
) : (
<div className="w-full h-full flex items-center justify-center text-textMuted">
<Icons.Layers className="w-8 h-8" />
</div>
)}
</div>
</div>
{/* Banner URL Input */}
<div className="mb-6">
<h4 className="font-semibold text-textMain mb-3">Neue Banner-URL</h4>
<input
type="url"
value={bannerUrl}
onChange={(e) => setBannerUrl(e.target.value)}
placeholder="https://example.com/banner-image.jpg"
className="w-full bg-[#0b0b0d] border border-border rounded p-3 text-sm"
/>
<p className="text-xs text-textMuted mt-2">
Geben Sie eine direkte URL zu einem Bild ein. Empfohlene Größe: 1200x400 Pixel oder größer.
</p>
</div>
{/* Preview */}
{bannerUrl && bannerUrl !== currentBannerUrl && (
<div className="mb-6">
<h4 className="font-semibold text-textMain mb-3">Vorschau</h4>
<div className="relative h-32 rounded-lg overflow-hidden border border-border bg-surfaceHighlight/30">
<img
src={bannerUrl}
alt="Banner preview"
className="w-full h-full object-cover"
onError={() => setError('Vorschau-Bild konnte nicht geladen werden')}
/>
</div>
</div>
)}
{/* Common Banner Suggestions */}
<div className="mb-6">
<h4 className="font-semibold text-textMain mb-3">Beispiele</h4>
<div className="grid grid-cols-2 gap-2 text-xs">
<button
onClick={() => setBannerUrl('https://images.unsplash.com/photo-1449824913935-59a10b8d2000?q=80&w=1200&auto=format&fit=crop')}
className="p-2 bg-surfaceHighlight/50 rounded hover:bg-surfaceHighlight transition-colors text-left"
>
<div className="font-medium">Berglandschaft</div>
<div className="text-textMuted truncate">Unsplash</div>
</button>
<button
onClick={() => setBannerUrl('https://images.unsplash.com/photo-1506905925346-21bda4d32df4?q=80&w=1200&auto=format&fit=crop')}
className="p-2 bg-surfaceHighlight/50 rounded hover:bg-surfaceHighlight transition-colors text-left"
>
<div className="font-medium">Stadt bei Nacht</div>
<div className="text-textMuted truncate">Unsplash</div>
</button>
<button
onClick={() => setBannerUrl('https://images.unsplash.com/photo-1542601906990-b4d3fb778b09?q=80&w=1200&auto=format&fit=crop')}
className="p-2 bg-surfaceHighlight/50 rounded hover:bg-surfaceHighlight transition-colors text-left"
>
<div className="font-medium">Architektur</div>
<div className="text-textMuted truncate">Unsplash</div>
</button>
<button
onClick={() => setBannerUrl('https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?q=80&w=1200&auto=format&fit=crop')}
className="p-2 bg-surfaceHighlight/50 rounded hover:bg-surfaceHighlight transition-colors text-left"
>
<div className="font-medium">Abstrakt</div>
<div className="text-textMuted truncate">Unsplash</div>
</button>
</div>
</div>
{/* Info */}
<div className="bg-blue-500/10 border border-blue-500/20 rounded-lg p-4">
<div className="flex items-start gap-3">
<Icons.Terminal className="w-5 h-5 text-blue-400 mt-0.5" />
<div>
<h4 className="text-sm font-medium text-blue-400 mb-1">Banner-Empfehlungen</h4>
<ul className="text-xs text-blue-300 space-y-1">
<li> Verwenden Sie hochwertige Bilder mit 16:9 Seitenverhältnis</li>
<li> Stellen Sie sicher, dass die Bilder öffentlich zugänglich sind</li>
<li> Dunklere Bilder funktionieren oft besser mit dem Text-Overlay</li>
</ul>
</div>
</div>
</div>
</div>
<div className="p-4 border-t border-border flex justify-end gap-3">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-textMuted hover:text-white transition-colors"
>
Abbrechen
</button>
<button
onClick={updateBanner}
disabled={loading || !bannerUrl.trim() || bannerUrl === currentBannerUrl}
className="px-6 py-2 text-sm font-medium bg-orange-500 hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded transition-colors flex items-center gap-2"
>
{loading ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
Aktualisiere...
</>
) : (
<>
<Icons.Layers className="w-4 h-4" />
Banner aktualisieren
</>
)}
</button>
</div>
</div>
</div>
);
};
export default BannerManagementModal;

View File

@@ -0,0 +1,218 @@
import React, { useState } from 'react';
import { Project } from '../types';
import { Icons } from './IconSet';
interface CreateProjectModalProps {
isOpen: boolean;
onClose: () => void;
onCreate: (projectData: {
title: string;
description: string;
category: Project['category'];
}) => Promise<void>;
linkedPlayerName?: string | null;
}
const CreateProjectModal: React.FC<CreateProjectModalProps> = ({ isOpen, onClose, onCreate, linkedPlayerName }) => {
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [category, setCategory] = useState<Project['category']>('Enterprise');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const categories = [
{ value: 'Enterprise', label: 'Unternehmen', description: 'Firmen, Läden, Dienstleistungen' },
{ value: 'Story Arc', label: 'Story Arc', description: 'Rollenspiel-Handlung, Quest' },
{ value: 'Faction', label: 'Fraktion', description: 'Gruppe, Gilde, Organisation' },
{ value: 'Black Market', label: 'Schwarzmarkt', description: 'Illegale Geschäfte (Vorsicht!)' },
];
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim() || !description.trim()) {
setError('Titel und Beschreibung sind erforderlich');
return;
}
try {
setLoading(true);
setError(null);
await onCreate({ title: title.trim(), description: description.trim(), category });
onClose();
// Reset form
setTitle('');
setDescription('');
setCategory('Enterprise');
} catch (err) {
console.error('Error creating project:', err);
setError('Fehler beim Erstellen des Projekts');
} finally {
setLoading(false);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm animate-in fade-in duration-200">
<div className="bg-surface border border-border rounded-xl w-full max-w-2xl shadow-2xl flex flex-col max-h-[90vh]">
<div className="p-4 border-b border-border flex justify-between items-center bg-surfaceHighlight/20">
<h3 className="font-bold text-textMain flex items-center gap-2">
<Icons.ShoppingBag className="w-5 h-5" />
Neues Unternehmen erstellen
</h3>
<button onClick={onClose} className="text-textMuted hover:text-white transition-colors text-xl leading-none">&times;</button>
</div>
<form onSubmit={handleSubmit} className="p-6 flex-1 overflow-y-auto">
<div className="space-y-6">
{/* Title */}
<div>
<label className="block text-sm font-medium text-textMain mb-2">
Name des Unternehmens *
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full bg-[#0b0b0d] border border-border rounded-lg p-3 text-sm text-gray-300 focus:border-accentInfo focus:outline-none"
placeholder="z.B. DrKButz Architektur GmbH"
required
maxLength={100}
/>
</div>
{/* Category */}
<div>
<label className="block text-sm font-medium text-textMain mb-3">
Kategorie *
</label>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{categories.map((cat) => (
<label
key={cat.value}
className={`p-3 border rounded-lg cursor-pointer transition-all ${
category === cat.value
? 'border-accentInfo bg-accentInfo/10'
: 'border-border hover:border-accentInfo/50'
}`}
>
<input
type="radio"
name="category"
value={cat.value}
checked={category === cat.value}
onChange={(e) => setCategory(e.target.value as Project['category'])}
className="sr-only"
/>
<div className="flex items-start gap-3">
<div className={`w-4 h-4 rounded-full border-2 mt-0.5 ${
category === cat.value
? 'border-accentInfo bg-accentInfo'
: 'border-textMuted'
}`}>
{category === cat.value && (
<div className="w-full h-full rounded-full bg-white scale-50"></div>
)}
</div>
<div>
<div className="font-medium text-textMain">{cat.label}</div>
<div className="text-xs text-textMuted mt-1">{cat.description}</div>
</div>
</div>
</label>
))}
</div>
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-textMain mb-2">
Beschreibung *
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full h-32 bg-[#0b0b0d] border border-border rounded-lg p-3 text-sm text-gray-300 focus:border-accentInfo focus:outline-none resize-none"
placeholder="Beschreiben Sie Ihr Unternehmen, seine Dienstleistungen und Ziele..."
required
maxLength={1000}
/>
<div className="text-xs text-textMuted mt-1">
{description.length}/1000 Zeichen
</div>
</div>
{/* Linked Player Info */}
{linkedPlayerName && (
<div className="bg-green-500/10 border border-green-500/20 rounded-lg p-4">
<div className="flex items-start gap-3">
<Icons.Users className="w-5 h-5 text-green-400 mt-0.5" />
<div>
<h4 className="text-sm font-medium text-green-400 mb-1">Projekt-Ersteller</h4>
<p className="text-sm text-green-300">
Dieses Unternehmen wird im Namen von <strong>{linkedPlayerName}</strong> erstellt.
</p>
</div>
</div>
</div>
)}
{/* Error */}
{error && (
<div className="bg-red-500/10 border border-red-500/20 rounded-lg p-4">
<p className="text-red-400 text-sm">{error}</p>
</div>
)}
{/* Info */}
<div className="bg-blue-500/10 border border-blue-500/20 rounded-lg p-4">
<div className="flex items-start gap-3">
<Icons.Crown className="w-5 h-5 text-blue-400 mt-0.5" />
<div>
<h4 className="text-sm font-medium text-blue-400 mb-1">Wichtige Hinweise</h4>
<ul className="text-xs text-blue-300 space-y-1">
<li> Sie sind automatisch der Eigentümer und können das Unternehmen jederzeit bearbeiten</li>
<li> Das Unternehmen startet mit dem Status "aktiv"</li>
<li> Sie können später Mitarbeiter hinzufügen und einen Shop einrichten</li>
</ul>
</div>
</div>
</div>
</div>
<div className="flex justify-end gap-3 mt-8 pt-6 border-t border-border">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-textMuted hover:text-white transition-colors"
disabled={loading}
>
Abbrechen
</button>
<button
type="submit"
disabled={loading || !title.trim() || !description.trim()}
className="px-6 py-2 text-sm font-medium bg-accentInfo hover:bg-accentInfo/90 text-white rounded transition-colors shadow-lg shadow-accentInfo/20 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{loading ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
Erstelle...
</>
) : (
<>
<Icons.ShoppingBag className="w-4 h-4" />
Unternehmen erstellen
</>
)}
</button>
</div>
</form>
</div>
</div>
);
};
export default CreateProjectModal;

View File

@@ -0,0 +1,131 @@
import React, { useState } from 'react';
import { Icons } from './IconSet';
interface DeleteProjectModalProps {
isOpen: boolean;
onClose: () => void;
projectId: string;
projectTitle: string;
onDelete: () => Promise<void>;
}
const DeleteProjectModal: React.FC<DeleteProjectModalProps> = ({
isOpen,
onClose,
projectId,
projectTitle,
onDelete
}) => {
const [confirmText, setConfirmText] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const requiredText = `LÖSCHE ${projectTitle}`;
const handleDelete = async () => {
if (confirmText !== requiredText) {
setError('Bitte geben Sie den korrekten Text ein');
return;
}
try {
setLoading(true);
setError(null);
await onDelete();
onClose();
} catch (err) {
console.error('Error deleting project:', err);
setError('Fehler beim Löschen des Projekts');
} finally {
setLoading(false);
}
};
const resetAndClose = () => {
setConfirmText('');
setError(null);
onClose();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm animate-in fade-in duration-200">
<div className="bg-surface border border-red-500/20 rounded-xl w-full max-w-md shadow-2xl">
<div className="p-4 border-b border-red-500/20 bg-red-500/5">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-red-500/20 rounded-full flex items-center justify-center">
<Icons.Shield className="w-5 h-5 text-red-400" />
</div>
<div>
<h3 className="font-bold text-red-400">Projekt löschen</h3>
<p className="text-sm text-red-300">Diese Aktion kann nicht rückgängig gemacht werden</p>
</div>
</div>
</div>
<div className="p-6">
<div className="mb-6">
<h4 className="font-semibold text-textMain mb-2">
Sind Sie absolut sicher?
</h4>
<p className="text-sm text-textMuted mb-4">
Das Löschen von <strong>"{projectTitle}"</strong> wird alle zugehörigen Daten unwiderruflich entfernen:
</p>
<ul className="text-sm text-textMuted space-y-1 mb-4">
<li> Alle Shop-Artikel und Dienstleistungen</li>
<li> Die komplette Bildergalerie</li>
<li> Alle Mitarbeiter-Zuweisungen</li>
<li> Projekt-Beschreibung und Einstellungen</li>
</ul>
</div>
<div className="bg-red-500/10 border border-red-500/20 rounded-lg p-4 mb-6">
<label className="block text-sm font-medium text-red-400 mb-2">
Geben Sie <strong>{requiredText}</strong> ein, um zu bestätigen:
</label>
<input
type="text"
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
className="w-full bg-[#0b0b0d] border border-red-500/30 rounded p-2 text-sm font-mono"
placeholder={requiredText}
/>
{error && (
<p className="text-red-400 text-xs mt-2">{error}</p>
)}
</div>
<div className="flex gap-3">
<button
onClick={resetAndClose}
disabled={loading}
className="flex-1 px-4 py-2 text-sm font-medium text-textMuted hover:text-white transition-colors border border-border rounded"
>
Abbrechen
</button>
<button
onClick={handleDelete}
disabled={confirmText !== requiredText || loading}
className="flex-1 bg-red-500 hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed text-white px-4 py-2 rounded text-sm font-medium transition-colors flex items-center justify-center gap-2"
>
{loading ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
Lösche...
</>
) : (
<>
<Icons.Shield className="w-4 h-4" />
Endgültig löschen
</>
)}
</button>
</div>
</div>
</div>
</div>
);
};
export default DeleteProjectModal;

76
components/EditModal.tsx Normal file
View File

@@ -0,0 +1,76 @@
import React, { useState, useEffect } from 'react';
import MarkdownEditor from './MarkdownEditor';
interface EditModalProps {
isOpen: boolean;
title: string;
initialValue: string;
multiline?: boolean;
markdown?: boolean; // New prop for markdown editor
onClose: () => void;
onSave: (value: string) => void;
}
const EditModal: React.FC<EditModalProps> = ({ isOpen, title, initialValue, multiline = false, markdown = false, onClose, onSave }) => {
const [value, setValue] = useState(initialValue);
useEffect(() => {
setValue(initialValue);
}, [initialValue]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm animate-in fade-in duration-200">
<div className="bg-surface border border-border rounded-xl w-full max-w-2xl shadow-2xl flex flex-col max-h-[90vh]">
<div className="p-4 border-b border-border flex justify-between items-center bg-surfaceHighlight/20">
<h3 className="font-bold text-textMain">{title}</h3>
<button onClick={onClose} className="text-textMuted hover:text-white transition-colors text-xl leading-none">&times;</button>
</div>
<div className="p-4 flex-1 overflow-hidden flex flex-col">
{multiline ? (
markdown ? (
<MarkdownEditor
value={value}
onChange={setValue}
className="flex-1"
/>
) : (
<textarea
className="w-full h-64 md:h-96 bg-[#0b0b0d] border border-border rounded-lg p-3 text-sm font-mono text-gray-300 focus:border-accentInfo focus:outline-none resize-none"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
)
) : (
<input
type="text"
className="w-full bg-[#0b0b0d] border border-border rounded-lg p-3 text-sm text-gray-300 focus:border-accentInfo focus:outline-none"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
)}
{multiline && !markdown && <p className="text-xs text-textMuted mt-2">Unterstützt Markdown Formatierung.</p>}
</div>
<div className="p-4 border-t border-border flex justify-end gap-3 bg-surfaceHighlight/10">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-textMuted hover:text-white transition-colors"
>
Abbrechen
</button>
<button
onClick={() => { onSave(value); onClose(); }}
className="px-4 py-2 text-sm font-medium bg-accentInfo hover:bg-accentInfo/90 text-white rounded transition-colors shadow-lg shadow-accentInfo/20"
>
Speichern
</button>
</div>
</div>
</div>
);
};
export default EditModal;

View File

@@ -0,0 +1,203 @@
import React, { useState, useEffect } from 'react';
import { Icons } from './IconSet';
interface EmployeeManagementModalProps {
isOpen: boolean;
onClose: () => void;
projectId: string;
onUpdate: () => void;
}
const EmployeeManagementModal: React.FC<EmployeeManagementModalProps> = ({
isOpen,
onClose,
projectId,
onUpdate
}) => {
const [employees, setEmployees] = useState<string[]>([]);
const [availablePlayers, setAvailablePlayers] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [selectedPlayer, setSelectedPlayer] = useState('');
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (isOpen && projectId) {
loadEmployees();
loadAvailablePlayers();
}
}, [isOpen, projectId]);
const loadEmployees = async () => {
try {
const response = await fetch(`https://vollidioten.ceraticsoft.de/api/projects/${projectId}/employees`);
if (response.ok) {
const data = await response.json();
setEmployees(data);
}
} catch (err) {
console.error('Error loading employees:', err);
}
};
const loadAvailablePlayers = async () => {
try {
const response = await fetch('https://vollidioten.ceraticsoft.de/api/players');
if (response.ok) {
const players = await response.json();
// Filter out players who are already employees
const available = players.filter((player: any) =>
!employees.includes(player.username)
);
setAvailablePlayers(available);
}
} catch (err) {
console.error('Error loading available players:', err);
}
};
const addEmployee = async () => {
if (!selectedPlayer) return;
try {
setLoading(true);
setError(null);
const response = await fetch(`https://vollidioten.ceraticsoft.de/api/projects/${projectId}/employees`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ employeeName: selectedPlayer })
});
if (response.ok) {
await loadEmployees();
setSelectedPlayer('');
onUpdate();
} else {
const errorData = await response.json();
setError(errorData.error || 'Fehler beim Hinzufügen');
}
} catch (err) {
console.error('Error adding employee:', err);
setError('Netzwerkfehler');
} finally {
setLoading(false);
}
};
const removeEmployee = async (employeeName: string) => {
try {
setLoading(true);
const response = await fetch(`https://vollidioten.ceraticsoft.de/api/projects/${projectId}/employees/${employeeName}`, {
method: 'DELETE',
credentials: 'include'
});
if (response.ok) {
await loadEmployees();
onUpdate();
} else {
setError('Fehler beim Entfernen');
}
} catch (err) {
console.error('Error removing employee:', err);
setError('Netzwerkfehler');
} finally {
setLoading(false);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm animate-in fade-in duration-200">
<div className="bg-surface border border-border rounded-xl w-full max-w-2xl shadow-2xl flex flex-col max-h-[90vh]">
<div className="p-4 border-b border-border flex justify-between items-center bg-surfaceHighlight/20">
<h3 className="font-bold text-textMain flex items-center gap-2">
<Icons.Users className="w-5 h-5" />
Mitarbeiter verwalten
</h3>
<button onClick={onClose} className="text-textMuted hover:text-white transition-colors text-xl leading-none">&times;</button>
</div>
<div className="p-6 flex-1 overflow-y-auto">
{error && (
<div className="bg-red-500/10 border border-red-500/20 rounded-lg p-4 mb-6">
<p className="text-red-400">{error}</p>
</div>
)}
{/* Add Employee */}
<div className="bg-surfaceHighlight/30 border border-border rounded-lg p-4 mb-6">
<h4 className="font-semibold text-textMain mb-4">Mitarbeiter hinzufügen</h4>
<div className="flex gap-2">
<select
value={selectedPlayer}
onChange={(e) => setSelectedPlayer(e.target.value)}
className="flex-1 bg-[#0b0b0d] border border-border rounded p-2 text-sm"
>
<option value="">Spieler auswählen...</option>
{availablePlayers.map((player) => (
<option key={player.uuid} value={player.username}>
{player.username}
</option>
))}
</select>
<button
onClick={addEmployee}
disabled={!selectedPlayer || loading}
className="bg-accentInfo hover:bg-accentInfo/90 disabled:opacity-50 text-white px-4 py-2 rounded text-sm font-medium"
>
Hinzufügen
</button>
</div>
</div>
{/* Current Employees */}
<div className="mb-4">
<h4 className="font-semibold text-textMain mb-4">
Aktuelle Mitarbeiter ({employees.length})
</h4>
{employees.length === 0 ? (
<div className="text-center py-8 text-textMuted">
<p>Noch keine Mitarbeiter hinzugefügt.</p>
</div>
) : (
<div className="space-y-3">
{employees.map((employee) => (
<div key={employee} className="flex items-center justify-between bg-surfaceHighlight/30 border border-border rounded-lg p-3">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-gradient-to-br from-gray-700 to-gray-900 rounded flex items-center justify-center text-xs font-bold">
{employee.charAt(0).toUpperCase()}
</div>
<span className="font-medium text-textMain">{employee}</span>
</div>
<button
onClick={() => removeEmployee(employee)}
disabled={loading}
className="text-red-400 hover:text-red-300 text-sm px-2 py-1 rounded hover:bg-red-500/10 transition-colors"
>
Entfernen
</button>
</div>
))}
</div>
)}
</div>
</div>
<div className="p-4 border-t border-border flex justify-end">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-textMuted hover:text-white transition-colors"
>
Schließen
</button>
</div>
</div>
</div>
);
};
export default EmployeeManagementModal;

View File

@@ -0,0 +1,205 @@
import React, { useState, useEffect } from 'react';
import { Icons } from './IconSet';
interface GalleryManagementModalProps {
isOpen: boolean;
onClose: () => void;
projectId: string;
onUpdate: () => void;
}
const GalleryManagementModal: React.FC<GalleryManagementModalProps> = ({
isOpen,
onClose,
projectId,
onUpdate
}) => {
const [images, setImages] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [imageUrl, setImageUrl] = useState('');
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (isOpen && projectId) {
loadGallery();
}
}, [isOpen, projectId]);
const loadGallery = async () => {
try {
setLoading(true);
const response = await fetch(`https://vollidioten.ceraticsoft.de/api/projects/${projectId}/gallery`);
if (response.ok) {
const data = await response.json();
setImages(data);
} else {
setError('Fehler beim Laden der Galerie');
}
} catch (err) {
console.error('Error loading gallery:', err);
setError('Netzwerkfehler');
} finally {
setLoading(false);
}
};
const addImage = async () => {
if (!imageUrl.trim()) return;
try {
setLoading(true);
setError(null);
const response = await fetch(`https://vollidioten.ceraticsoft.de/api/projects/${projectId}/gallery`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ imageUrl: imageUrl.trim() })
});
if (response.ok) {
await loadGallery();
setImageUrl('');
onUpdate();
} else {
const errorData = await response.json();
setError(errorData.error || 'Fehler beim Hinzufügen');
}
} catch (err) {
console.error('Error adding image:', err);
setError('Netzwerkfehler');
} finally {
setLoading(false);
}
};
const removeImage = async (index: number) => {
try {
setLoading(true);
const response = await fetch(`https://vollidioten.ceraticsoft.de/api/projects/${projectId}/gallery/${index}`, {
method: 'DELETE',
credentials: 'include'
});
if (response.ok) {
await loadGallery();
onUpdate();
} else {
setError('Fehler beim Löschen');
}
} catch (err) {
console.error('Error removing image:', err);
setError('Netzwerkfehler');
} finally {
setLoading(false);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm animate-in fade-in duration-200">
<div className="bg-surface border border-border rounded-xl w-full max-w-4xl shadow-2xl flex flex-col max-h-[90vh]">
<div className="p-4 border-b border-border flex justify-between items-center bg-surfaceHighlight/20">
<h3 className="font-bold text-textMain flex items-center gap-2">
<Icons.Layers className="w-5 h-5" />
Galerie verwalten
</h3>
<button onClick={onClose} className="text-textMuted hover:text-white transition-colors text-xl leading-none">&times;</button>
</div>
<div className="p-6 flex-1 overflow-y-auto">
{error && (
<div className="bg-red-500/10 border border-red-500/20 rounded-lg p-4 mb-6">
<p className="text-red-400">{error}</p>
</div>
)}
{/* Add Image */}
<div className="bg-surfaceHighlight/30 border border-border rounded-lg p-4 mb-6">
<h4 className="font-semibold text-textMain mb-4">Bild hinzufügen</h4>
<div className="flex gap-2">
<input
type="url"
value={imageUrl}
onChange={(e) => setImageUrl(e.target.value)}
placeholder="Bild-URL eingeben..."
className="flex-1 bg-[#0b0b0d] border border-border rounded p-2 text-sm"
/>
<button
onClick={addImage}
disabled={!imageUrl.trim() || loading}
className="bg-accentInfo hover:bg-accentInfo/90 disabled:opacity-50 text-white px-4 py-2 rounded text-sm font-medium"
>
Hinzufügen
</button>
</div>
<p className="text-xs text-textMuted mt-2">
Geben Sie eine direkte URL zu einem Bild ein (z.B. von Imgur, Discord, etc.)
</p>
</div>
{/* Gallery Grid */}
<div className="mb-4">
<h4 className="font-semibold text-textMain mb-4">
Galerie-Bilder ({images.length})
</h4>
{loading ? (
<div className="flex justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-accentInfo"></div>
</div>
) : images.length === 0 ? (
<div className="text-center py-8 text-textMuted">
<p>Noch keine Bilder in der Galerie.</p>
<p className="text-sm mt-2">Fügen Sie Bild-URLs hinzu, um Ihre Galerie zu füllen.</p>
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{images.map((imageUrl, index) => (
<div key={index} className="relative group">
<div className="aspect-square rounded-lg overflow-hidden border border-border bg-surfaceHighlight/30">
<img
src={imageUrl}
alt={`Galerie ${index + 1}`}
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.src = 'https://via.placeholder.com/200x200/374151/6b7280?text=Bild+fehlerhaft';
}}
/>
</div>
{/* Delete Button */}
<button
onClick={() => removeImage(index)}
disabled={loading}
className="absolute top-2 right-2 bg-red-500 hover:bg-red-600 text-white rounded-full w-6 h-6 flex items-center justify-center text-xs opacity-0 group-hover:opacity-100 transition-opacity"
title="Bild entfernen"
>
×
</button>
{/* Overlay on hover */}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors rounded-lg pointer-events-none" />
</div>
))}
</div>
)}
</div>
</div>
<div className="p-4 border-t border-border flex justify-end">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-textMuted hover:text-white transition-colors"
>
Schließen
</button>
</div>
</div>
</div>
);
};
export default GalleryManagementModal;

View File

@@ -43,6 +43,9 @@ export const Icons = {
),
Hammer: ({ className }: { className?: string }) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><path d="m15 12-8.5 8.5c-.83.83-2.17.83-3 0 0 0 0 0 0 0a2.12 2.12 0 0 1 0-3L12 9"/><path d="M17.64 15 22 10.64"/><path d="m20.91 11.7-1.25-1.25c-.6-.6-.93-1.4-.93-2.25V7.86c0-.55-.45-1-1-1H16.4c-.84 0-1.65-.33-2.25-.93L12.9 4.68c-.6-.6-1.4-.93-2.25-.93H4.86c-.55 0-1 .45-1 1v1.36c0 .84.33 1.65.93 2.25L12 15.64"/></svg>
),
Edit: ({ className }: { className?: string }) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
)
};

View File

@@ -1,5 +1,7 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { Icons } from './IconSet';
import { authService } from '../services/AuthService';
import { DiscordUser } from '../types';
interface LayoutProps {
children: React.ReactNode;
@@ -30,6 +32,13 @@ const NavItem = ({
const Layout: React.FC<LayoutProps> = ({ children, activeTab, onNavigate }) => {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [user, setUser] = useState<DiscordUser | null>(null);
useEffect(() => {
// Subscribe to auth changes
const unsubscribe = authService.subscribe(setUser);
return unsubscribe;
}, []);
return (
<div className="min-h-screen flex flex-col font-sans">
@@ -55,6 +64,9 @@ const Layout: React.FC<LayoutProps> = ({ children, activeTab, onNavigate }) => {
<NavItem active={activeTab === 'players'} label="Bürger" onClick={() => onNavigate('players')} />
{/* <NavItem active={activeTab === 'organizations'} label="Organisationen" onClick={() => onNavigate('organizations')} />*/}
<NavItem active={activeTab === 'projects'} label="Unternehmen" onClick={() => onNavigate('projects')} />
{user?.isAdmin && (
<NavItem active={activeTab === 'admin'} label="Admin" onClick={() => onNavigate('admin')} />
)}
</nav>
</div>
@@ -92,8 +104,11 @@ const Layout: React.FC<LayoutProps> = ({ children, activeTab, onNavigate }) => {
<div onClick={() => { onNavigate('players'); setMobileMenuOpen(false); }} className="block py-2 text-textMuted hover:text-textMain">Bürger</div>
<div onClick={() => { onNavigate('organizations'); setMobileMenuOpen(false); }} className="block py-2 text-textMuted hover:text-textMain">Organisationen</div>
<div onClick={() => { onNavigate('projects'); setMobileMenuOpen(false); }} className="block py-2 text-textMuted hover:text-textMain">Unternehmen</div>
{user?.isAdmin && (
<div onClick={() => { onNavigate('admin'); setMobileMenuOpen(false); }} className="block py-2 text-red-400 hover:text-red-300">Admin</div>
)}
<div onClick={() => { onNavigate('datapack'); setMobileMenuOpen(false); }} className="block py-2 text-textMain">Datapack holen</div>
<div onClick={() => { onNavigate('setup'); setMobileMenuOpen(false); }} className="block py-2 text-accentInfo font-mono text-sm border-t border-white/5 pt-4">Admin Setup >_</div>
<div onClick={() => { onNavigate('setup'); setMobileMenuOpen(false); }} className="block py-2 text-accentInfo font-mono text-sm border-t border-white/5 pt-4">{"Admin Setup >_"}</div>
</div>
)}
</header>
@@ -112,12 +127,45 @@ const Layout: React.FC<LayoutProps> = ({ children, activeTab, onNavigate }) => {
<div className="w-4 h-4 bg-textMuted rounded-full"></div>
<p>© 2024 Obsidian Platform</p>
</div>
<div className="flex flex-col md:flex-row items-center gap-8">
{/* Auth Section */}
<div className="flex items-center gap-4 mb-4 md:mb-0">
{user ? (
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<img
src={user.avatarUrl}
alt={user.username}
className="w-6 h-6 rounded-full"
/>
<span className="text-textMain font-medium">{user.username}</span>
</div>
<button
onClick={() => authService.logout()}
className="text-xs text-textMuted hover:text-accentInfo transition-colors"
>
Logout
</button>
</div>
) : (
<button
onClick={() => authService.login()}
className="flex items-center gap-2 text-textMain hover:text-accentInfo transition-colors font-medium"
>
<Icons.Users className="w-4 h-4" />
<span>Discord Login</span>
</button>
)}
</div>
{/* Links */}
<div className="flex gap-8">
<span className="cursor-pointer hover:text-textMain transition-colors">Dokumentation</span>
<span className="cursor-pointer hover:text-textMain transition-colors">Server Status</span>
<span className="cursor-pointer hover:text-textMain transition-colors">Datenschutz</span>
</div>
</div>
</div>
</footer>
</div>
);

View File

@@ -0,0 +1,116 @@
import React, { useState, useRef } from 'react';
import { Icons } from './IconSet';
interface MarkdownEditorProps {
value: string;
onChange: (value: string) => void;
className?: string;
}
const MarkdownEditor: React.FC<MarkdownEditorProps> = ({ value, onChange, className = '' }) => {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const insertText = (before: string, after: string = '', placeholder: string = 'text') => {
const textarea = textareaRef.current;
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selectedText = value.substring(start, end);
const textToInsert = selectedText || placeholder;
const newText = value.substring(0, start) + before + textToInsert + after + value.substring(end);
onChange(newText);
// Set cursor position after the inserted text
setTimeout(() => {
const newCursorPos = start + before.length + textToInsert.length + after.length;
textarea.setSelectionRange(newCursorPos, newCursorPos);
textarea.focus();
}, 0);
};
const formatButtons = [
{
icon: 'H',
label: 'Überschrift',
action: () => insertText('# ', ''),
className: 'text-lg font-bold'
},
{
icon: <Icons.Hammer className="w-3 h-3" />,
label: 'Fett',
action: () => insertText('**', '**', 'fetter Text'),
},
{
icon: <Icons.Tag className="w-3 h-3" />,
label: 'Kursiv',
action: () => insertText('*', '*', 'kursiver Text'),
},
{
icon: <Icons.Scroll className="w-3 h-3" />,
label: 'Liste',
action: () => insertText('- ', ''),
},
{
icon: <Icons.Crown className="w-3 h-3" />,
label: 'Nummerierte Liste',
action: () => insertText('1. ', ''),
},
{
icon: <Icons.Shield className="w-3 h-3" />,
label: 'Zitat',
action: () => insertText('> ', ''),
},
{
icon: '🔗',
label: 'Link',
action: () => insertText('[', '](url)', 'Link-Text'),
},
{
icon: '📷',
label: 'Bild',
action: () => insertText('![', '](url)', 'Alt-Text'),
},
];
return (
<div className={`border border-border rounded-lg overflow-hidden ${className}`}>
{/* Toolbar */}
<div className="bg-surfaceHighlight border-b border-border p-2 flex flex-wrap gap-1">
{formatButtons.map((button, index) => (
<button
key={index}
type="button"
onClick={button.action}
className="p-1.5 hover:bg-white/10 rounded text-textMuted hover:text-textMain transition-colors text-sm flex items-center justify-center min-w-[32px] h-8"
title={button.label}
>
{typeof button.icon === 'string' ? (
<span className={button.className}>{button.icon}</span>
) : (
button.icon
)}
</button>
))}
</div>
{/* Textarea */}
<textarea
ref={textareaRef}
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full h-64 md:h-96 bg-[#0b0b0d] border-0 p-4 text-sm font-mono text-gray-300 focus:outline-none resize-none"
placeholder="Schreibe dein Journal hier... Verwende die Toolbar für Formatierung."
spellCheck={false}
/>
{/* Footer */}
<div className="bg-surfaceHighlight border-t border-border px-4 py-2 text-xs text-textMuted">
Markdown-Formatierung unterstützt. Verwende die Toolbar für schnelle Formatierung.
</div>
</div>
);
};
export default MarkdownEditor;

View File

@@ -0,0 +1,392 @@
import React, { useState, useEffect } from 'react';
import { Icons } from './IconSet';
interface ShopItem {
id: string;
name: string;
description: string;
price: number;
currency: string;
stock: number;
type: 'item' | 'service';
materialsRequired?: string;
}
interface ShopManagementModalProps {
isOpen: boolean;
onClose: () => void;
projectId: string;
onUpdate: () => void;
}
const ShopManagementModal: React.FC<ShopManagementModalProps> = ({
isOpen,
onClose,
projectId,
onUpdate
}) => {
const [items, setItems] = useState<ShopItem[]>([]);
const [loading, setLoading] = useState(false);
const [showAddForm, setShowAddForm] = useState(false);
const [editingItem, setEditingItem] = useState<ShopItem | null>(null);
const [error, setError] = useState<string | null>(null);
// Form state for add/edit
const [formData, setFormData] = useState({
name: '',
description: '',
price: '',
currency: 'Gold',
stock: '',
type: 'item' as 'item' | 'service',
materialsRequired: ''
});
useEffect(() => {
if (isOpen && projectId) {
loadShopItems();
}
}, [isOpen, projectId]);
const loadShopItems = async () => {
try {
setLoading(true);
const response = await fetch(`https://vollidioten.ceraticsoft.de/api/projects/${projectId}/shop`);
if (response.ok) {
const data = await response.json();
setItems(data);
} else {
setError('Fehler beim Laden der Shop-Artikel');
}
} catch (err) {
console.error('Error loading shop items:', err);
setError('Netzwerkfehler');
} finally {
setLoading(false);
}
};
const resetForm = () => {
setFormData({
name: '',
description: '',
price: '',
currency: 'Gold',
stock: '',
type: 'item',
materialsRequired: ''
});
setEditingItem(null);
setShowAddForm(false);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name.trim() || !formData.description.trim() || !formData.price) {
setError('Name, Beschreibung und Preis sind erforderlich');
return;
}
try {
setLoading(true);
setError(null);
const itemData = {
name: formData.name.trim(),
description: formData.description.trim(),
price: parseFloat(formData.price),
currency: formData.currency,
stock: parseInt(formData.stock) || 0,
type: formData.type,
materialsRequired: formData.materialsRequired.trim() || undefined
};
let response;
if (editingItem) {
// Update existing item
response = await fetch(`https://vollidioten.ceraticsoft.de/api/projects/${projectId}/shop/${editingItem.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(itemData)
});
} else {
// Add new item
response = await fetch(`https://vollidioten.ceraticsoft.de/api/projects/${projectId}/shop`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(itemData)
});
}
if (response.ok) {
await loadShopItems();
resetForm();
onUpdate();
} else {
const errorData = await response.json();
setError(errorData.error || 'Fehler beim Speichern');
}
} catch (err) {
console.error('Error saving shop item:', err);
setError('Netzwerkfehler');
} finally {
setLoading(false);
}
};
const handleEdit = (item: ShopItem) => {
setFormData({
name: item.name,
description: item.description,
price: item.price.toString(),
currency: item.currency,
stock: item.stock.toString(),
type: item.type,
materialsRequired: item.materialsRequired || ''
});
setEditingItem(item);
setShowAddForm(true);
};
const handleDelete = async (itemId: string) => {
if (!confirm('Artikel wirklich löschen?')) return;
try {
setLoading(true);
const response = await fetch(`https://vollidioten.ceraticsoft.de/api/projects/${projectId}/shop/${itemId}`, {
method: 'DELETE',
credentials: 'include'
});
if (response.ok) {
await loadShopItems();
onUpdate();
} else {
setError('Fehler beim Löschen');
}
} catch (err) {
console.error('Error deleting shop item:', err);
setError('Netzwerkfehler');
} finally {
setLoading(false);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm animate-in fade-in duration-200">
<div className="bg-surface border border-border rounded-xl w-full max-w-4xl shadow-2xl flex flex-col max-h-[90vh]">
<div className="p-4 border-b border-border flex justify-between items-center bg-surfaceHighlight/20">
<h3 className="font-bold text-textMain flex items-center gap-2">
<Icons.ShoppingBag className="w-5 h-5" />
Shop verwalten
</h3>
<button onClick={onClose} className="text-textMuted hover:text-white transition-colors text-xl leading-none">&times;</button>
</div>
<div className="p-6 flex-1 overflow-y-auto">
{/* Add/Edit Form */}
{showAddForm && (
<div className="bg-surfaceHighlight/30 border border-border rounded-lg p-4 mb-6">
<h4 className="font-semibold text-textMain mb-4">
{editingItem ? 'Artikel bearbeiten' : 'Neuen Artikel hinzufügen'}
</h4>
<form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="md:col-span-2">
<label className="block text-sm font-medium text-textMain mb-1">Name *</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({...formData, name: e.target.value})}
className="w-full bg-[#0b0b0d] border border-border rounded p-2 text-sm"
required
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-textMain mb-1">Beschreibung *</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({...formData, description: e.target.value})}
className="w-full h-20 bg-[#0b0b0d] border border-border rounded p-2 text-sm"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-textMain mb-1">Preis *</label>
<input
type="number"
step="0.01"
value={formData.price}
onChange={(e) => setFormData({...formData, price: e.target.value})}
className="w-full bg-[#0b0b0d] border border-border rounded p-2 text-sm"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-textMain mb-1">Währung</label>
<select
value={formData.currency}
onChange={(e) => setFormData({...formData, currency: e.target.value})}
className="w-full bg-[#0b0b0d] border border-border rounded p-2 text-sm"
>
<option value="Gold">Gold</option>
<option value="Diamonds">Diamanten</option>
<option value="Credits">Credits</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-textMain mb-1">Bestand</label>
<input
type="number"
value={formData.stock}
onChange={(e) => setFormData({...formData, stock: e.target.value})}
className="w-full bg-[#0b0b0d] border border-border rounded p-2 text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-textMain mb-1">Typ</label>
<select
value={formData.type}
onChange={(e) => setFormData({...formData, type: e.target.value as 'item' | 'service'})}
className="w-full bg-[#0b0b0d] border border-border rounded p-2 text-sm"
>
<option value="item">Produkt</option>
<option value="service">Dienstleistung</option>
</select>
</div>
{formData.type === 'service' && (
<div className="md:col-span-2">
<label className="block text-sm font-medium text-textMain mb-1">Materialanforderungen</label>
<input
type="text"
value={formData.materialsRequired}
onChange={(e) => setFormData({...formData, materialsRequired: e.target.value})}
className="w-full bg-[#0b0b0d] border border-border rounded p-2 text-sm"
placeholder="z.B. Kunde stellt Steinziegel"
/>
</div>
)}
<div className="md:col-span-2 flex gap-2 pt-2">
<button
type="submit"
disabled={loading}
className="bg-accentInfo hover:bg-accentInfo/90 text-white px-4 py-2 rounded text-sm font-medium disabled:opacity-50"
>
{loading ? 'Speichere...' : (editingItem ? 'Aktualisieren' : 'Hinzufügen')}
</button>
<button
type="button"
onClick={resetForm}
className="bg-surfaceHighlight hover:bg-white/10 px-4 py-2 rounded text-sm"
>
Abbrechen
</button>
</div>
</form>
</div>
)}
{/* Error Display */}
{error && (
<div className="bg-red-500/10 border border-red-500/20 rounded-lg p-4 mb-6">
<p className="text-red-400">{error}</p>
</div>
)}
{/* Header with Add Button */}
<div className="flex justify-between items-center mb-6">
<h4 className="text-lg font-semibold text-textMain">Artikel ({items.length})</h4>
<button
onClick={() => setShowAddForm(true)}
className="bg-accentInfo hover:bg-accentInfo/90 text-white px-4 py-2 rounded text-sm font-medium flex items-center gap-2"
>
<Icons.ShoppingBag className="w-4 h-4" />
Artikel hinzufügen
</button>
</div>
{/* Items List */}
{loading ? (
<div className="flex justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-accentInfo"></div>
</div>
) : items.length === 0 ? (
<div className="text-center py-8 text-textMuted">
<p>Noch keine Artikel im Shop.</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{items.map((item) => (
<div key={item.id} className="bg-surfaceHighlight/30 border border-border rounded-lg p-4">
<div className="flex justify-between items-start mb-2">
<h5 className="font-medium text-textMain">{item.name}</h5>
<span className={`text-xs px-2 py-1 rounded ${
item.type === 'service' ? 'bg-amber-500/20 text-amber-400' : 'bg-blue-500/20 text-blue-400'
}`}>
{item.type === 'service' ? 'Dienst' : 'Produkt'}
</span>
</div>
<p className="text-sm text-textMuted mb-3 line-clamp-2">{item.description}</p>
<div className="flex items-center justify-between mb-3">
<div className="text-sm font-medium text-accentInfo">
{item.price} {item.currency}
</div>
<div className="text-xs text-textMuted">
Bestand: {item.stock}
</div>
</div>
{item.materialsRequired && (
<div className="text-xs text-amber-400 bg-amber-500/10 p-2 rounded mb-3">
Material: {item.materialsRequired}
</div>
)}
<div className="flex gap-2">
<button
onClick={() => handleEdit(item)}
className="flex-1 bg-accentInfo hover:bg-accentInfo/90 text-white px-3 py-1 rounded text-xs font-medium"
>
Bearbeiten
</button>
<button
onClick={() => handleDelete(item.id)}
className="flex-1 bg-red-500 hover:bg-red-600 text-white px-3 py-1 rounded text-xs font-medium"
>
Löschen
</button>
</div>
</div>
))}
</div>
)}
</div>
<div className="p-4 border-t border-border flex justify-end">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-textMuted hover:text-white transition-colors"
>
Schließen
</button>
</div>
</div>
</div>
);
};
export default ShopManagementModal;

View File

@@ -65,7 +65,7 @@ Spezialisiert auf automatisierte Logistik. Gerüchten zufolge hat er eine Maschi
},
{
uuid: 'b3b84518-03a2-4b48-8551-448c3f7a7d77',
username: 'ceratic',
username: 'ceratic_test',
isOnline: true,
tags: ['#Bürger', '#Händler'],
stats: {

77
docker-compose.yml Normal file
View File

@@ -0,0 +1,77 @@
services:
frontend:
build:
context: .
dockerfile: Dockerfile.frontend
labels:
- traefik.enable=true
- traefik.http.routers.vollidioten.rule=Host(`vollidioten.ceraticsoft.de`)
- traefik.http.routers.vollidioten.entrypoints=websecure
- traefik.http.routers.vollidioten.tls.certresolver=lets-encrypt
- traefik.http.services.vollidioten.loadbalancer.server.port=80
networks:
- external_web
- default
depends_on:
- backend
restart: always
backend:
build:
context: .
dockerfile: Dockerfile.backend
environment:
- DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID}
- DISCORD_CLIENT_SECRET=${DISCORD_CLIENT_SECRET}
- SESSION_SECRET=${SESSION_SECRET}
- CALLBACK_URL=https://vollidioten.ceraticsoft.de/auth/discord/callback
- FRONTEND_URL=https://vollidioten.ceraticsoft.de
- DB_HOST=${DB_HOST:-db}
- DB_USER=${DB_USER:-obsidian_user}
- DB_PASS=${DB_PASS:-obsidian_pass}
- DB_NAME=${DB_NAME:-obsidian_db}
restart: always
networks:
- external_web
- default
#db:
# image: mysql:8.0
# command: --default-authentication-plugin=mysql_native_password
# restart: always
# environment:
# MYSQL_DATABASE: obsidian_db
# MYSQL_USER: obsidian_user
# MYSQL_PASSWORD: obsidian_pass
# MYSQL_ROOT_PASSWORD: root_secret_pass
# volumes:
# - db_data:/var/lib/mysql
# networks:
# - default
db:
image: mariadb:latest
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: fgterherthethehdfghfghdfghdfgh
MYSQL_DATABASE: obsidian_db
MYSQL_USER: obsidian_user
MYSQL_PASSWORD: obsidian_pass
volumes:
- ./database:/var/lib/mysql
networks:
- default
phpmyadmin:
image: phpmyadmin/phpmyadmin
restart: always
ports:
- 8081:80
environment:
PMA_HOST: db
MYSQL_ROOT_PASSWORD: root_secret_pass
depends_on:
- db
networks:
- default
networks:
external_web:
external: true
volumes:
db_data: null

View File

@@ -67,8 +67,10 @@
}
}
</script>
<link rel="stylesheet" href="/index.css">
</head>
<body>
<div id="root"></div>
<script type="module" src="/index.tsx"></script>
</body>
</html>

51
nginx.conf Normal file
View File

@@ -0,0 +1,51 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
# Try to proxy to backend first
proxy_pass http://backend:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# If backend is unavailable, serve mock data
error_page 502 503 504 = @fallback;
}
location @fallback {
# Serve mock data from frontend
rewrite ^/api/(.*)$ /mock/$1 break;
try_files $uri /index.html;
# Add header to indicate mock data is being used
add_header X-Mock-Data "true";
add_header X-Backend-Status "unavailable";
}
# Handle auth endpoints
location /auth/ {
proxy_pass http://backend:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Auth endpoints should fail if backend is down
error_page 502 503 504 = /auth-unavailable.html;
}
# Handle static assets
location /assets/ {
expires 1y;
add_header Cache-Control "public";
}
}

1251
pages/Admin.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { MOCK_ORGS } from '../constants';
import React, { useState, useEffect } from 'react';
import { Organization } from '../types';
import { Icons } from '../components/IconSet';
import { dbService } from '../services/DatabaseService';
interface CitiesProps {
onSelectCity: (id: string) => void;
@@ -48,7 +48,55 @@ const CityCard = ({ city, onClick }: { city: Organization; onClick: () => void }
);
const Cities: React.FC<CitiesProps> = ({ onSelectCity }) => {
const cities = MOCK_ORGS.filter(org => org.type === 'City');
const [cities, setCities] = useState<Organization[]>([]);
const [citiesWithStats, setCitiesWithStats] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const loadCities = () => {
// Get all organizations from database
const allOrgs = dbService.getOrgs();
const cityOrgs = allOrgs.filter(org => org.type === 'City');
// Calculate dynamic stats for each city
const citiesStats = cityOrgs.map(city => {
// Count citizens (players with this organizationId)
const allPlayers = dbService.getPlayers();
const citizenCount = allPlayers.filter(player =>
player.stats.organizationId === city.id
).length;
// Count businesses/projects in this city
const allProjects = dbService.getProjects();
const businessCount = allProjects.filter(project =>
project.associatedOrgId === city.id
).length;
return {
...city,
memberCount: citizenCount,
businessCount: businessCount,
// Override static memberCount with dynamic count
stats: {
citizens: citizenCount,
businesses: businessCount,
...city.cityStats
}
};
});
setCities(cityOrgs);
setCitiesWithStats(citiesStats);
setLoading(false);
};
// Initial load
loadCities();
// Subscribe to updates
const unsub = dbService.subscribe(loadCities);
return unsub;
}, []);
return (
<div className="animate-in fade-in slide-in-from-bottom-2 space-y-8">
@@ -57,17 +105,27 @@ const Cities: React.FC<CitiesProps> = ({ onSelectCity }) => {
<p className="text-textMuted">Entdecke die blühenden Zentren der Zivilisation im Obsidian-Tal.</p>
</div>
{loading ? (
<div className="flex justify-center py-20">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-accentInfo"></div>
</div>
) : (
<>
<div className="grid grid-cols-1 gap-6">
{cities.map(city => (
<CityCard key={city.id} city={city} onClick={() => onSelectCity(city.id)} />
{citiesWithStats.map(city => (
<div key={city.id}>
<CityCard city={city} onClick={() => onSelectCity(city.id)} />
</div>
))}
</div>
{cities.length === 0 && (
{citiesWithStats.length === 0 && (
<div className="text-center py-20 text-textMuted">
<p>Noch keine Städte gegründet.</p>
</div>
)}
</>
)}
</div>
);
};

View File

@@ -1,7 +1,7 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { Organization, Project, Player } from '../types';
import { MOCK_PLAYERS, MOCK_PROJECTS } from '../constants';
import { Icons } from '../components/IconSet';
import { dbService } from '../services/DatabaseService';
interface CityProfileProps {
city: Organization;
@@ -19,9 +19,44 @@ const CityProfile: React.FC<CityProfileProps> = ({
onSelectProject
}) => {
const [activeTab, setActiveTab] = useState<'overview' | 'residents' | 'ventures'>('overview');
const [residents, setResidents] = useState<Player[]>([]);
const [ventures, setVentures] = useState<Project[]>([]);
const [cityStats, setCityStats] = useState<any>(null);
const [loading, setLoading] = useState(true);
const residents = MOCK_PLAYERS.filter(p => p.stats.organizationId === city.id);
const ventures = MOCK_PROJECTS.filter(p => p.associatedOrgId === city.id);
useEffect(() => {
const loadCityData = () => {
// Load residents (players in this city)
const allPlayers = dbService.getPlayers();
const cityResidents = allPlayers.filter(p => p.stats.organizationId === city.id);
setResidents(cityResidents);
// Load ventures (projects in this city)
const allProjects = dbService.getProjects();
const cityVentures = allProjects.filter(p => p.associatedOrgId === city.id);
setVentures(cityVentures);
// Calculate dynamic city statistics
const stats = {
citizens: cityResidents.length,
businesses: cityVentures.length,
activeBusinesses: cityVentures.filter(p => p.status === 'active').length,
recruitingBusinesses: cityVentures.filter(p => p.hiring).length,
// Merge with existing static stats
...city.cityStats
};
setCityStats(stats);
setLoading(false);
};
// Initial load
loadCityData();
// Subscribe to updates
const unsub = dbService.subscribe(loadCityData);
return unsub;
}, [city.id]);
return (
<div className="animate-in slide-in-from-right-4 duration-300">

View File

@@ -1,6 +1,7 @@
import React from 'react';
import { MOCK_PLAYERS, MOCK_PROJECTS, MOCK_ORGS } from '../constants';
import React, { useState, useEffect } from 'react';
import { dbService } from '../services/DatabaseService';
import { Icons } from '../components/IconSet';
import { Player, Project, Organization } from '../types';
const StatCard = ({ label, value, trend, icon: Icon }: any) => (
<div className="bg-surface/50 border border-border p-6 rounded-xl hover:border-accentInfo/30 transition-all duration-300 group">
@@ -41,6 +42,31 @@ const ProjectCard = ({ project }: { project: any }) => (
);
const Dashboard: React.FC = () => {
const [players, setPlayers] = useState<Player[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
const [orgs, setOrgs] = useState<Organization[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Subscribe to database updates
const unsubscribe = dbService.subscribe(() => {
setPlayers(dbService.getPlayers());
setProjects(dbService.getProjects());
setOrgs(dbService.getOrgs());
setLoading(false);
});
// Initial data load
setPlayers(dbService.getPlayers());
setProjects(dbService.getProjects());
setOrgs(dbService.getOrgs());
setLoading(false);
return unsubscribe;
}, []);
const activeProjectsCount = projects.filter(p => p.status === 'active' || p.status === 'recruiting').length;
return (
<div className="space-y-12">
{/* Intro Section */}
@@ -56,9 +82,9 @@ const Dashboard: React.FC = () => {
{/* KPI Grid */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
<StatCard label="Registrierte Bürger" value={MOCK_PLAYERS.length} trend={12} icon={Icons.Users} />
<StatCard label="Aktive Unternehmen" value={MOCK_PROJECTS.filter(p => p.status === 'active' || p.status === 'recruiting').length} trend={5} icon={Icons.Layers} />
<StatCard label="Organisationen" value={MOCK_ORGS.length} trend={0} icon={Icons.Map} />
<StatCard label="Registrierte Bürger" value={loading ? '...' : players.length} trend={12} icon={Icons.Users} />
<StatCard label="Aktive Unternehmen" value={loading ? '...' : activeProjectsCount} trend={5} icon={Icons.Layers} />
<StatCard label="Organisationen" value={loading ? '...' : orgs.length} trend={0} icon={Icons.Map} />
</div>
{/* Content Grid */}
@@ -70,7 +96,17 @@ const Dashboard: React.FC = () => {
<button className="text-sm text-textMuted hover:text-accentInfo transition-colors">Verzeichnis ansehen </button>
</div>
<div className="bg-surface/30 border border-border rounded-2xl p-6 backdrop-blur-sm">
{MOCK_PROJECTS.slice(0, 5).map(p => <ProjectCard key={p.id} project={p} />)}
{loading ? (
<div className="flex justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-accentInfo"></div>
</div>
) : (
projects.slice(0, 5).map(p => (
<div key={p.id}>
<ProjectCard project={p} />
</div>
))
)}
</div>
</div>

25
pages/DatabaseManager.tsx Normal file
View File

@@ -0,0 +1,25 @@
import React from 'react';
const DatabaseManager: React.FC = () => {
return (
<div className="flex flex-col items-center justify-center h-full text-center p-10 animate-in fade-in">
<div className="bg-surface border border-border rounded-xl p-8 max-w-md shadow-card">
<h1 className="text-2xl font-bold mb-4 text-textMain">Datenbank-Manager</h1>
<p className="text-textMuted mb-6 leading-relaxed">
Die Datenbank wird über eine externe MySQL-Instanz verwaltet.
Für administrative Eingriffe nutze bitte das phpMyAdmin Interface.
</p>
<a
href="http://localhost:8081"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center px-6 py-3 bg-accentInfo text-white rounded-lg hover:bg-accentInfo/90 transition-colors font-medium shadow-glow"
>
phpMyAdmin öffnen
</a>
</div>
</div>
);
};
export default DatabaseManager;

201
pages/LinkPlayer.tsx Normal file
View File

@@ -0,0 +1,201 @@
import React, { useState, useEffect } from 'react';
import { authService } from '../services/AuthService';
import { DiscordUser } from '../types';
interface Player {
uuid: string;
username: string;
tags: string[];
stats: {
playtimeHours: number;
level: number;
role: string;
};
}
const LinkPlayer: React.FC = () => {
const [user, setUser] = useState<DiscordUser | null>(null);
const [unlinkedPlayers, setUnlinkedPlayers] = useState<Player[]>([]);
const [loading, setLoading] = useState(true);
const [linking, setLinking] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
// Get current user
const unsubscribe = authService.subscribe(setUser);
return unsubscribe;
}, []);
useEffect(() => {
// Fetch unlinked players
if (user) {
fetchUnlinkedPlayers();
}
}, [user]);
const fetchUnlinkedPlayers = async () => {
try {
setLoading(true);
const response = await fetch('https://vollidioten.ceraticsoft.de/api/unlinked-players');
if (response.ok) {
const players = await response.json();
setUnlinkedPlayers(players);
} else {
setError('Fehler beim Laden der Spielerliste');
}
} catch (err) {
console.error('Error fetching unlinked players:', err);
setError('Netzwerkfehler beim Laden der Spielerliste');
} finally {
setLoading(false);
}
};
const linkPlayer = async (playerUuid: string) => {
if (!user) return;
try {
setLinking(true);
setError(null);
const response = await fetch('https://vollidioten.ceraticsoft.de/api/link-user', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({ playerUuid }),
});
if (response.ok) {
// Update user data
const updatedUser = { ...user, linkedPlayerUuid: playerUuid };
setUser(updatedUser);
// Redirect to dashboard
window.location.href = '/';
} else {
setError('Fehler beim Verknüpfen des Accounts');
}
} catch (err) {
console.error('Error linking player:', err);
setError('Netzwerkfehler beim Verknüpfen');
} finally {
setLinking(false);
}
};
if (!user) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<h2 className="text-xl font-semibold mb-4">Nicht eingeloggt</h2>
<p>Bitte loggen Sie sich zuerst ein.</p>
</div>
</div>
);
}
if (user.linkedPlayerUuid) {
// User is already linked, redirect to dashboard
window.location.href = '/';
return null;
}
return (
<div className="min-h-screen bg-background py-12 px-4">
<div className="max-w-4xl mx-auto">
<div className="text-center mb-8">
<div className="flex items-center justify-center gap-4 mb-6">
<img
src={user.avatarUrl}
alt={user.username}
className="w-16 h-16 rounded-full border-2 border-accentInfo"
/>
<div className="text-left">
<h1 className="text-3xl font-bold text-textMain">Willkommen, {user.username}!</h1>
<p className="text-textMuted">Verbinde deinen Discord-Account mit einem Bürger</p>
</div>
</div>
<div className="bg-surface rounded-lg p-6 border border-border">
<h2 className="text-xl font-semibold mb-4 text-textMain">Wähle deinen Bürger</h2>
<p className="text-textMuted mb-6">
Wähle einen Bürger aus der Liste der verfügbaren Charaktere aus, um deinen Discord-Account zu verknüpfen.
</p>
{error && (
<div className="bg-red-500/10 border border-red-500/20 rounded-lg p-4 mb-6">
<p className="text-red-400">{error}</p>
</div>
)}
{loading ? (
<div className="flex justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-accentInfo"></div>
</div>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{unlinkedPlayers.map((player) => (
<div
key={player.uuid}
className="bg-surfaceHighlight border border-border rounded-lg p-4 hover:border-accentInfo transition-colors"
>
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 bg-gradient-to-br from-accentInfo to-blue-900 rounded-full flex items-center justify-center">
<span className="font-bold text-white text-sm">
{player.username.charAt(0).toUpperCase()}
</span>
</div>
<div>
<h3 className="font-semibold text-textMain">{player.username}</h3>
<p className="text-sm text-textMuted">Level {player.stats.level}</p>
</div>
</div>
<div className="mb-4">
<div className="flex flex-wrap gap-1">
{player.tags.map((tag, index) => (
<span
key={index}
className="text-xs bg-accentInfo/10 text-accentInfo px-2 py-1 rounded"
>
{tag}
</span>
))}
</div>
</div>
<div className="text-sm text-textMuted mb-4">
<p>Spielzeit: {player.stats.playtimeHours}h</p>
<p>Rolle: {player.stats.role}</p>
</div>
<button
onClick={() => linkPlayer(player.uuid)}
disabled={linking}
className="w-full bg-accentInfo hover:bg-accentInfo/80 disabled:opacity-50 disabled:cursor-not-allowed text-white font-medium py-2 px-4 rounded transition-colors"
>
{linking ? 'Verknüpfe...' : 'Diesen Bürger wählen'}
</button>
</div>
))}
</div>
)}
{unlinkedPlayers.length === 0 && !loading && (
<div className="text-center py-8">
<p className="text-textMuted">Keine verfügbaren Bürger gefunden.</p>
<p className="text-sm text-textMuted mt-2">
Alle Bürger sind bereits verknüpft oder es gibt einen Fehler beim Laden.
</p>
</div>
)}
</div>
</div>
</div>
</div>
);
};
export default LinkPlayer;

View File

@@ -1,7 +1,9 @@
import React from 'react';
import React, { useState, useEffect } from 'react';
import { Player } from '../types';
import { MOCK_ORGS } from '../constants';
import { dbService } from '../services/DatabaseService';
import { authService } from '../services/AuthService';
import InventoryGrid from '../components/InventoryGrid';
import EditModal from '../components/EditModal';
import { Icons } from '../components/IconSet';
interface PlayerProfileProps {
@@ -9,11 +11,95 @@ interface PlayerProfileProps {
onBack: () => void;
}
const PlayerProfile: React.FC<PlayerProfileProps> = ({ player, onBack }) => {
const playerOrg = MOCK_ORGS.find(o => o.id === player.stats.organizationId);
const PlayerProfile: React.FC<PlayerProfileProps> = ({ player: initialPlayer, onBack }) => {
const [player, setPlayer] = useState(initialPlayer);
const [currentUser, setCurrentUser] = useState(authService.getUser());
// Edit State
const [isEditStoryOpen, setIsEditStoryOpen] = useState(false);
const [isEditTagsOpen, setIsEditTagsOpen] = useState(false);
const [isEditOrgOpen, setIsEditOrgOpen] = useState(false);
const [ownedProjects, setOwnedProjects] = useState<any[]>([]);
// Is this the logged-in user's profile?
const isOwner = currentUser?.linkedPlayerUuid === player.uuid;
const playerOrg = dbService.getOrg(player.stats.organizationId || '');
// Check if player is already linked to anyone in our mock/real DB logic
// Since the Player object doesn't expose 'discordId' publicly in types yet (it's hidden in DB),
// we use a heuristic or add it. For now, we assume if currentUser has NO link, they can claim.
const canClaim = !!currentUser && !currentUser.linkedPlayerUuid && !isOwner;
useEffect(() => {
// Refresh player data from "DB" to ensure we have latest local edits
const freshData = dbService.getPlayer(player.uuid);
if (freshData) setPlayer(freshData);
// Load owned projects
const allProjects = dbService.getProjects();
const playerProjects = allProjects.filter(p => p.owner === player.username);
setOwnedProjects(playerProjects);
// Subscribe to auth to show/hide edit buttons
const unsub = authService.subscribe(u => setCurrentUser(u));
return unsub;
}, [player.uuid, player.username]);
const handleSaveStory = (newStory: string) => {
// Update local DB
dbService.updatePlayer(player.uuid, { storyMarkdown: newStory });
// Update local state
setPlayer(prev => ({ ...prev, storyMarkdown: newStory }));
};
const handleSaveTags = async (tagsString: string) => {
const tags = tagsString.split(',').map(tag => tag.trim()).filter(tag => tag.length > 0);
// Update via API
const response = await fetch(`https://vollidioten.ceraticsoft.de/api/players/${player.uuid}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ tags })
});
if (response.ok) {
// Update local state
setPlayer(prev => ({ ...prev, tags }));
} else {
alert('Fehler beim Aktualisieren der Tags');
}
};
const handleSaveOrganization = async (orgId: string) => {
const response = await fetch(`https://vollidioten.ceraticsoft.de/api/players/${player.uuid}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ organizationId: orgId || null })
});
if (response.ok) {
// Update local state
setPlayer(prev => ({ ...prev, stats: { ...prev.stats, organizationId: orgId || undefined } }));
} else {
alert('Fehler beim Aktualisieren der Zugehörigkeit');
}
};
const handleClaim = async () => {
if (confirm(`Möchtest du den Charakter "${player.username}" mit deinem Discord-Account verknüpfen?`)) {
const success = await dbService.linkPlayer(player.uuid);
if (success) {
// Force refresh auth to get updated linkedPlayerUuid
await authService.checkSession();
alert("Erfolgreich verknüpft! Du kannst nun Bearbeitungen vornehmen.");
} else {
alert("Fehler beim Verknüpfen. Bitte Backend Logs prüfen.");
}
}
};
// Simple markdown renderer replacement for demo purposes
// In production, use 'react-markdown'
const renderMarkdown = (text: string) => {
return text.split('\n').map((line, i) => {
if (line.startsWith('# ')) return <h1 key={i} className="text-2xl font-bold mt-6 mb-3 text-textMain border-b border-border pb-2">{line.replace('# ', '')}</h1>;
@@ -26,15 +112,37 @@ const PlayerProfile: React.FC<PlayerProfileProps> = ({ player, onBack }) => {
return (
<div className="max-w-4xl mx-auto animate-in slide-in-from-right-4 duration-300">
<button onClick={onBack} className="flex items-center gap-2 text-sm text-textMuted hover:text-textMain mb-6 transition-colors">
<div className="flex justify-between items-center mb-6">
<button onClick={onBack} className="flex items-center gap-2 text-sm text-textMuted hover:text-textMain transition-colors">
<span className="text-lg"></span> Zurück zur Liste
</button>
{canClaim && (
<button
onClick={handleClaim}
className="text-xs bg-accentInfo hover:bg-accentInfo/80 text-white px-3 py-1.5 rounded flex items-center gap-2 transition-colors shadow-glow animate-pulse"
>
<Icons.Shield className="w-3 h-3" />
Charakter beanspruchen (Test)
</button>
)}
</div>
{/* Header */}
<div className="bg-surface border border-border rounded-xl p-6 shadow-card mb-6">
<div className="bg-surface border border-border rounded-xl p-6 shadow-card mb-6 relative overflow-hidden">
{isOwner && (
<div className="absolute top-0 right-0 bg-accentInfo text-white text-xs font-bold px-3 py-1 rounded-bl-xl shadow-lg flex items-center gap-1 z-10">
<Icons.Edit className="w-3 h-3" /> Dein Profil
</div>
)}
<div className="flex flex-col md:flex-row gap-6 items-start md:items-center">
<div className="w-20 h-20 rounded-lg flex items-center justify-center shadow-inner shrink-0">
<img src={"https://minotar.net/armor/bust/"+player.username+"/500.png"}></img>
<div className="w-20 h-20 rounded-lg flex items-center justify-center shadow-inner shrink-0 relative group">
<img src={"https://minotar.net/armor/bust/"+player.username+"/500.png"} className="rounded-lg shadow-lg"></img>
{isOwner && (
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center rounded-lg cursor-pointer">
<span className="text-[10px] text-white font-bold">Ändern</span>
</div>
)}
</div>
<div className="flex-1">
@@ -54,6 +162,14 @@ const PlayerProfile: React.FC<PlayerProfileProps> = ({ player, onBack }) => {
{tag}
</span>
))}
{isOwner && (
<button
onClick={() => setIsEditTagsOpen(true)}
className="text-xs px-2 py-1 border border-dashed border-textMuted/50 text-textMuted hover:text-white hover:border-white/50 rounded transition-colors"
>
+ Tag bearbeiten
</button>
)}
</div>
<div className="flex gap-6 text-sm">
@@ -71,9 +187,9 @@ const PlayerProfile: React.FC<PlayerProfileProps> = ({ player, onBack }) => {
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left Col: Inventory */}
<div className="lg:col-span-1">
{/* <InventoryGrid items={player.inventory} /> */}
{/* Left Col: Inventory & Org */}
<div className="lg:col-span-1 space-y-6">
<InventoryGrid items={player.inventory} />
<div className="bg-surface border border-border rounded-xl p-4 shadow-card">
<h3 className="text-xs font-bold uppercase tracking-wider text-textMuted mb-3">Zugehörigkeit</h3>
@@ -98,30 +214,98 @@ const PlayerProfile: React.FC<PlayerProfileProps> = ({ player, onBack }) => {
</div>
</div>
</div>
{playerOrg && (
<div className="mt-3 pt-3 border-t border-white/5 text-xs text-textMuted leading-relaxed">
{playerOrg.type} {playerOrg.description}
</div>
)}
</div>
</div>
{/* Right Col: Story */}
<div className="lg:col-span-2">
<div className="bg-surface border border-border rounded-xl p-8 shadow-card min-h-[400px]">
{/* Right Col: Story & Projects */}
<div className="lg:col-span-2 space-y-6">
{/* Story Section */}
<div className="bg-surface border border-border rounded-xl p-8 shadow-card min-h-[400px] relative">
<div className="flex items-center justify-between mb-6 border-b border-border pb-4">
<h2 className="text-lg font-semibold flex items-center gap-2">
Charakter-Journal
</h2>
<span className="text-xs text-textMuted font-mono">Markdown Rendered</span>
<div className="flex items-center gap-3">
<span className="text-xs text-textMuted font-mono">Markdown</span>
{isOwner && (
<button
onClick={() => setIsEditStoryOpen(true)}
className="flex items-center gap-1.5 text-xs bg-surfaceHighlight hover:bg-white/10 border border-white/10 px-2 py-1 rounded transition-colors text-white"
>
<Icons.Edit className="w-3 h-3" /> Bearbeiten
</button>
)}
</div>
</div>
<div className="prose-custom text-sm">
{renderMarkdown(player.storyMarkdown)}
</div>
</div>
{/* Owned Projects Section */}
{ownedProjects.length > 0 && (
<div className="bg-surface border border-border rounded-xl p-6 shadow-card">
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
<Icons.ShoppingBag className="w-5 h-5 text-accentInfo" />
Unternehmen & Projekte ({ownedProjects.length})
</h3>
<div className="grid grid-cols-1 gap-4">
{ownedProjects.map((project) => (
<div key={project.id} className="bg-surfaceHighlight/30 border border-border rounded-lg p-4 hover:border-accentInfo/50 transition-all">
<div className="flex justify-between items-start mb-2">
<h4 className="font-medium text-textMain">{project.title}</h4>
<span className={`text-xs px-2 py-1 rounded border ${
project.status === 'active' ? 'bg-green-500/10 text-green-400 border-green-500/20' :
project.status === 'recruiting' ? 'bg-blue-500/10 text-blue-400 border-blue-500/20' :
'bg-gray-500/10 text-gray-400 border-gray-500/20'
}`}>
{project.category}
</span>
</div>
<p className="text-sm text-textMuted mb-3 line-clamp-2">{project.description}</p>
<div className="flex items-center justify-between text-xs text-textMuted">
<span>Gegründet: {project.foundedDate}</span>
<div className="flex items-center gap-2">
{project.shopCatalog && project.shopCatalog.length > 0 && (
<span className="flex items-center gap-1 text-accentInfo">
<Icons.ShoppingBag className="w-3 h-3" />
Shop ({project.shopCatalog.length})
</span>
)}
<span className="flex items-center gap-1">
<Icons.Users className="w-3 h-3" />
{project.employees.length + 1}
</span>
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
{/* Modals */}
<EditModal
isOpen={isEditStoryOpen}
title="Journal bearbeiten"
initialValue={player.storyMarkdown}
multiline={true}
markdown={true}
onClose={() => setIsEditStoryOpen(false)}
onSave={handleSaveStory}
/>
<EditModal
isOpen={isEditTagsOpen}
title="Tags bearbeiten"
initialValue={player.tags.join(', ')}
multiline={false}
onClose={() => setIsEditTagsOpen(false)}
onSave={handleSaveTags}
/>
</div>
);
};

View File

@@ -1,7 +1,15 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { Project, ShopItem } from '../types';
import { MOCK_ORGS, MOCK_PLAYERS } from '../constants';
import { Icons, ItemIcon } from '../components/IconSet';
import { dbService } from '../services/DatabaseService';
import { authService } from '../services/AuthService';
import { DiscordUser } from '../types';
import EditModal from '../components/EditModal';
import ShopManagementModal from '../components/ShopManagementModal';
import EmployeeManagementModal from '../components/EmployeeManagementModal';
import BannerManagementModal from '../components/BannerManagementModal';
import GalleryManagementModal from '../components/GalleryManagementModal';
import DeleteProjectModal from '../components/DeleteProjectModal';
interface ProjectProfileProps {
project: Project;
@@ -16,11 +24,39 @@ const ProjectProfile: React.FC<ProjectProfileProps> = ({
onSelectPlayer,
onSelectOrg
}) => {
const [activeTab, setActiveTab] = useState<'overview' | 'shop'>('overview');
const [activeTab, setActiveTab] = useState<'overview' | 'shop' | 'manage'>('overview');
const [user, setUser] = useState<DiscordUser | null>(null);
const [org, setOrg] = useState<any>(null);
const [ownerPlayer, setOwnerPlayer] = useState<any>(null);
const [isEditDescriptionOpen, setIsEditDescriptionOpen] = useState(false);
const [isEditHiringOpen, setIsEditHiringOpen] = useState(false);
const [isShopModalOpen, setIsShopModalOpen] = useState(false);
const [isEmployeeModalOpen, setIsEmployeeModalOpen] = useState(false);
const [isBannerModalOpen, setIsBannerModalOpen] = useState(false);
const [isGalleryModalOpen, setIsGalleryModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
// Subscribe to auth and data updates
useEffect(() => {
const unsubAuth = authService.subscribe(setUser);
return unsubAuth;
}, []);
useEffect(() => {
// Load associated org and owner player
if (project.associatedOrgId) {
const foundOrg = dbService.getOrg(project.associatedOrgId);
setOrg(foundOrg);
}
// Find owner player by username
const players = dbService.getPlayers();
const owner = players.find(p => p.username === project.owner);
setOwnerPlayer(owner);
}, [project]);
const org = project.associatedOrgId ? MOCK_ORGS.find(o => o.id === project.associatedOrgId) : null;
const ownerPlayer = MOCK_PLAYERS.find(p => p.username === project.owner);
const hasShop = project.shopCatalog && project.shopCatalog.length > 0;
const isOwner = user?.linkedPlayerUuid && ownerPlayer && dbService.getPlayer(user.linkedPlayerUuid)?.username === project.owner;
// Group shop items
const services = project.shopCatalog?.filter(i => i.type === 'service') || [];
@@ -119,6 +155,14 @@ const ProjectProfile: React.FC<ProjectProfileProps> = ({
Katalog ({project.shopCatalog?.length})
</button>
)}
{isOwner && (
<button
onClick={() => setActiveTab('manage')}
className={`pb-4 text-sm font-medium transition-colors border-b-2 ${activeTab === 'manage' ? 'border-accentInfo text-white' : 'border-transparent text-textMuted hover:text-white'}`}
>
Verwalten
</button>
)}
</div>
{/* Content */}
@@ -156,7 +200,8 @@ const ProjectProfile: React.FC<ProjectProfileProps> = ({
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{project.employees.map((emp, idx) => {
const empPlayer = MOCK_PLAYERS.find(p => p.username === emp);
const players = dbService.getPlayers();
const empPlayer = players.find(p => p.username === emp);
return (
<div
key={idx}
@@ -247,6 +292,117 @@ const ProjectProfile: React.FC<ProjectProfileProps> = ({
</div>
)}
{activeTab === 'manage' && isOwner && (
<div className="space-y-8">
<div className="bg-surface/50 border border-border rounded-xl p-6">
<h3 className="text-lg font-bold text-white mb-6 flex items-center gap-2">
<Icons.Edit className="w-5 h-5 text-accentInfo" />
Unternehmen verwalten
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Edit Description */}
<div className="bg-surfaceHighlight/30 border border-border rounded-lg p-4">
<h4 className="text-sm font-semibold text-textMain mb-3">Manifest bearbeiten</h4>
<p className="text-xs text-textMuted mb-4">
Ändern Sie die Beschreibung und das Leitbild Ihres Unternehmens.
</p>
<button
onClick={() => setIsEditDescriptionOpen(true)}
className="w-full bg-accentInfo hover:bg-accentInfo/90 text-white text-sm font-medium py-2 px-4 rounded transition-colors"
>
Beschreibung bearbeiten
</button>
</div>
{/* Toggle Hiring */}
<div className="bg-surfaceHighlight/30 border border-border rounded-lg p-4">
<h4 className="text-sm font-semibold text-textMain mb-3">Stellenanzeigen</h4>
<p className="text-xs text-textMuted mb-4">
Aktuell: {project.hiring ? 'Stellen sind ausgeschrieben' : 'Keine offenen Stellen'}
</p>
<button
onClick={() => setIsEditHiringOpen(true)}
className="w-full bg-accentInfo hover:bg-accentInfo/90 text-white text-sm font-medium py-2 px-4 rounded transition-colors"
>
{project.hiring ? 'Stellenanzeigen beenden' : 'Stellen ausschreiben'}
</button>
</div>
{/* Shop Management */}
<div className="bg-surfaceHighlight/30 border border-border rounded-lg p-4">
<h4 className="text-sm font-semibold text-textMain mb-3">Shop verwalten</h4>
<p className="text-xs text-textMuted mb-4">
Fügen Sie Produkte, Dienstleistungen und Preise hinzu.
</p>
<button
onClick={() => setIsShopModalOpen(true)}
className="w-full bg-purple-500 hover:bg-purple-600 text-white text-sm font-medium py-2 px-4 rounded transition-colors"
>
Shop bearbeiten
</button>
</div>
{/* Employee Management */}
<div className="bg-surfaceHighlight/30 border border-border rounded-lg p-4">
<h4 className="text-sm font-semibold text-textMain mb-3">Mitarbeiter verwalten</h4>
<p className="text-xs text-textMuted mb-4">
Fügen Sie Mitarbeiter hinzu oder entfernen Sie sie.
</p>
<button
onClick={() => setIsEmployeeModalOpen(true)}
className="w-full bg-green-500 hover:bg-green-600 text-white text-sm font-medium py-2 px-4 rounded transition-colors"
>
Mitarbeiter verwalten
</button>
</div>
{/* Banner Management */}
<div className="bg-surfaceHighlight/30 border border-border rounded-lg p-4">
<h4 className="text-sm font-semibold text-textMain mb-3">Banner ändern</h4>
<p className="text-xs text-textMuted mb-4">
Ändern Sie das Titelbild Ihres Unternehmens.
</p>
<button
onClick={() => setIsBannerModalOpen(true)}
className="w-full bg-orange-500 hover:bg-orange-600 text-white text-sm font-medium py-2 px-4 rounded transition-colors"
>
Banner bearbeiten
</button>
</div>
{/* Gallery Management */}
<div className="bg-surfaceHighlight/30 border border-border rounded-lg p-4">
<h4 className="text-sm font-semibold text-textMain mb-3">Galerie verwalten</h4>
<p className="text-xs text-textMuted mb-4">
Fügen Sie Bilder zu Ihrem Portfolio hinzu.
</p>
<button
onClick={() => setIsGalleryModalOpen(true)}
className="w-full bg-blue-500 hover:bg-blue-600 text-white text-sm font-medium py-2 px-4 rounded transition-colors"
>
Galerie bearbeiten
</button>
</div>
{/* Danger Zone */}
<div className="bg-red-500/10 border border-red-500/20 rounded-lg p-4">
<h4 className="text-sm font-semibold text-red-400 mb-3">Gefahrenzone</h4>
<p className="text-xs text-red-300 mb-4">
Unwiderrufliche Aktionen für dieses Unternehmen.
</p>
<button
onClick={() => setIsDeleteModalOpen(true)}
className="w-full bg-red-500 hover:bg-red-600 text-white text-sm font-medium py-2 px-4 rounded transition-colors"
>
Unternehmen löschen
</button>
</div>
</div>
</div>
</div>
)}
{activeTab === 'shop' && project.shopCatalog && (
<div className="space-y-12">
@@ -342,6 +498,138 @@ const ProjectProfile: React.FC<ProjectProfileProps> = ({
)}
</div>
)}
{/* Edit Modals */}
<EditModal
isOpen={isEditDescriptionOpen}
title="Manifest bearbeiten"
initialValue={project.description}
multiline={true}
markdown={true}
onClose={() => setIsEditDescriptionOpen(false)}
onSave={async (newDescription) => {
try {
const response = await fetch(`https://vollidioten.ceraticsoft.de/api/projects/${project.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ description: newDescription })
});
if (response.ok) {
// Update local data
dbService.updateProject(project.id, { description: newDescription });
console.log('Description updated successfully');
} else {
const errorData = await response.json();
alert(`Fehler beim Aktualisieren: ${errorData.error || 'Unbekannter Fehler'}`);
}
} catch (err) {
console.error('Error updating description:', err);
alert('Netzwerkfehler beim Aktualisieren der Beschreibung');
}
}}
/>
<EditModal
isOpen={isEditHiringOpen}
title={project.hiring ? 'Stellenanzeigen beenden' : 'Stellen ausschreiben'}
initialValue={project.hiring ? 'Ja, Stellen sind derzeit ausgeschrieben.' : 'Nein, derzeit keine offenen Stellen.'}
multiline={false}
onClose={() => setIsEditHiringOpen(false)}
onSave={async (value) => {
const newHiringStatus = !project.hiring;
try {
const response = await fetch(`https://vollidioten.ceraticsoft.de/api/projects/${project.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ hiring: newHiringStatus })
});
if (response.ok) {
// Update local data
dbService.updateProject(project.id, { hiring: newHiringStatus });
console.log('Hiring status updated successfully:', newHiringStatus);
} else {
const errorData = await response.json();
alert(`Fehler beim Aktualisieren: ${errorData.error || 'Unbekannter Fehler'}`);
}
} catch (err) {
console.error('Error updating hiring status:', err);
alert('Netzwerkfehler beim Aktualisieren der Stellenanzeigen');
}
}}
/>
{/* Management Modals */}
<ShopManagementModal
isOpen={isShopModalOpen}
onClose={() => setIsShopModalOpen(false)}
projectId={project.id}
onUpdate={() => {
// Refresh project data
console.log('Shop updated, refreshing project data...');
// The dbService should automatically update via subscription
}}
/>
<EmployeeManagementModal
isOpen={isEmployeeModalOpen}
onClose={() => setIsEmployeeModalOpen(false)}
projectId={project.id}
onUpdate={() => {
// Refresh project data
console.log('Employees updated, refreshing project data...');
}}
/>
<BannerManagementModal
isOpen={isBannerModalOpen}
onClose={() => setIsBannerModalOpen(false)}
projectId={project.id}
currentBannerUrl={project.bannerUrl || ''}
onUpdate={() => {
// Refresh project data
console.log('Banner updated, refreshing project data...');
}}
/>
<GalleryManagementModal
isOpen={isGalleryModalOpen}
onClose={() => setIsGalleryModalOpen(false)}
projectId={project.id}
onUpdate={() => {
// Refresh project data
console.log('Gallery updated, refreshing project data...');
}}
/>
<DeleteProjectModal
isOpen={isDeleteModalOpen}
onClose={() => setIsDeleteModalOpen(false)}
projectId={project.id}
projectTitle={project.title}
onDelete={async () => {
try {
const response = await fetch(`https://vollidioten.ceraticsoft.de/api/projects/${project.id}`, {
method: 'DELETE',
credentials: 'include'
});
if (response.ok) {
alert('Projekt erfolgreich gelöscht!');
onBack(); // Navigate back to projects list
} else {
alert('Fehler beim Löschen des Projekts');
}
} catch (err) {
console.error('Error deleting project:', err);
alert('Netzwerkfehler beim Löschen');
}
}}
/>
</div>
</div>
);

View File

@@ -1,7 +1,10 @@
import React, { useState } from 'react';
import { MOCK_PROJECTS } from '../constants';
import React, { useState, useEffect } from 'react';
import { Project } from '../types';
import { Icons } from '../components/IconSet';
import { dbService } from '../services/DatabaseService';
import { authService } from '../services/AuthService';
import { DiscordUser } from '../types';
import CreateProjectModal from '../components/CreateProjectModal';
interface ProjectsProps {
onSelectProject?: (id: string) => void;
@@ -113,11 +116,69 @@ const VentureCard = ({ project, onClick }: { project: Project, onClick?: () => v
const Projects: React.FC<ProjectsProps> = ({ onSelectProject }) => {
const [filter, setFilter] = useState<'all' | Project['status']>('all');
const [projects, setProjects] = useState<Project[]>([]);
const [user, setUser] = useState<DiscordUser | null>(null);
const [loading, setLoading] = useState(true);
const [showCreateForm, setShowCreateForm] = useState(false);
const filteredProjects = MOCK_PROJECTS.filter(p =>
// Subscribe to auth and data updates
useEffect(() => {
const unsubAuth = authService.subscribe(setUser);
const unsubDb = dbService.subscribe(() => {
setProjects(dbService.getProjects());
});
// Initial data load
setProjects(dbService.getProjects());
setLoading(false);
return () => {
unsubAuth();
unsubDb();
};
}, []);
const filteredProjects = projects.filter(p =>
filter === 'all' ? true : p.status === filter
);
// Berechne den Namen des verknüpften Charakters für die Modal-Anzeige
const getLinkedPlayerName = () => {
if (!user) return null;
if (user.linkedPlayerUuid) {
const linkedPlayer = dbService.getPlayer(user.linkedPlayerUuid);
return linkedPlayer ? linkedPlayer.username : null;
}
return null;
};
const linkedPlayerName = getLinkedPlayerName();
const createProject = async (projectData: { title: string; description: string; category: Project['category'] }) => {
if (!user) {
throw new Error('Nicht eingeloggt');
}
console.log('Creating project with data:', projectData);
console.log('User will be resolved to Minecraft name in backend');
const success = await dbService.createProject(projectData);
if (!success) {
throw new Error('Fehler beim Erstellen des Projekts');
}
};
const handleCreateClick = () => {
if (!user) {
// Redirect to login or show message
alert('Sie müssen sich zuerst anmelden, um ein Unternehmen zu erstellen.');
authService.login();
return;
}
setShowCreateForm(true);
};
const tabs = [
{ id: 'all', label: 'Alle Unternehmen' },
{ id: 'active', label: 'Aktive Firmen' },
@@ -132,7 +193,11 @@ const Projects: React.FC<ProjectsProps> = ({ onSelectProject }) => {
<h1 className="text-3xl font-bold mb-1">Unternehmen & Projekte</h1>
<p className="text-textMuted">Spielergeführte Firmen, aktive Rollenspiel-Stränge und Dienstleister.</p>
</div>
<button className="bg-textMain text-background hover:bg-white font-medium px-4 py-2 rounded-lg text-sm transition-colors flex items-center gap-2">
<button
onClick={handleCreateClick}
className="bg-textMain text-background hover:bg-white font-medium px-4 py-2 rounded-lg text-sm transition-colors flex items-center gap-2"
>
<Icons.ShoppingBag className="w-4 h-4" />
<span>+ Firma registrieren</span>
</button>
</div>
@@ -157,11 +222,12 @@ const Projects: React.FC<ProjectsProps> = ({ onSelectProject }) => {
{/* Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
{filteredProjects.map(project => (
<div key={project.id}>
<VentureCard
key={project.id}
project={project}
onClick={() => onSelectProject && onSelectProject(project.id)}
/>
</div>
))}
</div>
@@ -170,6 +236,14 @@ const Projects: React.FC<ProjectsProps> = ({ onSelectProject }) => {
<p>Keine Unternehmen in dieser Kategorie gefunden.</p>
</div>
)}
{/* Create Project Modal */}
<CreateProjectModal
isOpen={showCreateForm}
onClose={() => setShowCreateForm(false)}
onCreate={createProject}
linkedPlayerName={linkedPlayerName}
/>
</div>
);
};

View File

@@ -0,0 +1,70 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Authentifizierung nicht verfügbar</title>
<style>
body {
font-family: Arial, sans-serif;
background-color: #1a1a1a;
color: #ffffff;
margin: 0;
padding: 0;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
}
.container {
text-align: center;
max-width: 600px;
padding: 20px;
}
h1 {
color: #ff5555;
}
.logo {
width: 100px;
height: 100px;
margin-bottom: 20px;
}
.button {
background-color: #ff5555;
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
font-size: 16px;
margin-top: 20px;
}
.button:hover {
background-color: #ff3333;
}
</style>
</head>
<body>
<div class="container">
<h1>Authentifizierung nicht verfügbar</h1>
<p>Der Authentifizierungsdienst ist derzeit nicht erreichbar. Bitte versuchen Sie es später erneut.</p>
<p>Sie können die Anwendung weiterhin im Nur-Lese-Modus verwenden.</p>
<button class="button" onclick="window.location.href='/'">Zurück zur Startseite</button>
</div>
</body>
</html>
<task_progress>
- [x] Analyze current project structure and requirements
- [x] Examine existing docker-compose.yml
- [x] Check backend and frontend configuration
- [x] Create Dockerfile for frontend
- [x] Create Dockerfile for backend
- [x] Create nginx configuration with fallback
- [x] Create mock data files
- [x] Update docker-compose.yml with Traefik integration
- [x] Implement backend fallback mechanism in frontend
- [ ] Update frontend Dockerfile to include public folder
- [ ] Test the setup
</task_progress>
</write_to_file>

14
public/mock/orgs.json Normal file
View File

@@ -0,0 +1,14 @@
[
{
"id": 1,
"name": "Mock Organization",
"description": "This is a mock organization that appears when the backend is unavailable.",
"leader": "DrKButz",
"members": ["8984c0b5-d912-4462-b189-c864fba4a1af"],
"gallery": [],
"cityStats": {
"influence": 50,
"reputation": 75
}
}
]

37
public/mock/players.json Normal file
View File

@@ -0,0 +1,37 @@
[
{
"uuid": "8984c0b5-d912-4462-b189-c864fba4a1af",
"name": "DrKButz",
"discordId": "mock_discord_id",
"storyMarkdown": "# Mock Player Story\n\nThis is a mock player story that appears when the backend is unavailable.",
"tags": ["admin", "developer"],
"stats": {
"health": 100,
"armor": 50,
"money": 10000
},
"inventory": [
{"id": "weapon_pistol", "name": "Pistol", "quantity": 1},
{"id": "item_medkit", "name": "Medkit", "quantity": 3}
],
"isOnline": false,
"lastSeen": "2025-01-01T12:00:00Z"
},
{
"uuid": "123e4567-e89b-12d3-a456-426614174000",
"name": "TestPlayer",
"discordId": null,
"storyMarkdown": "# Test Player\n\nThis is a test player for mock data purposes.",
"tags": ["player"],
"stats": {
"health": 80,
"armor": 30,
"money": 5000
},
"inventory": [
{"id": "item_food", "name": "Food", "quantity": 5}
],
"isOnline": true,
"lastSeen": "2025-01-01T10:00:00Z"
}
]

13
public/mock/projects.json Normal file
View File

@@ -0,0 +1,13 @@
[
{
"id": 1,
"name": "Mock Project",
"description": "This is a mock project that appears when the backend is unavailable.",
"leader": "DrKButz",
"employees": ["8984c0b5-d912-4462-b189-c864fba4a1af"],
"shopCatalog": [],
"gallery": [],
"hiring": false,
"status": "active"
}
]

54
services/AuthService.ts Normal file
View File

@@ -0,0 +1,54 @@
import { DiscordUser } from '../types';
// Points to the production backend via Traefik
const API_URL = 'https://vollidioten.ceraticsoft.de';
class AuthService {
private user: DiscordUser | null = null;
private listeners: ((user: DiscordUser | null) => void)[] = [];
constructor() {
this.checkSession();
}
getUser(): DiscordUser | null {
return this.user;
}
// Check if session cookie exists and is valid
async checkSession() {
try {
const res = await fetch(`${API_URL}/auth/me`, { credentials: 'include' });
if (res.ok) {
const data = await res.json();
this.user = data;
this.notifyListeners();
}
} catch (e) {
console.log("Auth check failed (Backend might be offline)", e);
}
}
// Redirects to Discord OAuth
async login(): Promise<void> {
window.location.href = `${API_URL}/auth/discord`;
}
logout() {
window.location.href = `${API_URL}/auth/logout`;
}
subscribe(listener: (user: DiscordUser | null) => void) {
this.listeners.push(listener);
listener(this.user);
return () => {
this.listeners = this.listeners.filter(l => l !== listener);
};
}
private notifyListeners() {
this.listeners.forEach(l => l(this.user));
}
}
export const authService = new AuthService();

169
services/DatabaseService.ts Normal file
View File

@@ -0,0 +1,169 @@
import { MOCK_PLAYERS, MOCK_ORGS, MOCK_PROJECTS } from '../constants';
import { Player, Organization, Project } from '../types';
const API_URL = 'https://vollidioten.ceraticsoft.de/api';
const MOCK_DATA_HEADER = 'X-Mock-Data';
class DatabaseService {
private players: Player[] = MOCK_PLAYERS;
private orgs: Organization[] = MOCK_ORGS;
private projects: Project[] = MOCK_PROJECTS;
private listeners: Function[] = [];
constructor() {
this.fetchAll();
}
// Try to fetch real data from backend
async fetchAll() {
try {
console.log("Fetching data from API...");
const [pRes, oRes, prRes] = await Promise.all([
fetch(`${API_URL}/players`),
fetch(`${API_URL}/orgs`),
fetch(`${API_URL}/projects`)
]);
// Check if we're getting mock data from nginx fallback
const isMockData = pRes.headers.get(MOCK_DATA_HEADER) === 'true' ||
oRes.headers.get(MOCK_DATA_HEADER) === 'true' ||
prRes.headers.get(MOCK_DATA_HEADER) === 'true';
if (pRes.ok && oRes.ok && prRes.ok) {
const playersData = await pRes.json();
const orgsData = await oRes.json();
const projectsData = await prRes.json();
console.log(`Loaded ${playersData.length} players, ${orgsData.length} orgs, ${projectsData.length} projects from API`);
this.players = playersData;
this.orgs = orgsData;
this.projects = projectsData;
this.notify();
if (isMockData) {
console.warn("Backend unavailable - using mock data from nginx fallback");
} else {
console.log("✅ Connected to Backend Database - using real data");
}
} else {
console.warn(`API returned errors: players=${pRes.status}, orgs=${oRes.status}, projects=${prRes.status}`);
console.warn("Using built-in mock data");
// Data is already set to mock data in constructor
}
} catch (e) {
console.warn("Backend not available, using built-in Mock Data.", e);
// Fallback is already set in constructor
}
}
subscribe(cb: Function) {
this.listeners.push(cb);
return () => {
this.listeners = this.listeners.filter(l => l !== cb);
};
}
notify() {
this.listeners.forEach(cb => cb());
}
// --- READ ---
getPlayers(): Player[] { return this.players; }
getOrgs(): Organization[] { return this.orgs; }
getProjects(): Project[] { return this.projects; }
getPlayer(uuid: string): Player | undefined {
return this.players.find(p => p.uuid === uuid);
}
getOrg(id: string): Organization | undefined {
return this.orgs.find(o => o.id === id);
}
getProject(id: string): Project | undefined {
return this.projects.find(p => p.id === id);
}
// --- WRITE (Optimistic UI + API Call) ---
async updatePlayer(uuid: string, updates: Partial<Player>) {
// 1. Optimistic Update
this.players = this.players.map(p => p.uuid === uuid ? { ...p, ...updates } : p);
this.notify();
// 2. API Call
try {
await fetch(`${API_URL}/players/${uuid}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
credentials: 'include' // Sends session cookie
});
} catch (e) {
console.error("Failed to save player update", e);
}
}
async updateProject(id: string, updates: Partial<Project>) {
this.projects = this.projects.map(p => p.id === id ? { ...p, ...updates } : p);
this.notify();
try {
await fetch(`${API_URL}/projects/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
credentials: 'include'
});
} catch (e) {
console.error("Failed to save project update", e);
}
}
// --- PROJECT MANAGEMENT ---
async createProject(projectData: { title: string; description: string; category: Project['category'] }): Promise<boolean> {
try {
const response = await fetch(`${API_URL}/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(projectData),
});
if (response.ok) {
// Reload data to include the new project
await this.fetchAll();
return true;
}
return false;
} catch (e) {
console.error("Failed to create project", e);
return false;
}
}
// --- LINKING ---
async linkPlayer(playerUuid: string): Promise<boolean> {
try {
const res = await fetch(`${API_URL}/link-user`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ playerUuid }),
credentials: 'include'
});
if (res.ok) {
// Reload data to reflect linking (might unlock edit buttons)
await this.fetchAll();
return true;
}
return false;
} catch(e) {
console.error("Link failed", e);
return false;
}
}
}
export const dbService = new DatabaseService();

93
test-docker-setup.sh Normal file
View File

@@ -0,0 +1,93 @@
#!/bin/bash
# Test script for Docker setup
echo "🚀 Testing Docker setup for Vollidioten project..."
# Check if Docker is running
if ! docker info > /dev/null 2>&1; then
echo "❌ Docker is not running. Please start Docker first."
exit 1
fi
# Check if Traefik network exists
if ! docker network ls | grep -q "traefik_network"; then
echo "❌ Traefik network not found. Creating it..."
docker network create traefik_network
if [ $? -eq 0 ]; then
echo "✅ Traefik network created successfully."
else
echo "❌ Failed to create Traefik network."
exit 1
fi
fi
# Test building the containers
echo "📦 Building Docker containers..."
docker-compose build
if [ $? -eq 0 ]; then
echo "✅ Docker containers built successfully."
else
echo "❌ Failed to build Docker containers."
exit 1
fi
# Check if required files exist
echo "📁 Checking required files..."
required_files=(
"Dockerfile.frontend"
"Dockerfile.backend"
"docker-compose.yml"
"nginx.conf"
"public/mock/players.json"
"public/mock/orgs.json"
"public/mock/projects.json"
"public/auth-unavailable.html"
)
missing_files=()
for file in "${required_files[@]}"; do
if [ ! -f "$file" ]; then
missing_files+=("$file")
fi
done
if [ ${#missing_files[@]} -eq 0 ]; then
echo "✅ All required files are present."
else
echo "❌ Missing files:"
for file in "${missing_files[@]}"; do
echo " - $file"
done
exit 1
fi
echo "🎉 Docker setup test completed successfully!"
echo ""
echo "📋 Summary:"
echo " - Docker is running"
echo " - Traefik network is available"
echo " - All required files are present"
echo " - Docker containers can be built"
echo ""
echo "🚀 You can now start the services with:"
echo " docker-compose up -d"
echo ""
echo "🌐 The application will be available at:"
echo " https://vollidioten.ceraticsoft.de"
<task_progress>
- [x] Analyze current project structure and requirements
- [x] Examine existing docker-compose.yml
- [x] Check backend and frontend configuration
- [x] Create Dockerfile for frontend
- [x] Create Dockerfile for backend
- [x] Create nginx configuration with fallback
- [x] Create mock data files
- [x] Update docker-compose.yml with Traefik integration
- [x] Implement backend fallback mechanism in frontend
- [x] Update frontend Dockerfile to include public folder
- [x] Create test script
- [ ] Test the setup
</task_progress>
</write_to_file>

View File

@@ -75,3 +75,11 @@ export interface Project {
gallery?: string[];
bannerUrl?: string;
}
export interface DiscordUser {
id: string;
username: string;
discriminator: string;
avatarUrl: string;
linkedPlayerUuid?: string | null;
}