mirror of
https://github.com/ceratic/project_vollidioten_website.git
synced 2026-05-14 00:16:47 +02:00
Merge pull request #1 from ceratic/ff
feat: Add DatabaseManager and LinkPlayer components, implement authen…
This commit is contained in:
5
.dockerignore
Normal file
5
.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
||||
# .dockerignore
|
||||
node_modules
|
||||
database
|
||||
|
||||
*/node_modules*
|
||||
14
.env
Normal file
14
.env
Normal 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
77
App.tsx
@@ -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
210
DOCKER-SETUP.md
Normal 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
20
Dockerfile.backend
Normal 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
33
Dockerfile.frontend
Normal 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
226
backend/database.js
Normal 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
17
backend/package.json
Normal 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
923
backend/server.js
Normal 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}`);
|
||||
});
|
||||
230
components/BannerManagementModal.tsx
Normal file
230
components/BannerManagementModal.tsx
Normal 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">×</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;
|
||||
218
components/CreateProjectModal.tsx
Normal file
218
components/CreateProjectModal.tsx
Normal 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">×</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;
|
||||
131
components/DeleteProjectModal.tsx
Normal file
131
components/DeleteProjectModal.tsx
Normal 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
76
components/EditModal.tsx
Normal 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">×</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;
|
||||
203
components/EmployeeManagementModal.tsx
Normal file
203
components/EmployeeManagementModal.tsx
Normal 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">×</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;
|
||||
205
components/GalleryManagementModal.tsx
Normal file
205
components/GalleryManagementModal.tsx
Normal 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">×</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;
|
||||
@@ -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>
|
||||
)
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
116
components/MarkdownEditor.tsx
Normal file
116
components/MarkdownEditor.tsx
Normal 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('', '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;
|
||||
392
components/ShopManagementModal.tsx
Normal file
392
components/ShopManagementModal.tsx
Normal 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">×</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;
|
||||
@@ -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
77
docker-compose.yml
Normal 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
|
||||
@@ -67,8 +67,10 @@
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<link rel="stylesheet" href="/index.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
<script type="module" src="/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
51
nginx.conf
Normal file
51
nginx.conf
Normal 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
1251
pages/Admin.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
25
pages/DatabaseManager.tsx
Normal 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
201
pages/LinkPlayer.tsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
70
public/auth-unavailable.html
Normal file
70
public/auth-unavailable.html
Normal 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
14
public/mock/orgs.json
Normal 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
37
public/mock/players.json
Normal 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
13
public/mock/projects.json
Normal 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
54
services/AuthService.ts
Normal 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
169
services/DatabaseService.ts
Normal 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
93
test-docker-setup.sh
Normal 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>
|
||||
Reference in New Issue
Block a user