feat: add world map functionality and admin map management

- Added world map page with interactive marker display
- Implemented admin map management for marker CRUD operations
- Added map layers and markers seed data to database
- Integrated new routes for map functionality
- Updated database configuration for production environment
- Added documentation page route
- Enhanced package.json with required dependencies for map features
This commit is contained in:
Lars Behrends
2026-01-02 05:08:07 +01:00
parent ea2b803534
commit 065a6e657d
152 changed files with 5024 additions and 35 deletions

12
App.tsx
View File

@@ -14,6 +14,10 @@ import DatapackGenerator from './pages/DatapackGenerator';
import DatabaseManager from './pages/DatabaseManager';
import LinkPlayer from './pages/LinkPlayer';
import AdminPage from './pages/Admin';
import AdminMapManagement from './pages/AdminMapManagement';
import WorldMap from './pages/WorldMap';
import EditMarker from './pages/EditMarker';
import DocumentationPage from './pages/Dokumentation';
import { dbService } from './services/DatabaseService';
import { authService } from './services/AuthService';
import { DiscordUser } from './types';
@@ -131,6 +135,14 @@ function App() {
{/* Admin Routes */}
<Route path="/admin" element={<AdminPage onBack={() => navigate('/')} />} />
<Route path="/admin/map-management" element={<AdminMapManagement />} />
{/* Map Route */}
<Route path="/world-map" element={<WorldMap />} />
<Route path="/admin/edit-marker/:markerId" element={<EditMarker />} />
{/* Dokumentation Route */}
<Route path="/dokumentation" element={<DocumentationPage />} />
{/* Fallback */}
<Route path="*" element={

90
MAP_SETUP_GUIDE.md Normal file
View File

@@ -0,0 +1,90 @@
# Map Setup Guide
This guide explains how to set up the world map feature with Xaero's World Map PNG files.
## Prerequisites
1. **Xaero's World Map Mod**: Install the Xaero's World Map mod on your Minecraft server
2. **Map Generation**: Generate the world map by exploring your Minecraft world
3. **PNG Export**: Export the map as PNG files using Xaero's World Map
## Step 1: Generate Map in Minecraft
1. Install Xaero's World Map mod on your Minecraft server
2. Have players explore the world to generate map data
3. The mod will automatically create PNG files in the server's data directory
## Step 2: Locate PNG Files
The PNG files are typically located in:
```
<server_directory>/world/XaeroWaypoints/dim0/
```
Look for files named like:
- `xaero_map_0_0.png`
- `xaero_map_1_0.png`
- `xaero_map_0_1.png`
- etc.
## Step 3: Upload Files to Backend Container
The map tiles should be uploaded to the backend container's `uploads/map/` directory:
1. **For Docker Setup**: Upload PNG files to the `uploads/map/` directory inside the backend container
2. **For Direct Upload**: Use the file upload functionality in your admin panel to upload PNG files to `uploads/map/`
3. **File Naming**: Ensure files follow the pattern `xaero_map_x_y.png` (e.g., `xaero_map_0_0.png`, `xaero_map_1_0.png`)
**Note**: The map assembly process automatically looks for tiles in the backend's `uploads/map/` directory.
## Step 4: Run Map Assembly
1. Go to `/admin/map-management` in your admin panel
2. Click "Karten-Zusammenstellung starten"
3. The system will automatically process all PNG files and create the web map
## Troubleshooting
### No Tiles Found
- Ensure PNG files are in the correct directory
- Check that files follow the naming pattern `xaero_map_x_y.png`
- Verify file permissions allow the server to read the files
### Assembly Fails
- Check server logs for error messages
- Ensure sufficient disk space for processing
- Verify PNG files are not corrupted
### Map Not Displaying
- Check that the assembly completed successfully
- Verify the `map-metadata.json` file was created
- Ensure the frontend can access the generated map tiles
## Example Directory Structure
```
projekt_vollidion_website/
├── map-tiles/
│ ├── xaero_map_0_0.png
│ ├── xaero_map_1_0.png
│ ├── xaero_map_0_1.png
│ ├── xaero_map_1_1.png
│ └── ...
├── backend/
├── frontend/
└── ...
```
## Performance Notes
- Large maps (100+ tiles) may take several minutes to process
- The assembly process is memory-intensive for very large maps
- Consider using a powerful server for initial map generation
## Support
If you encounter issues:
1. Check the browser console for JavaScript errors
2. Review the server logs for backend errors
3. Verify all PNG files are valid and accessible
4. Ensure the map-tiles directory exists and is writable

View File

@@ -2,9 +2,9 @@ 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 || '',
host: process.env.DB_HOST || '192.168.1.102',
user: process.env.DB_USER || 'obsidian_user',
password: process.env.DB_PASS || 'obsidian_pass',
database: process.env.DB_NAME || 'obsidian_db',
waitForConnections: true,
connectionLimit: 10,
@@ -90,6 +90,41 @@ const SEED_PROJECTS = [
}
];
const SEED_MAP_LAYERS = [
{ id: 'layer-1', name: 'Städte', description: 'Alle Städte und Siedlungen', order_index: 1 },
{ id: 'layer-2', name: 'Points of Interest', description: 'Besondere Orte und Sehenswürdigkeiten', order_index: 2 },
{ id: 'layer-3', name: 'Spieler-Häuser', description: 'Spieler-Wohnsitze und Häuser', order_index: 3 }
];
const SEED_MAP_MARKERS = [
{
id: 'marker-1',
name: 'Provisorium Null',
type: 'city',
x_coord: -2560,
z_coord: 512,
description: 'Die erste Siedlung der neuen Ära',
linked_entity_type: 'organization',
linked_entity_id: 'org-3',
icon_type: 'city',
color: '#2563eb',
is_public: 1
},
{
id: 'marker-2',
name: 'Sakura',
type: 'city',
x_coord: 1536,
z_coord: -512,
description: 'Eine dunkle, biolumineszente Hafenstadt',
linked_entity_type: 'organization',
linked_entity_id: 'org-4',
icon_type: 'city',
color: '#dc2626',
is_public: 1
}
];
// Retry connection logic for Docker
function init() {
const tryConnect = () => {
@@ -169,6 +204,34 @@ function setupTables() {
logoImageId VARCHAR(50),
shopCatalog JSON,
gallery JSON
)`,
`CREATE TABLE IF NOT EXISTS map_markers (
id VARCHAR(50) PRIMARY KEY,
name VARCHAR(255),
type VARCHAR(50), -- 'city', 'poi', 'player_home', 'waypoint'
x_coord INTEGER,
z_coord INTEGER,
description TEXT,
linked_entity_type VARCHAR(50), -- 'city', 'organization', 'player'
linked_entity_id VARCHAR(50),
icon_type VARCHAR(50), -- 'city', 'house', 'chest', 'flag', etc.
color VARCHAR(7), -- hex color code
is_public TINYINT DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS map_layers (
id VARCHAR(50) PRIMARY KEY,
name VARCHAR(255),
description TEXT,
is_active TINYINT DEFAULT 1,
order_index INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS map_metadata (
key VARCHAR(100) PRIMARY KEY,
value TEXT,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
)`
];
@@ -212,6 +275,28 @@ function seedData() {
});
}
});
// Seed map layers
pool.query("SELECT COUNT(*) as count FROM map_layers", (err, rows) => {
if (!err && rows[0].count === 0) {
console.log("Seeding Map Layers...");
SEED_MAP_LAYERS.forEach(layer => {
pool.query("INSERT INTO map_layers VALUES (?, ?, ?, ?, ?, ?)",
[layer.id, layer.name, layer.description, 1, layer.order_index, new Date().toISOString().slice(0, 19).replace('T', ' ')]);
});
}
});
// Seed map markers
pool.query("SELECT COUNT(*) as count FROM map_markers", (err, rows) => {
if (!err && rows[0].count === 0) {
console.log("Seeding Map Markers...");
SEED_MAP_MARKERS.forEach(marker => {
pool.query("INSERT INTO map_markers (id, name, type, x_coord, z_coord, description, linked_entity_type, linked_entity_id, icon_type, color, is_public) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
[marker.id, marker.name, marker.type, marker.x_coord, marker.z_coord, marker.description, marker.linked_entity_type, marker.linked_entity_id, marker.icon_type, marker.color, marker.is_public]);
});
}
});
}
// Wrapper to mimic SQLite API for easy migration in server.js

View File

@@ -0,0 +1,52 @@
#!/usr/bin/env node
// Detailed debug script for map processing
const debugInterface = require('./server.js');
const { db } = require('./database');
console.log('🔧 Detailed Map Processor Debug Console');
console.log('=====================================');
async function runDetailedDebug() {
try {
console.log('1. Testing database connection...');
const dbTest = await new Promise((resolve, reject) => {
db.get("SELECT 1 as test", [], (err, row) => {
if (err) reject(err);
else resolve(row);
});
});
console.log('✅ Database connection successful:', dbTest);
console.log('\n2. Testing tile discovery...');
const tiles = await debugInterface.getTiles();
console.log('✅ Tile discovery completed');
console.log('\n3. Testing map metadata...');
const metadata = await debugInterface.getMetadata();
console.log('✅ Metadata retrieval completed');
console.log('\n4. Testing coordinate conversion...');
await debugInterface.testCoords(0, 0);
await debugInterface.testCoords(1000, 1000);
console.log('✅ Coordinate conversion completed');
console.log('\n5. Attempting map assembly...');
await debugInterface.assembleMap();
} catch (error) {
console.error('\n❌ Detailed Error Analysis:');
console.error('Error Type:', error.constructor.name);
console.error('Error Message:', error.message);
console.error('Error Stack:', error.stack);
// Additional debugging info
console.error('\n🔍 Additional Debug Info:');
console.error('Error Properties:', Object.getOwnPropertyNames(error));
console.error('Error Code:', error.code);
console.error('Error SQL:', error.sql);
console.error('Error SQL Message:', error.sqlMessage);
}
}
runDetailedDebug();

16
backend/debug-map.js Normal file
View File

@@ -0,0 +1,16 @@
#!/usr/bin/env node
// Debug script for map processing
const debugInterface = require('./server.js');
console.log('🔧 Map Processor Debug Console');
console.log('Available commands:');
console.log(' assembleMap() - Assemble world map');
console.log(' getTiles() - Get all map tiles');
console.log(' getMetadata() - Get map metadata');
console.log(' testCoords(x, z) - Test coordinate conversion');
console.log('');
// Run assembleMap by default
console.log('🚀 Running map assembly...');
debugInterface.assembleMap();

248
backend/map-processor.js Normal file
View File

@@ -0,0 +1,248 @@
const sharp = require('sharp');
const fs = require('fs').promises;
const path = require('path');
const { db } = require('./database');
class MapProcessor {
constructor() {
this.mapDir = path.join(__dirname, 'uploads', 'map');
this.outputDir = path.join(__dirname, 'uploads', 'processed');
this.worldMapPath = path.join(this.outputDir, 'world-map.png');
this.tileSize = 1024; // Xaero's World Map tiles are 1024x1024
}
async init() {
try {
await fs.mkdir(this.outputDir, { recursive: true });
console.log('Map processor initialized');
} catch (error) {
console.error('Error initializing map processor:', error);
}
}
async getMapTiles() {
try {
const files = await fs.readdir(this.mapDir);
const tileFiles = files.filter(file => file.endsWith('.png'));
// Parse tile coordinates from filenames like "0_3_x-3584_z1536.png"
const tiles = tileFiles.map(file => {
const match = file.match(/(\d+)_(\d+)_x(-?\d+)_z(-?\d+)\.png/);
if (match) {
return {
filename: file,
gridX: parseInt(match[1]),
gridZ: parseInt(match[2]),
x: parseInt(match[3]),
z: parseInt(match[4])
};
}
return null;
}).filter(tile => tile !== null);
return tiles.sort((a, b) => {
// Sort by grid coordinates
if (a.gridX !== b.gridX) return a.gridX - b.gridX;
return a.gridZ - b.gridZ;
});
} catch (error) {
console.error('Error reading map tiles:', error);
return [];
}
}
async calculateMapDimensions(tiles) {
if (tiles.length === 0) return { width: 0, height: 0, offsetX: 0, offsetZ: 0 };
// Find the center tile (3_1_x-512_z-512.png)
//const centerTile = tiles.find(t => t.filename === '3_1_x-512_z-512.png');
let centerX, centerZ;
//if (centerTile) {
// Use the specified tile as center point
// centerX = centerTile.x + (this.tileSize / 2); // Center of the tile
// centerZ = centerTile.z + (this.tileSize / 2); // Center of the tile
// console.log(`Using tile ${centerTile.filename} as center point at (${centerX}, ${centerZ})`);
//} else {
// Fallback to original logic if center tile not found
centerX = 0;
centerZ = 0;
console.log('Center tile 3_1_x-512_z-512.png not found, using origin (0,0) as center');
//}
// Calculate bounds relative to the center
const minX = Math.min(...tiles.map(t => t.x));
const maxX = Math.max(...tiles.map(t => t.x + this.tileSize));
const minZ = Math.min(...tiles.map(t => t.z));
const maxZ = Math.max(...tiles.map(t => t.z + this.tileSize));
const width = maxX - minX;
const height = maxZ - minZ;
// Calculate offsets to make the center tile the origin (0,0)
// We need to shift everything so that the center point becomes (0,0)
const offsetX = -centerX;
const offsetZ = -centerZ;
return { width, height, offsetX, offsetZ };
}
async assembleWorldMap() {
console.log('Starting world map assembly...');
const tiles = await this.getMapTiles();
if (tiles.length === 0) {
console.log('No map tiles found');
return false;
}
console.log(`Found ${tiles.length} map tiles`);
const { width, height, offsetX, offsetZ } = await this.calculateMapDimensions(tiles);
console.log(`Map dimensions: ${width}x${height}px`);
console.log(`Offset: X=${offsetX}, Z=${offsetZ}`);
// Create a blank canvas for the world map
const worldMap = sharp({
create: {
width: width,
height: height,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 0 }
}
});
// Composite each tile onto the world map
const overlays = await Promise.all(tiles.map(async (tile) => {
const tilePath = path.join(this.mapDir, tile.filename);
const x = tile.x + offsetX;
const y = tile.z + offsetZ;
return {
input: tilePath,
left: x,
top: y
};
}));
try {
await worldMap
.composite(overlays)
.png()
.toFile(this.worldMapPath);
// Update map metadata in database
await this.updateMapMetadata({
width: width,
height: height,
offsetX: offsetX,
offsetZ: offsetZ,
tileSize: this.tileSize,
lastUpdated: new Date().toISOString()
});
console.log(`World map assembled: ${this.worldMapPath}`);
return true;
} catch (error) {
console.error('Error assembling world map:', error);
return false;
}
}
async updateMapMetadata(metadata) {
return new Promise((resolve, reject) => {
const keys = Object.keys(metadata);
const values = Object.values(metadata);
const updatePromises = keys.map((key, index) => {
return new Promise((resolve, reject) => {
db.run(
`INSERT INTO map_metadata (\`key\`, value) VALUES (?, ?)
ON DUPLICATE KEY UPDATE value = VALUES(value)`,
[key, values[index]],
(err) => {
if (err) reject(err);
else resolve();
}
);
});
});
Promise.all(updatePromises)
.then(() => resolve())
.catch(reject);
});
}
async getMapMetadata() {
return new Promise((resolve, reject) => {
db.all('SELECT `key`, value FROM map_metadata', [], (err, rows) => {
if (err) reject(err);
else {
const metadata = {};
rows.forEach(row => {
metadata[row.key] = row.value;
});
resolve(metadata);
}
});
});
}
async getWorldMapUrl() {
const metadata = await this.getMapMetadata();
if (metadata.width && metadata.height) {
return '/api/map/world-map';
}
return null;
}
async getCoordinateConversion(x, z) {
const metadata = await this.getMapMetadata();
if (!metadata.width || !metadata.height) {
return { x: 0, y: 0 };
}
const offsetX = parseInt(metadata.offsetX) || 0;
const offsetZ = parseInt(metadata.offsetZ) || 0;
const width = parseInt(metadata.width);
const height = parseInt(metadata.height);
// Convert Minecraft coordinates to pixel coordinates
const pixelX = x + offsetX;
const pixelY = z + offsetZ;
// Convert to percentage for frontend (0-100%)
const percentX = (pixelX / width) * 100;
const percentY = (pixelY / height) * 100;
return {
x: percentX,
y: percentY,
pixelX: pixelX,
pixelY: pixelY
};
}
async getMarkersWithCoordinates() {
return new Promise((resolve, reject) => {
db.all('SELECT * FROM map_markers WHERE is_public = 1', [], async (err, markers) => {
if (err) reject(err);
else {
const markersWithCoords = await Promise.all(markers.map(async (marker) => {
const coords = await this.getCoordinateConversion(marker.x_coord, marker.z_coord);
return {
...marker,
coordinates: coords
};
}));
resolve(markersWithCoords);
}
});
});
}
}
module.exports = MapProcessor;

View File

@@ -8,6 +8,7 @@ const multer = require('multer');
const path = require('path');
const fs = require('fs');
const { db, init } = require('./database');
const MapProcessor = require('./map-processor');
const app = express();
const PORT = 3000;
@@ -1951,6 +1952,396 @@ app.post('/api/data', (req, res) => { const { eventType, player, uuid, timestamp
// Helper function to convert images to WebP
function convertToWebP(inputPath, outputPath) {
return new Promise((resolve, reject) => {
const sharp = require('sharp');
sharp(inputPath)
.webp({ quality: 80 })
.toFile(outputPath)
.then(info => {
resolve(info.size);
})
.catch(reject);
});
}
// Initialize Map Processor
const mapProcessor = new MapProcessor();
mapProcessor.init();
// Debug console interface for map processing
// Available when running as standalone script or when required as module
const debugInterface = {
assembleMap: async () => {
try {
console.log('🔄 Starting map assembly...');
const success = await mapProcessor.assembleWorldMap();
if (success) {
console.log('✅ Map assembly completed successfully');
} else {
console.log('❌ Map assembly failed');
}
} catch (error) {
console.error('❌ Error during map assembly:', error.message);
}
},
getTiles: async () => {
try {
const tiles = await mapProcessor.getMapTiles();
console.log(`📋 Found ${tiles.length} map tiles:`);
tiles.forEach((tile, index) => {
if (index < 5) { // Show first 5 tiles
console.log(` ${tile.filename}: X=${tile.x}, Z=${tile.z}`);
}
});
if (tiles.length > 5) {
console.log(` ... and ${tiles.length - 5} more tiles`);
}
} catch (error) {
console.error('❌ Error getting tiles:', error.message);
}
},
getMetadata: async () => {
try {
const metadata = await mapProcessor.getMapMetadata();
console.log('📊 Map Metadata:');
console.log(` Width: ${metadata.width}px`);
console.log(` Height: ${metadata.height}px`);
console.log(` Offset X: ${metadata.offsetX}`);
console.log(` Offset Z: ${metadata.offsetZ}`);
console.log(` Tile Size: ${metadata.tileSize}px`);
console.log(` Last Updated: ${metadata.lastUpdated || 'Never'}`);
} catch (error) {
console.error('❌ Error getting metadata:', error.message);
}
},
testCoords: async (x, z) => {
try {
const coords = await mapProcessor.getCoordinateConversion(x, z);
console.log(`📍 Coordinate Conversion for (${x}, ${z}):`);
console.log(` Pixel X: ${coords.pixelX}`);
console.log(` Pixel Y: ${coords.pixelY}`);
console.log(` Percentage X: ${coords.x.toFixed(2)}%`);
console.log(` Percentage Y: ${coords.y.toFixed(2)}%`);
} catch (error) {
console.error('❌ Error converting coordinates:', error.message);
}
}
};
// Make debug interface available globally
global.mapProcessor = mapProcessor;
global.assembleMap = debugInterface.assembleMap;
global.getTiles = debugInterface.getTiles;
global.getMetadata = debugInterface.getMetadata;
global.testCoords = debugInterface.testCoords;
// Export debug interface for module usage
module.exports = debugInterface;
// If running as standalone script, show console interface
if (require.main === module) {
console.log('🔧 Map Processor Debug Console');
console.log('Available commands:');
console.log(' assembleMap() - Assemble world map');
console.log(' getTiles() - Get all map tiles');
console.log(' getMetadata() - Get map metadata');
console.log(' testCoords(x, z) - Test coordinate conversion');
console.log('');
console.log('💡 Usage examples:');
console.log(' node -e "require(\'./server.js\').assembleMap()"');
console.log(' node -e "require(\'./server.js\').getTiles()"');
console.log(' node -e "require(\'./server.js\').testCoords(0, 0)"');
console.log('');
console.log('🚀 Starting debug session...');
}
// === MAP API ===
// Get world map metadata
app.get('/api/map/metadata', (req, res) => {
mapProcessor.getMapMetadata()
.then(metadata => {
res.json({
width: parseInt(metadata.width) || 0,
height: parseInt(metadata.height) || 0,
offsetX: parseInt(metadata.offsetX) || 0,
offsetZ: parseInt(metadata.offsetZ) || 0,
tileSize: parseInt(metadata.tileSize) || 1024,
lastUpdated: metadata.lastUpdated || null
});
})
.catch(err => {
console.error('Error getting map metadata:', err);
res.status(500).json({error: 'Fehler beim Laden der Karten-Metadaten'});
});
});
// Get assembled world map
app.get('/api/map/world-map', (req, res) => {
const worldMapPath = path.join(__dirname, 'uploads', 'processed', 'world-map.png');
if (fs.existsSync(worldMapPath)) {
res.sendFile(worldMapPath);
} else {
res.status(404).json({error: 'Weltkarte nicht gefunden. Bitte zuerst zusammenstellen.'});
}
});
// Assemble world map from tiles
app.post('/api/map/assemble', (req, res) => {
if (!req.isAuthenticated()) return res.status(401).send();
if (!req.user.isAdmin) return res.status(403).json({error: 'Admin-Berechtigung erforderlich'});
console.log('🔄 Starting map assembly process...');
// Check if database is ready before assembling
db.get("SELECT 1", [], (err) => {
if (err) {
console.error('❌ Database not ready for map assembly:', err);
return res.status(500).json({
error: 'Datenbank nicht bereit für Karten-Zusammenstellung',
details: err.message,
stack: err.stack
});
}
console.log('✅ Database connection verified');
mapProcessor.assembleWorldMap()
.then(success => {
if (success) {
console.log('✅ Map assembly completed successfully');
res.json({success: true, message: 'Weltkarte erfolgreich zusammengestellt'});
} else {
console.error('❌ Map assembly failed - unknown error');
res.status(500).json({
error: 'Karten-Zusammenstellung fehlgeschlagen',
details: 'Unbekannter Fehler bei der Karten-Zusammenstellung'
});
}
})
.catch(err => {
console.error('❌ Error assembling world map:', err);
res.status(500).json({
error: 'Fehler beim Zusammensetzen der Weltkarte',
details: err.message,
stack: err.stack,
type: err.constructor.name
});
});
});
});
// Get all markers with coordinates
app.get('/api/map/markers', (req, res) => {
mapProcessor.getMarkersWithCoordinates()
.then(markers => {
res.json(markers);
})
.catch(err => {
console.error('Error getting markers:', err);
res.status(500).json({error: 'Fehler beim Laden der Marker'});
});
});
// Get public markers only
app.get('/api/map/markers/public', (req, res) => {
db.all('SELECT * FROM map_markers WHERE is_public = 1', [], async (err, markers) => {
if (err) {
console.error('Error getting public markers:', err);
return res.status(500).json({error: 'Fehler beim Laden der öffentlichen Marker'});
}
const markersWithCoords = await Promise.all(markers.map(async (marker) => {
const coords = await mapProcessor.getCoordinateConversion(marker.x_coord, marker.z_coord);
return {
...marker,
coordinates: coords
};
}));
res.json(markersWithCoords);
});
});
// Create new marker (Admin only)
app.post('/api/map/markers', (req, res) => {
if (!req.isAuthenticated()) return res.status(401).send();
if (!req.user.isAdmin) return res.status(403).json({error: 'Admin-Berechtigung erforderlich'});
const { name, type, x_coord, z_coord, description, linked_entity_type, linked_entity_id, icon_type, color } = req.body;
if (!name || x_coord === undefined || z_coord === undefined) {
return res.status(400).json({error: 'Name und Koordinaten sind erforderlich'});
}
// Generate unique ID
const markerId = 'marker_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
const markerData = {
id: markerId,
name: name.trim(),
type: type || 'poi',
x_coord: parseInt(x_coord),
z_coord: parseInt(z_coord),
description: description || '',
linked_entity_type: linked_entity_type || null,
linked_entity_id: linked_entity_id || null,
icon_type: icon_type || 'flag',
color: color || '#2563eb',
is_public: 1
};
db.run(`INSERT INTO map_markers (id, name, type, x_coord, z_coord, description, linked_entity_type, linked_entity_id, icon_type, color, is_public)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[markerData.id, markerData.name, markerData.type, markerData.x_coord, markerData.z_coord,
markerData.description, markerData.linked_entity_type, markerData.linked_entity_id,
markerData.icon_type, markerData.color, markerData.is_public],
function(err) {
if (err) {
console.error('Error creating marker:', err);
return res.status(500).json({error: 'Fehler beim Erstellen des Markers'});
}
res.json({
success: true,
markerId: markerId,
message: 'Marker erfolgreich erstellt'
});
}
);
});
// Update marker (Admin only)
app.put('/api/map/markers/:markerId', (req, res) => {
if (!req.isAuthenticated()) return res.status(401).send();
if (!req.user.isAdmin) return res.status(403).json({error: 'Admin-Berechtigung erforderlich'});
const { markerId } = req.params;
const updates = req.body;
// Build dynamic update query
const allowedFields = ['name', 'type', 'x_coord', 'z_coord', 'description', 'linked_entity_type', 'linked_entity_id', 'icon_type', 'color', 'is_public'];
const updateFields = [];
const values = [];
for (const field of allowedFields) {
if (updates[field] !== undefined) {
updateFields.push(`${field} = ?`);
values.push(field.includes('_coord') ? parseInt(updates[field]) : updates[field]);
}
}
if (updateFields.length === 0) {
return res.status(400).json({error: 'Keine gültigen Felder zum Aktualisieren'});
}
const query = `UPDATE map_markers SET ${updateFields.join(', ')} WHERE id = ?`;
values.push(markerId);
db.run(query, values, function(err) {
if (err) {
console.error('Error updating marker:', err);
return res.status(500).json({error: 'Fehler beim Aktualisieren des Markers'});
}
if (this.changes === 0) {
return res.status(404).json({error: 'Marker nicht gefunden'});
}
res.json({success: true, message: 'Marker erfolgreich aktualisiert'});
});
});
// Delete marker (Admin only)
app.delete('/api/map/markers/:markerId', (req, res) => {
if (!req.isAuthenticated()) return res.status(401).send();
if (!req.user.isAdmin) return res.status(403).json({error: 'Admin-Berechtigung erforderlich'});
const { markerId } = req.params;
db.run("DELETE FROM map_markers WHERE id = ?", [markerId], function(err) {
if (err) {
console.error('Error deleting marker:', err);
return res.status(500).json({error: 'Fehler beim Löschen des Markers'});
}
if (this.changes === 0) {
return res.status(404).json({error: 'Marker nicht gefunden'});
}
res.json({success: true, message: 'Marker erfolgreich gelöscht'});
});
});
// Get map layers
app.get('/api/map/layers', (req, res) => {
db.all('SELECT * FROM map_layers ORDER BY order_index', [], (err, rows) => {
if (err) {
console.error('Error getting map layers:', err);
return res.status(500).json({error: 'Fehler beim Laden der Karten-Layer'});
}
res.json(rows);
});
});
// Update map layer visibility/order (Admin only)
app.put('/api/map/layers/:layerId', (req, res) => {
if (!req.isAuthenticated()) return res.status(401).send();
if (!req.user.isAdmin) return res.status(403).json({error: 'Admin-Berechtigung erforderlich'});
const { layerId } = req.params;
const { is_active, order_index } = req.body;
const updateFields = [];
const values = [];
if (is_active !== undefined) {
updateFields.push('is_active = ?');
values.push(is_active ? 1 : 0);
}
if (order_index !== undefined) {
updateFields.push('order_index = ?');
values.push(parseInt(order_index));
}
if (updateFields.length === 0) {
return res.status(400).json({error: 'Keine gültigen Felder zum Aktualisieren'});
}
const query = `UPDATE map_layers SET ${updateFields.join(', ')} WHERE id = ?`;
values.push(layerId);
db.run(query, values, function(err) {
if (err) {
console.error('Error updating map layer:', err);
return res.status(500).json({error: 'Fehler beim Aktualisieren des Layers'});
}
if (this.changes === 0) {
return res.status(404).json({error: 'Layer nicht gefunden'});
}
res.json({success: true, message: 'Layer erfolgreich aktualisiert'});
});
});
// Coordinate conversion endpoint
app.get('/api/map/convert-coords', (req, res) => {
const { x, z } = req.query;
if (x === undefined || z === undefined) {
return res.status(400).json({error: 'X und Z Koordinaten sind erforderlich'});
}
mapProcessor.getCoordinateConversion(parseInt(x), parseInt(z))
.then(coords => {
res.json(coords);
})
.catch(err => {
console.error('Error converting coordinates:', err);
res.status(500).json({error: 'Fehler bei der Koordinaten-Konvertierung'});
});
});
// Serve uploaded files statically
app.use('/uploads', express.static(UPLOAD_DIR));

View File

@@ -54,7 +54,7 @@ const Layout: React.FC<LayoutProps> = ({ children, activeTab, onNavigate }) => {
{/* Logo */}
<div
className="flex items-center gap-3 cursor-pointer group"
onClick={() => onNavigate('dashboard')}
onClick={() => onNavigate('/')}
>
<div className="w-8 h-8 bg-gradient-to-br from-accentInfo to-blue-900 rounded flex items-center justify-center shadow-glow group-hover:shadow-lg transition-shadow">
<span className="font-bold text-white text-sm">P.V.</span>
@@ -69,6 +69,7 @@ const Layout: React.FC<LayoutProps> = ({ children, activeTab, onNavigate }) => {
<NavItem active={activeTab === 'players'} label="Bürger" to="/players" />
{/* <NavItem active={activeTab === 'organizations'} label="Organisationen" to="/organizations" />*/}
<NavItem active={activeTab === 'projects'} label="Unternehmen" to="/projects" />
<NavItem active={activeTab === 'world-map'} label="Weltkarte" to="/world-map" />
{user?.isAdmin && (
<NavItem active={activeTab === 'admin'} label="Admin" to="/admin" />
)}
@@ -111,6 +112,7 @@ const Layout: React.FC<LayoutProps> = ({ children, activeTab, onNavigate }) => {
<Link to="/players" onClick={() => setMobileMenuOpen(false)} className="block py-2 text-textMuted hover:text-textMain">Bürger</Link>
<Link to="/organizations" onClick={() => setMobileMenuOpen(false)} className="block py-2 text-textMuted hover:text-textMain">Organisationen</Link>
<Link to="/projects" onClick={() => setMobileMenuOpen(false)} className="block py-2 text-textMuted hover:text-textMain">Unternehmen</Link>
<Link to="/world-map" onClick={() => setMobileMenuOpen(false)} className="block py-2 text-textMuted hover:text-textMain">Weltkarte</Link>
{user?.isAdmin && (
<Link to="/admin" onClick={() => setMobileMenuOpen(false)} className="block py-2 text-red-400 hover:text-red-300">Admin</Link>
)}
@@ -182,7 +184,12 @@ const Layout: React.FC<LayoutProps> = ({ children, activeTab, onNavigate }) => {
{/* Links */}
<div className="flex gap-8">
<span className="cursor-pointer hover:text-textMain transition-colors">Dokumentation</span>
<button
onClick={() => onNavigate('/dokumentation')}
className="cursor-pointer hover:text-textMain transition-colors"
>
Dokumentation
</button>
<span className="cursor-pointer hover:text-textMain transition-colors">Server Status</span>
<span className="cursor-pointer hover:text-textMain transition-colors">Datenschutz</span>
</div>

View File

@@ -1,4 +1,6 @@
import React, { useState, useRef } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Icons } from './IconSet';
interface MarkdownEditorProps {
@@ -95,15 +97,24 @@ const MarkdownEditor: React.FC<MarkdownEditorProps> = ({ value, onChange, classN
))}
</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}
/>
{/* Editor and Preview */}
<div className="flex h-64 md:h-96">
{/* Textarea */}
<textarea
ref={textareaRef}
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-1/2 h-full bg-[#0b0b0d] border-0 border-r border-border 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}
/>
{/* Preview */}
<div className="w-1/2 h-full bg-[#0b0b0d] p-4 text-sm text-gray-300 overflow-auto">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{value || 'Hier erscheint die Vorschau deiner Markdown-Formatierung.'}
</ReactMarkdown>
</div>
</div>
{/* Footer */}
<div className="bg-surfaceHighlight border-t border-border px-4 py-2 text-xs text-textMuted">

View File

@@ -54,9 +54,9 @@ services:
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: fgterherthethehdfghfghdfghdfgh
MYSQL_DATABASE: obsidian_db
MYSQL_USER: obsidian_user
MYSQL_PASSWORD: obsidian_pass
MYSQL_DATABASE: ${DB_NAME}
MYSQL_USER: ${DB_USER}
MYSQL_PASSWORD: ${DB_PASS}
volumes:
- ./database:/var/lib/mysql
networks:
@@ -67,8 +67,11 @@ services:
ports:
- 8081:80
environment:
PMA_HOST: db
PMA_HOST: ${DB_HOST}
MYSQL_ROOT_PASSWORD: root_secret_pass
PMA_USER: ${DB_USER}
PMA_PASSWORD: ${DB_PASS}
PMA_DATABASE: ${DB_NAME}
depends_on:
- db
networks:

1489
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,9 @@
"dependencies": {
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.11.0",
"remark-gfm": "^4.0.1",
"sharp": "^0.34.5"
},
"devDependencies": {

View File

@@ -4,6 +4,7 @@ import { authService } from '../services/AuthService';
import NpcBannerManagementModal from '../components/NpcBannerManagementModal';
import NpcLogoManagementModal from '../components/NpcLogoManagementModal';
import NpcGalleryManagementModal from '../components/NpcGalleryManagementModal';
import { useNavigate } from 'react-router-dom';
interface AdminPageProps {
onBack: () => void;
@@ -699,6 +700,7 @@ const EditNpcCompanyCard: React.FC<{ company: any; npcCitizens: any[]; onUpdate:
};
const AdminPage: React.FC<AdminPageProps> = ({ onBack }) => {
const navigate = useNavigate();
const [user, setUser] = useState(authService.getUser());
const [activeTab, setActiveTab] = useState<'overview' | 'create-npc' | 'edit-npcs' | 'cities' | 'create-city' | 'manage-admins'>('overview');
const [npcs, setNpcs] = useState<any>({ citizens: [], companies: [] });
@@ -729,7 +731,7 @@ const AdminPage: React.FC<AdminPageProps> = ({ onBack }) => {
if (!isAdmin) return;
const interval = setInterval(() => {
if (activeTab === 'edit-npcs' || activeTab === 'create-npc') {
if (activeTab === 'edit-npcs' || activeTab === 'create-npc') {
loadNpcs();
} else if (activeTab === 'cities' || activeTab === 'create-city') {
loadCities();
@@ -813,7 +815,9 @@ const AdminPage: React.FC<AdminPageProps> = ({ onBack }) => {
defenseRating: 5,
government: 'Demokratie',
specialty: 'Handel'
}, null, 2)
}, null, 2),
bannerFile: null as File | null,
logoFile: null as File | null
});
const [editingCity, setEditingCity] = useState<any>(null);
@@ -991,6 +995,12 @@ const AdminPage: React.FC<AdminPageProps> = ({ onBack }) => {
>
Stadt erstellen
</button>
<button
onClick={() => navigate('/admin/map-management')}
className={`px-6 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap border-transparent text-textMuted hover:text-white`}
>
Karten-Management
</button>
</div>
{/* Error Display */}

View File

@@ -0,0 +1,696 @@
import React, { useState, useEffect } from 'react';
import { MapMetadata, MapLayer, MapMarker } from '../types';
import { authService } from '../services/AuthService';
import { DiscordUser } from '../types';
const AdminMapManagement: React.FC = () => {
const [user, setUser] = useState<DiscordUser | null>(null);
const [mapMetadata, setMapMetadata] = useState<MapMetadata | null>(null);
const [layers, setLayers] = useState<MapLayer[]>([]);
const [markers, setMarkers] = useState<MapMarker[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Map assembly state
const [isAssembling, setIsAssembling] = useState(false);
const [assemblyProgress, setAssemblyProgress] = useState<string>('');
const [assemblyLog, setAssemblyLog] = useState<string[]>([]);
// Layer management state
const [editingLayer, setEditingLayer] = useState<MapLayer | null>(null);
const [newLayer, setNewLayer] = useState({ name: '', is_active: true });
// Marker management state
const [editingMarker, setEditingMarker] = useState<MapMarker | null>(null);
const [newMarker, setNewMarker] = useState({
name: '',
type: 'poi' as const,
x_coord: 0,
z_coord: 0,
description: '',
linked_entity_type: null as string | null,
linked_entity_id: null as number | null,
icon_type: 'flag' as const,
color: '#2563eb',
is_public: true
});
// Subscribe to auth changes
useEffect(() => {
const unsub = authService.subscribe((currentUser) => {
setUser(currentUser);
});
return unsub;
}, []);
// Load map data
useEffect(() => {
const loadData = async () => {
if (!user?.isAdmin) return;
try {
setLoading(true);
setError(null);
// Load map metadata
try {
const metadataResponse = await fetch('/api/map/metadata');
if (metadataResponse.ok) {
const metadata = await metadataResponse.json();
setMapMetadata(metadata);
}
} catch (metaErr) {
console.log('Map metadata not available:', metaErr);
}
// Load layers
const layersResponse = await fetch('/api/map/layers');
if (layersResponse.ok) {
const layerData = await layersResponse.json();
setLayers(layerData);
}
// Load all markers (including private ones)
const markersResponse = await fetch('/api/map/markers');
if (markersResponse.ok) {
const markerData = await markersResponse.json();
setMarkers(markerData);
}
} catch (err) {
console.error('Error loading map data:', err);
setError(err instanceof Error ? err.message : 'Unbekannter Fehler');
} finally {
setLoading(false);
}
};
loadData();
}, [user?.isAdmin]);
// Map assembly function
const handleAssembleMap = async () => {
if (!user?.isAdmin || isAssembling) return;
setIsAssembling(true);
setAssemblyProgress('Starte Karten-Zusammenstellung...');
setAssemblyLog(['Starte Karten-Zusammenstellung...']);
try {
setAssemblyProgress('Überprüfe Kacheln...');
addToLog('Überprüfe Kacheln...');
// Check if tiles exist
const tilesResponse = await fetch('/api/map/metadata');
if (!tilesResponse.ok) {
setAssemblyProgress('Keine Kacheln gefunden. Bitte stellen Sie sicher, dass PNG-Dateien im Ordner backend/uploads/map/ vorhanden sind.');
addToLog('❌ Keine Kacheln gefunden');
setTimeout(() => {
setIsAssembling(false);
setAssemblyProgress('');
}, 3000);
return;
}
setAssemblyProgress('Starte Karten-Zusammenstellung...');
addToLog('Starte Karten-Zusammenstellung...');
const response = await fetch('/api/map/assemble', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Server-Fehler: ${response.status} - ${errorText}`);
}
const result = await response.json();
if (result.success) {
setAssemblyProgress('Karte erfolgreich zusammengestellt!');
addToLog('✅ Karte erfolgreich zusammengestellt!');
// Reload map metadata
const metadataResponse = await fetch('/api/map/metadata');
if (metadataResponse.ok) {
const metadata = await metadataResponse.json();
setMapMetadata(metadata);
}
setIsAssembling(false);
setAssemblyProgress('');
alert('Weltkarte wurde erfolgreich zusammengestellt!');
} else {
throw new Error(result.message || 'Fehler beim Zusammensetzen der Karte');
}
} catch (err) {
console.error('Error assembling map:', err);
setAssemblyProgress(`Fehler: ${err.message}`);
addToLog(`❌ Fehler: ${err.message}`);
setTimeout(() => {
setIsAssembling(false);
setAssemblyProgress('');
}, 3000);
}
};
const addToLog = (message: string) => {
setAssemblyLog(prev => [...prev, message]);
};
// Layer management functions
const handleCreateLayer = async () => {
if (!user?.isAdmin) return;
try {
const response = await fetch('/api/map/layers', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(newLayer)
});
if (response.ok) {
const result = await response.json();
setLayers([...layers, result]);
setNewLayer({ name: '', is_active: true });
} else {
throw new Error('Fehler beim Erstellen des Layers');
}
} catch (err) {
console.error('Error creating layer:', err);
setError(err instanceof Error ? err.message : 'Unbekannter Fehler');
}
};
const handleUpdateLayer = async (layerId: string) => {
if (!user?.isAdmin || !editingLayer) return;
try {
const response = await fetch(`/api/map/layers/${layerId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(editingLayer)
});
if (response.ok) {
const result = await response.json();
setLayers(layers.map(l => l.id === layerId ? result : l));
setEditingLayer(null);
} else {
throw new Error('Fehler beim Aktualisieren des Layers');
}
} catch (err) {
console.error('Error updating layer:', err);
setError(err instanceof Error ? err.message : 'Unbekannter Fehler');
}
};
const handleDeleteLayer = async (layerId: string) => {
if (!user?.isAdmin) return;
try {
const response = await fetch(`/api/map/layers/${layerId}`, {
method: 'DELETE'
});
if (response.ok) {
setLayers(layers.filter(l => l.id !== layerId));
} else {
throw new Error('Fehler beim Löschen des Layers');
}
} catch (err) {
console.error('Error deleting layer:', err);
setError(err instanceof Error ? err.message : 'Unbekannter Fehler');
}
};
// Marker management functions
const handleCreateMarker = async () => {
if (!user?.isAdmin) return;
try {
const response = await fetch('/api/map/markers', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(newMarker)
});
if (response.ok) {
const result = await response.json();
setMarkers([...markers, result]);
setNewMarker({
name: '',
type: 'poi',
x_coord: 0,
z_coord: 0,
description: '',
linked_entity_type: null,
linked_entity_id: null,
icon_type: 'flag',
color: '#2563eb',
is_public: true
});
} else {
throw new Error('Fehler beim Erstellen des Markers');
}
} catch (err) {
console.error('Error creating marker:', err);
setError(err instanceof Error ? err.message : 'Unbekannter Fehler');
}
};
const handleUpdateMarker = async (markerId: string) => {
if (!user?.isAdmin || !editingMarker) return;
try {
const response = await fetch(`/api/map/markers/${markerId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(editingMarker)
});
if (response.ok) {
const result = await response.json();
setMarkers(markers.map(m => m.id === markerId ? result : m));
setEditingMarker(null);
} else {
throw new Error('Fehler beim Aktualisieren des Markers');
}
} catch (err) {
console.error('Error updating marker:', err);
setError(err instanceof Error ? err.message : 'Unbekannter Fehler');
}
};
const handleDeleteMarker = async (markerId: string) => {
if (!user?.isAdmin) return;
try {
const response = await fetch(`/api/map/markers/${markerId}`, {
method: 'DELETE'
});
if (response.ok) {
setMarkers(markers.filter(m => m.id !== markerId));
} else {
throw new Error('Fehler beim Löschen des Markers');
}
} catch (err) {
console.error('Error deleting marker:', err);
setError(err instanceof Error ? err.message : 'Unbekannter Fehler');
}
};
if (!user?.isAdmin) {
return (
<div className="flex items-center justify-center h-screen">
<div className="text-red-500">Zugriff verweigert: Admin-Berechtigungen erforderlich</div>
</div>
);
}
if (loading) {
return (
<div className="flex items-center justify-center h-screen">
<div className="text-textMuted">Lade Map-Management...</div>
</div>
);
}
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-textPrimary">Karten-Management</h1>
<div className="text-sm text-textMuted">
Admin-Tools für Weltkarte, Marker und Layer
</div>
</div>
{error && (
<div className="bg-red-500/10 border border-red-500/50 text-red-500 p-3 rounded">
{error}
</div>
)}
{/* Map Assembly Section */}
<div className="bg-bgSecondary border border-border rounded-lg p-6">
<h2 className="text-lg font-semibold text-textPrimary mb-4">Karten-Zusammenstellung</h2>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Assembly Controls */}
<div className="space-y-4">
<div className="p-4 bg-bgPrimary rounded">
<h3 className="font-semibold text-textPrimary mb-2">Karten-Status</h3>
{mapMetadata ? (
<div className="space-y-1 text-sm">
<div className="text-textMuted">Status: <span className="text-green-400 font-medium">Zusammengestellt</span></div>
<div className="text-textMuted">Größe: <span className="text-textPrimary">{mapMetadata.width} x {mapMetadata.height}px</span></div>
<div className="text-textMuted">Offset: <span className="text-textPrimary">X: {mapMetadata.offsetX}, Z: {mapMetadata.offsetZ}</span></div>
<div className="text-textMuted">Letztes Update: <span className="text-textPrimary">{new Date().toLocaleString()}</span></div>
</div>
) : (
<div className="text-textMuted">Status: <span className="text-red-400 font-medium">Nicht zusammengestellt</span></div>
)}
</div>
<div className="space-y-2">
<button
onClick={handleAssembleMap}
disabled={isAssembling}
className="w-full px-4 py-2 bg-accentSuccess text-white rounded hover:bg-accentSuccess/90 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isAssembling ? (
<div className="flex items-center justify-center gap-2">
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
<span>{assemblyProgress}</span>
</div>
) : (
<span>{mapMetadata ? 'Karte neu zusammensetzen' : 'Karte zusammensetzen'}</span>
)}
</button>
<div className="text-xs text-textMuted">
{mapMetadata
? 'Aktualisiert die bestehende Weltkarte mit neuen Kacheln aus backend/uploads/map/'
: 'Erstellt die Weltkarte aus den PNG-Kacheln im Upload-Ordner'
}
</div>
</div>
</div>
{/* Assembly Log */}
<div className="space-y-4">
<div className="p-4 bg-bgPrimary rounded">
<h3 className="font-semibold text-textPrimary mb-2">Zusammenstellungs-Log</h3>
<div className="space-y-1 max-h-32 overflow-y-auto">
{assemblyLog.length === 0 ? (
<div className="text-textMuted text-sm">Keine Aktivitäten</div>
) : (
assemblyLog.map((log, index) => (
<div key={index} className="text-sm font-mono text-textMuted">
{log}
</div>
))
)}
</div>
</div>
<div className="p-4 bg-bgPrimary rounded">
<h3 className="font-semibold text-textPrimary mb-2">Anweisungen</h3>
<div className="text-sm text-textMuted space-y-1">
<div>1. Stellen Sie sicher, dass PNG-Kacheln im Ordner <code className="bg-bgSecondary px-1 rounded">backend/uploads/map/</code> im Backend-Container vorhanden sind</div>
<div>2. Klicken Sie auf "Karte zusammensetzen"</div>
<div>3. Warten Sie, bis der Vorgang abgeschlossen ist</div>
<div>4. Die Karte ist dann unter <code className="bg-bgSecondary px-1 rounded">/world-map</code> verfügbar</div>
</div>
<div className="mt-3 text-xs text-textMuted border-t border-border pt-2">
<strong>Hinweis:</strong> Wenn "Keine Kacheln gefunden" angezeigt wird, befolgen Sie bitte die Anleitung in <code className="bg-bgSecondary px-1 rounded">MAP_SETUP_GUIDE.md</code>
</div>
</div>
</div>
</div>
</div>
{/* Layer Management */}
<div className="bg-bgSecondary border border-border rounded-lg p-6">
<h2 className="text-lg font-semibold text-textPrimary mb-4">Layer-Verwaltung</h2>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Create Layer */}
<div className="space-y-4">
<h3 className="font-semibold text-textPrimary">Neuen Layer erstellen</h3>
<div className="space-y-3">
<input
type="text"
placeholder="Layer-Name"
value={newLayer.name}
onChange={(e) => setNewLayer({...newLayer, name: e.target.value})}
className="w-full px-3 py-2 bg-bgPrimary border border-border rounded"
/>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={newLayer.is_active}
onChange={(e) => setNewLayer({...newLayer, is_active: e.target.checked})}
/>
<span>Aktiv</span>
</label>
<button
onClick={handleCreateLayer}
className="px-4 py-2 bg-accentInfo text-white rounded hover:bg-accentInfo/90"
>
Layer erstellen
</button>
</div>
</div>
{/* Layer List */}
<div className="space-y-4">
<h3 className="font-semibold text-textPrimary">Bestehende Layer</h3>
<div className="space-y-2 max-h-40 overflow-y-auto">
{layers.length === 0 ? (
<div className="text-textMuted text-sm">Keine Layer vorhanden</div>
) : (
layers.map(layer => (
<div key={layer.id} className="flex items-center justify-between p-3 bg-bgPrimary rounded">
<div>
<div className="font-medium text-textPrimary">{layer.name}</div>
<div className="text-xs text-textMuted">
{layer.is_active ? 'Aktiv' : 'Inaktiv'} ID: {layer.id}
</div>
</div>
<div className="flex gap-2">
<button
onClick={() => setEditingLayer(layer)}
className="px-2 py-1 bg-bgTertiary text-textPrimary rounded text-sm hover:bg-bgPrimary"
>
Bearbeiten
</button>
<button
onClick={() => handleDeleteLayer(layer.id)}
className="px-2 py-1 bg-red-500/20 text-red-400 rounded text-sm hover:bg-red-500/30"
>
Löschen
</button>
</div>
</div>
))
)}
</div>
</div>
</div>
{/* Edit Layer Modal */}
{editingLayer && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4">
<div className="bg-bgSecondary border border-border rounded-lg p-6 w-full max-w-md">
<h3 className="text-lg font-semibold text-textPrimary mb-4">Layer bearbeiten</h3>
<div className="space-y-3">
<input
type="text"
value={editingLayer.name}
onChange={(e) => setEditingLayer({...editingLayer, name: e.target.value})}
className="w-full px-3 py-2 bg-bgPrimary border border-border rounded"
/>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={editingLayer.is_active}
onChange={(e) => setEditingLayer({...editingLayer, is_active: e.target.checked})}
/>
<span>Aktiv</span>
</label>
<div className="flex gap-2">
<button
onClick={() => handleUpdateLayer(editingLayer.id)}
className="flex-1 px-4 py-2 bg-accentInfo text-white rounded hover:bg-accentInfo/90"
>
Speichern
</button>
<button
onClick={() => setEditingLayer(null)}
className="flex-1 px-4 py-2 bg-bgTertiary text-textPrimary rounded hover:bg-bgPrimary"
>
Abbrechen
</button>
</div>
</div>
</div>
</div>
)}
</div>
{/* Marker Management */}
<div className="bg-bgSecondary border border-border rounded-lg p-6">
<h2 className="text-lg font-semibold text-textPrimary mb-4">Marker-Verwaltung</h2>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Create Marker */}
<div className="space-y-4">
<h3 className="font-semibold text-textPrimary">Neuen Marker erstellen</h3>
<div className="space-y-3">
<input
type="text"
placeholder="Name"
value={newMarker.name}
onChange={(e) => setNewMarker({...newMarker, name: e.target.value})}
className="w-full px-3 py-2 bg-bgPrimary border border-border rounded"
/>
<div className="grid grid-cols-2 gap-3">
<input
type="number"
placeholder="X-Koordinate"
value={newMarker.x_coord}
onChange={(e) => setNewMarker({...newMarker, x_coord: parseInt(e.target.value)})}
className="px-3 py-2 bg-bgPrimary border border-border rounded"
/>
<input
type="number"
placeholder="Z-Koordinate"
value={newMarker.z_coord}
onChange={(e) => setNewMarker({...newMarker, z_coord: parseInt(e.target.value)})}
className="px-3 py-2 bg-bgPrimary border border-border rounded"
/>
</div>
<input
type="text"
placeholder="Beschreibung"
value={newMarker.description}
onChange={(e) => setNewMarker({...newMarker, description: e.target.value})}
className="w-full px-3 py-2 bg-bgPrimary border border-border rounded"
/>
<input
type="color"
value={newMarker.color}
onChange={(e) => setNewMarker({...newMarker, color: e.target.value})}
className="w-full px-3 py-2 bg-bgPrimary border border-border rounded"
/>
<button
onClick={handleCreateMarker}
className="w-full px-4 py-2 bg-accentInfo text-white rounded hover:bg-accentInfo/90"
>
Marker erstellen
</button>
</div>
</div>
{/* Marker List */}
<div className="space-y-4">
<h3 className="font-semibold text-textPrimary">Bestehende Marker</h3>
<div className="space-y-2 max-h-64 overflow-y-auto">
{markers.length === 0 ? (
<div className="text-textMuted text-sm">Keine Marker vorhanden</div>
) : (
markers.map(marker => (
<div key={marker.id} className="flex items-center justify-between p-3 bg-bgPrimary rounded">
<div>
<div className="font-medium text-textPrimary">{marker.name}</div>
<div className="text-xs text-textMuted">
{marker.type} {marker.x_coord}, {marker.z_coord} {marker.is_public ? 'Öffentlich' : 'Privat'}
</div>
{marker.description && (
<div className="text-xs text-textMuted mt-1">{marker.description}</div>
)}
</div>
<div className="flex gap-2">
<button
onClick={() => setEditingMarker(marker)}
className="px-2 py-1 bg-bgTertiary text-textPrimary rounded text-sm hover:bg-bgPrimary"
>
Bearbeiten
</button>
<button
onClick={() => handleDeleteMarker(marker.id)}
className="px-2 py-1 bg-red-500/20 text-red-400 rounded text-sm hover:bg-red-500/30"
>
Löschen
</button>
</div>
</div>
))
)}
</div>
</div>
</div>
{/* Edit Marker Modal */}
{editingMarker && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4">
<div className="bg-bgSecondary border border-border rounded-lg p-6 w-full max-w-md">
<h3 className="text-lg font-semibold text-textPrimary mb-4">Marker bearbeiten</h3>
<div className="space-y-3">
<input
type="text"
value={editingMarker.name}
onChange={(e) => setEditingMarker({...editingMarker, name: e.target.value})}
className="w-full px-3 py-2 bg-bgPrimary border border-border rounded"
/>
<div className="grid grid-cols-2 gap-3">
<input
type="number"
value={editingMarker.x_coord}
onChange={(e) => setEditingMarker({...editingMarker, x_coord: parseInt(e.target.value)})}
className="px-3 py-2 bg-bgPrimary border border-border rounded"
/>
<input
type="number"
value={editingMarker.z_coord}
onChange={(e) => setEditingMarker({...editingMarker, z_coord: parseInt(e.target.value)})}
className="px-3 py-2 bg-bgPrimary border border-border rounded"
/>
</div>
<input
type="text"
value={editingMarker.description}
onChange={(e) => setEditingMarker({...editingMarker, description: e.target.value})}
className="w-full px-3 py-2 bg-bgPrimary border border-border rounded"
/>
<input
type="color"
value={editingMarker.color}
onChange={(e) => setEditingMarker({...editingMarker, color: e.target.value})}
className="w-full px-3 py-2 bg-bgPrimary border border-border rounded"
/>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={editingMarker.is_public === 1}
onChange={(e) => setEditingMarker({...editingMarker, is_public: e.target.checked ? 1 : 0})}
/>
<span>Öffentlich anzeigen</span>
</label>
<div className="flex gap-2">
<button
onClick={() => handleUpdateMarker(editingMarker.id)}
className="flex-1 px-4 py-2 bg-accentInfo text-white rounded hover:bg-accentInfo/90"
>
Speichern
</button>
<button
onClick={() => setEditingMarker(null)}
className="flex-1 px-4 py-2 bg-bgTertiary text-textPrimary rounded hover:bg-bgPrimary"
>
Abbrechen
</button>
</div>
</div>
</div>
</div>
)}
</div>
</div>
);
};
export default AdminMapManagement;

View File

@@ -191,6 +191,45 @@ const CityProfile: React.FC = () => {
</div>
</section>
)}
{/* Map Preview */}
<section>
<h3 className="text-xl font-bold mb-4 flex items-center gap-2">
<Icons.Map className="w-5 h-5 text-accentInfo" /> Standort auf der Weltkarte
</h3>
<div className="relative aspect-video bg-surface border border-border rounded-xl overflow-hidden">
<img
src="/api/map/world-map"
alt="Weltkarte"
className="w-full h-full object-contain opacity-80"
onError={(e) => {
e.currentTarget.src = 'data:image/svg+xml;utf8,' + encodeURIComponent(`
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="600" viewBox="0 0 800 600">
<rect width="800" height="600" fill="#1f2937"/>
<text x="400" y="300" text-anchor="middle" fill="#9ca3af" font-family="Arial" font-size="24">Weltkarte nicht verfügbar</text>
<text x="400" y="340" text-anchor="middle" fill="#9ca3af" font-family="Arial" font-size="16">Bitte zuerst im Admin-Bereich zusammensetzen</text>
</svg>
`);
}}
/>
{/* City marker */}
<div className="absolute top-4 left-4 bg-black/60 text-white px-3 py-2 rounded-lg border border-white/20">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-accentInfo"></div>
<span className="font-medium">{city.name}</span>
</div>
<div className="text-xs text-gray-300 mt-1">Stadtmarker</div>
</div>
<div className="absolute bottom-4 right-4">
<button
onClick={() => navigate('/world-map')}
className="bg-accentInfo text-white px-4 py-2 rounded-lg hover:bg-accentInfo/90 transition-colors text-sm font-medium"
>
Zur Weltkarte
</button>
</div>
</div>
</section>
</div>
{city.cityStats && (

469
pages/Dokumentation.tsx Normal file
View File

@@ -0,0 +1,469 @@
import React, { useState } from 'react';
import { Icons } from '../components/IconSet';
// Fallback icons for missing icons
const FallbackIcon = ({ 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}>
<circle cx="12" cy="12" r="10"/>
<path d="M12 8v8M8 12h8"/>
</svg>
);
interface FAQItem {
question: string;
answer: string;
category: 'account' | 'city' | 'map' | 'support' | 'technical';
}
interface DocumentationSection {
title: string;
description: string;
icon: keyof typeof Icons;
color: string;
items: {
title: string;
description: string;
link?: string;
isNew?: boolean;
isUpdated?: boolean;
}[];
}
const DocumentationPage: React.FC = () => {
const [activeCategory, setActiveCategory] = useState<string>('all');
// FAQ Daten
const faqData: FAQItem[] = [
{
question: "Wie verlinke ich meinen Account?",
answer: "Gehe zu 'Bürger' im Menü, klicke auf 'Account verknüpfen' und folge den Anweisungen. Du benötigst einen Discord-Account für diesen Vorgang. Nach der Verifizierung erhältst du Zugriff auf alle Bürger-Funktionen.",
category: 'account'
},
{
question: "Wie gründe ich eine Stadt?",
answer: "Du benötigst mindestens 5 aktive Bürger und eine geeignete Landfläche. Kontaktiere den Bürgermeister oder besuche das Rathaus für weitere Informationen. Alternativ kannst du im 'Projekte'-Bereich einen Gründungsantrag stellen.",
category: 'city'
},
{
question: "Wo finde ich die Weltkarte?",
answer: "Die Weltkarte ist im Hauptmenü unter 'Weltkarte' zu finden. Dort kannst du alle Städte, Unternehmen und wichtige Orte im Tal einsehen. Die Karte wird regelmäßig aktualisiert und zeigt Echtzeit-Informationen.",
category: 'map'
},
{
question: "Wie bekomme ich Support?",
answer: "Für technische Probleme wende dich an das Admin-Team. Für inhaltliche Fragen kannst du die FAQ durchsuchen oder im Discord-Server nachfragen. Support ist werktags von 18:00-22:00 Uhr verfügbar.",
category: 'support'
},
{
question: "Wie funktioniert die Datapack-Integration?",
answer: "Lade das neueste Datapack aus dem Download-Bereich herunter. Platziere es im 'datapacks'-Ordner deines Minecraft-Servers und starte den Server neu. Alle Funktionen sind dann automatisch verfügbar.",
category: 'technical'
},
{
question: "Wie melde ich Bugs oder Fehler?",
answer: "Gehe zu 'Feedback & Bugs' im Schnellzugriff oder erstelle ein Issue auf GitHub. Beschreibe das Problem detailliert und füge Screenshots bei, wenn möglich. Unser Team bearbeitet Meldungen innerhalb von 48 Stunden.",
category: 'support'
},
{
question: "Wie gründe ich ein Unternehmen?",
answer: "Im 'Projekte'-Bereich kannst du einen Unternehmensantrag stellen. Du benötigst mindestens 3 Gründungsmitglieder und ein Geschäftsmodell. Nach Genehmigung durch die Wirtschaftsbehörde erhältst du Unternehmensrechte.",
category: 'city'
},
{
question: "Wie funktioniert die Rangsystematik?",
answer: "Der Rang basiert auf Aktivität, Beiträgen und Reputation im Tal. Aktive Bürger erhalten regelmäßig Rang-Upgrades. Besondere Leistungen werden mit besonderen Rängen ausgezeichnet.",
category: 'account'
}
];
// Dokumentationsabschnitte
const documentationSections: DocumentationSection[] = [
{
title: "Bürger-Handbuch",
description: "Für alle neuen und erfahrenen Tal-Bewohner",
icon: "Users",
color: "accentInfo",
items: [
{
title: "Häufig gestellte Fragen",
description: "Die wichtigsten Fragen und Antworten",
link: "#faq"
},
{
title: "Stadtgründung",
description: "Schritt-für-Schritt Anleitung zur Gründung deiner eigenen Stadt",
link: "/docs/city-foundation",
isNew: true
},
{
title: "Unternehmensgründung",
description: "So startest du dein eigenes Unternehmen im Tal",
link: "/docs/business-foundation"
},
{
title: "Weltkarte nutzen",
description: "Wie du die interaktive Weltkarte effektiv nutzt",
link: "/docs/map-usage",
isUpdated: true
},
{
title: "Account-Verwaltung",
description: "Verwaltung deines Bürger-Accounts und Einstellungen",
link: "/docs/account-management"
},
{
title: "Rechte und Pflichten",
description: "Übersicht über Bürgerrechte und -pflichten im Tal",
link: "/docs/citizen-rights"
}
]
},
{
title: "Technische Dokumentation",
description: "Für Entwickler und Server-Administratoren",
icon: "Terminal",
color: "accentWarn",
items: [
{
title: "Datenbank-Struktur",
description: "Übersicht über das Datenbankschema und die API-Endpunkte",
link: "/docs/database-schema"
},
{
title: "Datapack-Integration",
description: "Wie du das Tal-Datapack in deinem Server integrierst",
link: "/docs/datapack-integration",
isNew: true
},
{
title: "Server-Setup",
description: "Komplette Anleitung zum Aufbau eines Tal-Servers",
link: "/docs/server-setup"
},
{
title: "API-Dokumentation",
description: "REST-API für Entwickler und externe Anwendungen",
link: "/docs/api-reference"
},
{
title: "Plugin-Entwicklung",
description: "Entwicklung eigener Plugins für den Tal-Server",
link: "/docs/plugin-development"
},
{
title: "Sicherheitshinweise",
description: "Wichtige Sicherheitshinweise für Server-Admins",
link: "/docs/security-guidelines"
}
]
},
{
title: "Projekt-Management",
description: "Für Projektmanager und Teamleiter",
icon: "Layers",
color: "accentSuccess",
items: [
{
title: "Projekt-Setup",
description: "Einrichtung und Konfiguration neuer Projekte",
link: "/docs/project-setup"
},
{
title: "Team-Management",
description: "Verwaltung von Projektteams und Berechtigungen",
link: "/docs/team-management"
},
{
title: "Milestones",
description: "Verwaltung von Projekt-Milestones und Zielen",
link: "/docs/milestones"
},
{
title: "Reporting",
description: "Erstellung von Projektberichten und Statistiken",
link: "/docs/reporting"
}
]
}
];
const categories = [
{ id: 'all', label: 'Alle', count: faqData.length },
{ id: 'account', label: 'Account', count: faqData.filter(f => f.category === 'account').length },
{ id: 'city', label: 'Städte & Unternehmen', count: faqData.filter(f => f.category === 'city').length },
{ id: 'map', label: 'Weltkarte', count: faqData.filter(f => f.category === 'map').length },
{ id: 'support', label: 'Support', count: faqData.filter(f => f.category === 'support').length },
{ id: 'technical', label: 'Technik', count: faqData.filter(f => f.category === 'technical').length }
];
const filteredFAQ = activeCategory === 'all'
? faqData
: faqData.filter(faq => faq.category === activeCategory);
return (
<div className="space-y-12">
{/* Header Section */}
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4 pb-4 border-b border-border">
<div>
<h2 className="text-sm font-bold text-accentInfo tracking-widest uppercase mb-2">Hilfe & Anleitungen</h2>
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-white">Dokumentation</h1>
</div>
<p className="text-textMuted text-right hidden md:block max-w-xs leading-relaxed">
Alles Wichtige rund um das Projekt: Vollidion und die Tal-Welt. Hier findest du Anleitungen,
technische Dokumentation und Antworten auf häufig gestellte Fragen.
</p>
</div>
{/* Documentation Grid */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12">
{/* Left Column - Main Documentation */}
<div className="lg:col-span-8 space-y-8">
{/* Dokumentationsabschnitte */}
{documentationSections.map((section, index) => (
<div key={index} className="bg-surface/30 border border-border rounded-2xl p-8">
<div className="flex items-center gap-4 mb-6">
<div className={`w-12 h-12 bg-${section.color}/20 rounded-xl flex items-center justify-center`}>
{/*<Icons[section.icon] className={`w-6 h-6 text-${section.color}`} />*/}
</div>
<div>
<h3 className="text-xl font-semibold text-textMain">{section.title}</h3>
<p className="text-textMuted text-sm">{section.description}</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{section.items.map((item, itemIndex) => (
<div key={itemIndex} className="p-4 bg-surfaceHighlight/50 rounded-lg hover:bg-surfaceHighlight/80 transition-colors group">
<div className="flex items-start justify-between gap-3">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<span className="font-medium text-textMain group-hover:text-accentInfo transition-colors">
{item.title}
</span>
{item.isNew && (
<span className="px-2 py-1 bg-accentSuccess/20 text-accentSuccess text-xs rounded-full">
Neu
</span>
)}
{item.isUpdated && (
<span className="px-2 py-1 bg-accentWarn/20 text-accentWarn text-xs rounded-full">
Aktualisiert
</span>
)}
</div>
<p className="text-textMuted text-sm">{item.description}</p>
</div>
{item.link && (
<FallbackIcon className="w-4 h-4 text-textMuted group-hover:text-accentInfo transition-colors mt-1" />
)}
</div>
</div>
))}
</div>
</div>
))}
{/* FAQ Section */}
<div className="bg-surface/30 border border-border rounded-2xl p-8">
<div className="flex items-center gap-4 mb-6">
<div className="w-12 h-12 bg-accentSuccess/20 rounded-xl flex items-center justify-center">
<Icons.Scroll className="w-6 h-6 text-accentSuccess" />
</div>
<div>
<h3 className="text-xl font-semibold text-textMain">Häufig gestellte Fragen</h3>
<p className="text-textMuted text-sm">Die wichtigsten Fragen und Antworten</p>
</div>
</div>
{/* Kategorien-Filter */}
<div className="flex flex-wrap gap-2 mb-6">
{categories.map((category) => (
<button
key={category.id}
onClick={() => setActiveCategory(category.id)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
activeCategory === category.id
? 'bg-accentInfo/20 text-accentInfo border border-accentInfo/30'
: 'bg-surfaceHighlight/50 text-textMuted hover:bg-surfaceHighlight/80'
}`}
>
{category.label} ({category.count})
</button>
))}
</div>
<div className="space-y-4">
{filteredFAQ.map((faq, index) => (
<div key={index} className="border-b border-border pb-4 last:border-b-0">
<div className="flex items-start justify-between gap-4 mb-2">
<h4 className="font-medium text-textMain flex-1">{faq.question}</h4>
<span className={`px-2 py-1 rounded-full text-xs ${
faq.category === 'account' ? 'bg-accentInfo/20 text-accentInfo' :
faq.category === 'city' ? 'bg-accentWarn/20 text-accentWarn' :
faq.category === 'map' ? 'bg-accentSuccess/20 text-accentSuccess' :
faq.category === 'support' ? 'bg-accentError/20 text-accentError' :
'bg-accentMuted/20 text-accentMuted'
}`}>
{faq.category === 'account' ? 'Account' :
faq.category === 'city' ? 'Städte' :
faq.category === 'map' ? 'Karte' :
faq.category === 'support' ? 'Support' : 'Technik'}
</span>
</div>
<p className="text-textMuted text-sm leading-relaxed">{faq.answer}</p>
</div>
))}
{filteredFAQ.length === 0 && (
<div className="text-center py-8 text-textMuted">
<p>Keine Fragen in dieser Kategorie gefunden.</p>
<p className="text-sm mt-2">Wähle eine andere Kategorie oder schaue dir alle Fragen an.</p>
</div>
)}
</div>
</div>
</div>
{/* Right Column - Quick Links */}
<div className="lg:col-span-4 space-y-8">
<div className="bg-gradient-to-br from-surface/50 to-surface/20 border border-border rounded-2xl p-8">
<h3 className="text-lg font-semibold mb-6 text-textMain">Schnellzugriffe</h3>
<div className="space-y-4">
<button className="w-full text-left p-4 bg-surfaceHighlight/50 rounded-lg hover:bg-surfaceHighlight/80 transition-colors group">
<div className="flex items-center justify-between">
<span className="font-medium text-textMain group-hover:text-accentInfo transition-colors">Download-Bereich</span>
<FallbackIcon className="w-4 h-4 text-textMuted group-hover:text-accentInfo transition-colors" />
</div>
<div className="text-xs text-textMuted mt-1">Alle benötigten Dateien und Tools</div>
</button>
<button className="w-full text-left p-4 bg-surfaceHighlight/50 rounded-lg hover:bg-surfaceHighlight/80 transition-colors group">
<div className="flex items-center justify-between">
<span className="font-medium text-textMain group-hover:text-accentInfo transition-colors">Video-Tutorials</span>
<FallbackIcon className="w-4 h-4 text-textMuted group-hover:text-accentInfo transition-colors" />
</div>
<div className="text-xs text-textMuted mt-1">Schritt-für-Schritt Anleitungen</div>
</button>
<button className="w-full text-left p-4 bg-surfaceHighlight/50 rounded-lg hover:bg-surfaceHighlight/80 transition-colors group">
<div className="flex items-center justify-between">
<span className="font-medium text-textMain group-hover:text-accentInfo transition-colors">Discord-Community</span>
<FallbackIcon className="w-4 h-4 text-textMuted group-hover:text-accentInfo transition-colors" />
</div>
<div className="text-xs text-textMuted mt-1">Direkter Support und Austausch</div>
</button>
<button className="w-full text-left p-4 bg-surfaceHighlight/50 rounded-lg hover:bg-surfaceHighlight/80 transition-colors group">
<div className="flex items-center justify-between">
<span className="font-medium text-textMain group-hover:text-accentInfo transition-colors">Feedback & Bugs</span>
<FallbackIcon className="w-4 h-4 text-textMuted group-hover:text-accentInfo transition-colors" />
</div>
<div className="text-xs text-textMuted mt-1">Fehler melden oder Vorschläge machen</div>
</button>
<button className="w-full text-left p-4 bg-surfaceHighlight/50 rounded-lg hover:bg-surfaceHighlight/80 transition-colors group">
<div className="flex items-center justify-between">
<span className="font-medium text-textMain group-hover:text-accentInfo transition-colors">Changelog</span>
<FallbackIcon className="w-4 h-4 text-textMuted group-hover:text-accentInfo transition-colors" />
</div>
<div className="text-xs text-textMuted mt-1">Änderungen und Updates</div>
</button>
</div>
</div>
{/* Kontakt */}
<div className="bg-gradient-to-br from-surface/50 to-surface/20 border border-border rounded-2xl p-8">
<h3 className="text-lg font-semibold mb-6 text-textMain">Kontakt</h3>
<div className="space-y-4 text-textMuted">
<div className="flex items-center gap-3">
<FallbackIcon className="w-5 h-5 text-accentInfo" />
<div>
<div className="font-medium text-textMain">Support-Email</div>
<div className="text-sm">support@tal-vollidion.de</div>
</div>
</div>
<div className="flex items-center gap-3">
<FallbackIcon className="w-5 h-5 text-accentSuccess" />
<div>
<div className="font-medium text-textMain">Discord</div>
<div className="text-sm">tal-vollidion.de/discord</div>
</div>
</div>
<div className="flex items-center gap-3">
<FallbackIcon className="w-5 h-5 text-textMuted" />
<div>
<div className="font-medium text-textMain">GitHub</div>
<div className="text-sm">github.com/ceratic/projekt_vollidion</div>
</div>
</div>
<div className="flex items-center gap-3">
<FallbackIcon className="w-5 h-5 text-accentWarn" />
<div>
<div className="font-medium text-textMain">Öffnungszeiten</div>
<div className="text-sm">Mo-Do: 18:00-22:00 Uhr</div>
<div className="text-sm">Fr: 16:00-20:00 Uhr</div>
</div>
</div>
</div>
</div>
{/* Letzte Aktualisierung */}
<div className="bg-surface/30 border border-border rounded-2xl p-6 text-center">
<div className="text-xs text-textMuted mb-2">Letzte Aktualisierung</div>
<div className="text-sm font-mono text-textMain">2024-01-02</div>
<div className="text-xs text-textMuted mt-2">Version 1.0.0</div>
<div className="mt-4 pt-4 border-t border-border">
<div className="text-xs text-textMuted mb-2">Nächste geplante Updates</div>
<div className="space-y-1 text-xs">
<div className="flex justify-between">
<span className="text-textMuted">Video-Tutorials</span>
<span className="text-accentWarn">Q1 2024</span>
</div>
<div className="flex justify-between">
<span className="text-textMuted">API-Dokumentation</span>
<span className="text-accentWarn">Q2 2024</span>
</div>
<div className="flex justify-between">
<span className="text-textMuted">Mobile App</span>
<span className="text-accentWarn">Q3 2024</span>
</div>
</div>
</div>
</div>
{/* Statistiken */}
<div className="bg-gradient-to-br from-surface/50 to-surface/20 border border-border rounded-2xl p-8">
<h3 className="text-lg font-semibold mb-6 text-textMain">Statistiken</h3>
<div className="space-y-4">
<div className="flex justify-between items-center">
<span className="text-textMuted">Dokumente</span>
<span className="font-bold text-textMain">15</span>
</div>
<div className="flex justify-between items-center">
<span className="text-textMuted">FAQ-Einträge</span>
<span className="font-bold text-textMain">8</span>
</div>
<div className="flex justify-between items-center">
<span className="text-textMuted">Aktive Nutzer</span>
<span className="font-bold text-textMain">156</span>
</div>
<div className="flex justify-between items-center">
<span className="text-textMuted">Support-Tickets</span>
<span className="font-bold text-textMain">23</span>
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default DocumentationPage;

366
pages/EditMarker.tsx Normal file
View File

@@ -0,0 +1,366 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { MapMarker } from '../types';
import { authService } from '../services/AuthService';
import { DiscordUser } from '../types';
const EditMarker: React.FC = () => {
const { markerId } = useParams<{ markerId: string }>();
const navigate = useNavigate();
const [user, setUser] = useState<DiscordUser | null>(null);
const [marker, setMarker] = useState<MapMarker | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
// Form state
const [formData, setFormData] = useState({
name: '',
type: 'city' as const,
x_coord: 0,
z_coord: 0,
description: '',
linked_entity_type: '',
linked_entity_id: '',
icon_type: 'city' as const,
color: '#2563eb',
is_public: true
});
// Subscribe to auth changes
useEffect(() => {
const unsub = authService.subscribe((currentUser) => {
setUser(currentUser);
if (!currentUser?.isAdmin) {
navigate('/world-map');
}
});
return unsub;
}, [navigate]);
// Load marker data
useEffect(() => {
const loadMarker = async () => {
try {
setLoading(true);
setError(null);
const response = await fetch(`/api/map/markers/${markerId}`);
if (!response.ok) throw new Error('Fehler beim Laden des Markers');
const markerData = await response.json();
setMarker(markerData);
// Initialize form with marker data
setFormData({
name: markerData.name || '',
type: markerData.type || 'city',
x_coord: markerData.x_coord || 0,
z_coord: markerData.z_coord || 0,
description: markerData.description || '',
linked_entity_type: markerData.linked_entity_type || '',
linked_entity_id: markerData.linked_entity_id || '',
icon_type: markerData.icon_type || 'city',
color: markerData.color || '#2563eb',
is_public: markerData.is_public || true
});
} catch (err) {
console.error('Error loading marker:', err);
setError(err instanceof Error ? err.message : 'Unbekannter Fehler');
} finally {
setLoading(false);
}
};
if (markerId) {
loadMarker();
}
}, [markerId]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
const { name, value, type } = e.target;
setFormData(prev => ({
...prev,
[name]: type === 'checkbox' ? (e.target as HTMLInputElement).checked : value
}));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSuccess(null);
setError(null);
try {
const response = await fetch(`/api/map/markers/${markerId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(formData)
});
if (!response.ok) {
throw new Error('Fehler beim Aktualisieren des Markers');
}
setSuccess('Marker erfolgreich aktualisiert');
// Redirect back to map after 2 seconds
setTimeout(() => {
navigate('/world-map');
}, 2000);
} catch (err) {
console.error('Error updating marker:', err);
setError(err instanceof Error ? err.message : 'Unbekannter Fehler');
}
};
const handleDelete = async () => {
if (!window.confirm('Sind Sie sicher, dass Sie diesen Marker löschen möchten?')) {
return;
}
try {
const response = await fetch(`/api/map/markers/${markerId}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error('Fehler beim Löschen des Markers');
}
setSuccess('Marker erfolgreich gelöscht');
// Redirect back to map after 2 seconds
setTimeout(() => {
navigate('/world-map');
}, 2000);
} catch (err) {
console.error('Error deleting marker:', err);
setError(err instanceof Error ? err.message : 'Unbekannter Fehler');
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-screen">
<div className="text-textMuted">Lade Marker...</div>
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center h-screen">
<div className="text-red-500">Fehler: {error}</div>
</div>
);
}
if (!marker) {
return (
<div className="flex items-center justify-center h-screen">
<div className="text-textMuted">Marker nicht gefunden</div>
</div>
);
}
return (
<div className="max-w-2xl mx-auto p-6">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-textPrimary">Marker bearbeiten</h1>
<button
onClick={() => navigate('/world-map')}
className="px-4 py-2 text-textMuted hover:text-textPrimary transition-colors"
>
Zurück zur Karte
</button>
</div>
{success && (
<div className="mb-6 p-4 bg-green-500/10 border border-green-500/50 text-green-400 rounded-lg">
{success}
</div>
)}
{error && (
<div className="mb-6 p-4 bg-red-500/10 border border-red-500/50 text-red-400 rounded-lg">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-textMuted mb-2">
Name
</label>
<input
type="text"
name="name"
value={formData.name}
onChange={handleInputChange}
className="w-full px-3 py-2 bg-bgSecondary border border-border rounded-lg text-textPrimary focus:outline-none focus:border-accentInfo"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-textMuted mb-2">
Typ
</label>
<select
name="type"
value={formData.type}
onChange={handleInputChange}
className="w-full px-3 py-2 bg-bgSecondary border border-border rounded-lg text-textPrimary focus:outline-none focus:border-accentInfo"
>
<option value="city">Stadt</option>
<option value="poi">Sehenswürdigkeit</option>
<option value="player_home">Spieler-Haus</option>
<option value="waypoint">Wegpunkt</option>
</select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-textMuted mb-2">
X-Koordinate
</label>
<input
type="number"
name="x_coord"
value={formData.x_coord}
onChange={handleInputChange}
className="w-full px-3 py-2 bg-bgSecondary border border-border rounded-lg text-textPrimary focus:outline-none focus:border-accentInfo"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-textMuted mb-2">
Z-Koordinate
</label>
<input
type="number"
name="z_coord"
value={formData.z_coord}
onChange={handleInputChange}
className="w-full px-3 py-2 bg-bgSecondary border border-border rounded-lg text-textPrimary focus:outline-none focus:border-accentInfo"
required
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-textMuted mb-2">
Beschreibung
</label>
<textarea
name="description"
value={formData.description}
onChange={handleInputChange}
rows={4}
className="w-full px-3 py-2 bg-bgSecondary border border-border rounded-lg text-textPrimary focus:outline-none focus:border-accentInfo"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-textMuted mb-2">
Verknüpftes Entity (optional)
</label>
<select
name="linked_entity_type"
value={formData.linked_entity_type}
onChange={handleInputChange}
className="w-full px-3 py-2 bg-bgSecondary border border-border rounded-lg text-textPrimary focus:outline-none focus:border-accentInfo"
>
<option value="">Keine Verknüpfung</option>
<option value="organization">Organisation</option>
<option value="city">Stadt</option>
<option value="player">Spieler</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-textMuted mb-2">
Entity ID
</label>
<input
type="text"
name="linked_entity_id"
value={formData.linked_entity_id}
onChange={handleInputChange}
className="w-full px-3 py-2 bg-bgSecondary border border-border rounded-lg text-textPrimary focus:outline-none focus:border-accentInfo"
placeholder="z.B. city_12345"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-textMuted mb-2">
Icon-Typ
</label>
<select
name="icon_type"
value={formData.icon_type}
onChange={handleInputChange}
className="w-full px-3 py-2 bg-bgSecondary border border-border rounded-lg text-textPrimary focus:outline-none focus:border-accentInfo"
>
<option value="city">Stadt</option>
<option value="flag">Flagge</option>
<option value="house">Haus</option>
<option value="chest">Kiste</option>
<option value="star">Stern</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-textMuted mb-2">
Farbe
</label>
<input
type="color"
name="color"
value={formData.color}
onChange={handleInputChange}
className="w-full px-3 py-2 bg-bgSecondary border border-border rounded-lg text-textPrimary focus:outline-none focus:border-accentInfo"
/>
</div>
</div>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 text-textMuted cursor-pointer">
<input
type="checkbox"
name="is_public"
checked={formData.is_public}
onChange={handleInputChange}
className="w-4 h-4 text-accentInfo bg-bgSecondary border-border rounded focus:ring-accentInfo"
/>
Öffentlich sichtbar
</label>
</div>
<div className="flex gap-4">
<button
type="submit"
className="px-6 py-2 bg-accentInfo text-white rounded-lg hover:bg-accentInfo/90 transition-colors"
>
Speichern
</button>
<button
type="button"
onClick={handleDelete}
className="px-6 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors"
>
Löschen
</button>
</div>
</form>
</div>
);
};
export default EditMarker;

View File

@@ -7,6 +7,43 @@ import InventoryGrid from '../components/InventoryGrid';
import EditModal from '../components/EditModal';
import { Icons } from '../components/IconSet';
// Helper function to get advancement icon path
const getAdvancementIconPath = (advancementId: string): string => {
// Remove 'minecraft:' prefix if present
const cleanId = advancementId.replace('minecraft:', '');
// Map advancement ID to icon path
return `/assets/advancement/${cleanId}.png`;
};
// Helper function to get advancement category
const getAdvancementCategory = (advancementId: string): string => {
const cleanId = advancementId.replace('minecraft:', '');
if (cleanId.startsWith('adventure/')) return 'Abenteuer';
if (cleanId.startsWith('nether/')) return 'Nether';
if (cleanId.startsWith('end/')) return 'End';
if (cleanId.startsWith('husbandry/')) return 'Tierhaltung';
if (cleanId.startsWith('story/')) return 'Geschichte';
return 'Sonstige';
};
// Helper function to organize advancements by category
const organizeAdvancementsByCategory = (advancements: any[]) => {
const categories: { [key: string]: any[] } = {};
advancements.forEach(advancement => {
const category = getAdvancementCategory(advancement.id);
if (!categories[category]) {
categories[category] = [];
}
categories[category].push(advancement);
});
return categories;
};
const PlayerProfile: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
@@ -241,7 +278,7 @@ const PlayerProfile: React.FC = () => {
<span className="font-mono text-textMain">
{player.minecraftStats?.statistics?.general?.["minecraft:play_time"]
? `${Math.round((player.minecraftStats.statistics.general["minecraft:play_time"] || 0) / 20 / 3600)}h`
: `${player.minecraftStats?.playtimeHours || 0}h`
: '0h'
}
</span>
</div>
@@ -280,8 +317,7 @@ const PlayerProfile: React.FC = () => {
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left Col: Inventory & Org */}
<div className="lg:col-span-1 space-y-6">
<InventoryGrid items={player.inventory || []} />
<div className="lg:col-span-1 space-y-6">
<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>
@@ -302,7 +338,7 @@ const PlayerProfile: React.FC = () => {
{playerOrg.name}
</div>
<div className="text-xs text-textMuted">
{player.minecraftStats?.role || 'Unbekannt'} Klick zum Anzeigen
{player.stats?.role || 'Unbekannt'} Klick zum Anzeigen
</div>
</div>
</div>
@@ -464,13 +500,43 @@ const PlayerProfile: React.FC = () => {
{player.minecraftStats.advancements && player.minecraftStats.advancements.length > 0 && (
<div>
<h4 className="text-md font-semibold mb-3 text-textMain">Erfolge ({player.minecraftStats.advancements.length})</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{player.minecraftStats.advancements.map((advancement) => (
<div key={advancement.id} className="p-3 bg-surfaceHighlight/20 rounded border border-white/5">
<div className="text-sm font-medium text-textMain">{advancement.title}</div>
<div className="text-xs text-textMuted font-mono">{advancement.id.replace('minecraft:', '')}</div>
</div>
))}
<div className="space-y-6">
{Object.entries(organizeAdvancementsByCategory(player.minecraftStats.advancements))
.sort(([a], [b]) => a.localeCompare(b))
.map(([category, advancements]) => (
<div key={category} className="bg-surfaceHighlight/20 rounded-lg border border-white/5 p-4">
<div className="flex items-center gap-3 mb-3">
<span className="text-sm font-semibold text-accentInfo uppercase tracking-wide">{category}</span>
<span className="text-xs text-textMuted">({advancements.length})</span>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-4 gap-1">
{advancements.map((advancement) => (
<div
key={advancement.id}
className="group cursor-pointer hover:bg-white/5 p-2 rounded transition-all"
title={advancement.title}
>
<div className="flex flex-col items-center gap-2">
<div className="bg-white/5 rounded-lg flex items-center justify-center border border-white/10 group-hover:border-white/20 transition-colors">
<img
src={getAdvancementIconPath(advancement.id)}
alt={advancement.title}
className="w-28 h-28 object-contain"
onError={(e) => {
// Fallback to a default icon if the specific advancement icon doesn't exist
e.currentTarget.src = '/assets/advancement/advancement_categories.png';
}}
/>
</div>
<div className="text-xs text-center text-textMuted font-mono truncate w-full">
{advancement.id.replace('minecraft:', '').split('/')[1] || advancement.id.replace('minecraft:', '')}
</div>
</div>
</div>
))}
</div>
</div>
))}
</div>
</div>
)}

724
pages/WorldMap.tsx Normal file
View File

@@ -0,0 +1,724 @@
import React, { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { MapMarker, MapLayer, MapMetadata } from '../types';
import { authService } from '../services/AuthService';
import { DiscordUser } from '../types';
const WorldMap: React.FC = () => {
const navigate = useNavigate();
const [markers, setMarkers] = useState<MapMarker[]>([]);
const [layers, setLayers] = useState<MapLayer[]>([]);
const [mapMetadata, setMapMetadata] = useState<MapMetadata | null>(null);
const [user, setUser] = useState<DiscordUser | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Map interaction state
const [isDragging, setIsDragging] = useState(false);
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
const [mapPosition, setMapPosition] = useState({ x: 0, y: 0 });
const [scale, setScale] = useState(1);
const [showAdminPanel, setShowAdminPanel] = useState(false);
const [selectedMarker, setSelectedMarker] = useState<MapMarker | null>(null);
const [isAddingMarker, setIsAddingMarker] = useState(false);
const [showOriginPin, setShowOriginPin] = useState(false);
// Map assembly state
const [isAssembling, setIsAssembling] = useState(false);
const [assemblyProgress, setAssemblyProgress] = useState<string>('');
const mapContainerRef = useRef<HTMLDivElement>(null);
const mapImageRef = useRef<HTMLImageElement>(null);
// Subscribe to auth changes
useEffect(() => {
const unsub = authService.subscribe((currentUser) => {
setUser(currentUser);
});
return unsub;
}, []);
// Load map data
useEffect(() => {
const loadData = async () => {
try {
setLoading(true);
setError(null);
// Load map metadata (may not exist if map hasn't been assembled)
let metadata = null;
try {
const metadataResponse = await fetch('/api/map/metadata');
if (metadataResponse.ok) {
metadata = await metadataResponse.json();
setMapMetadata(metadata);
} else {
console.log('Map metadata not found - map may not be assembled yet');
}
} catch (metaErr) {
console.log('Map metadata not available:', metaErr);
}
// Load layers
const layersResponse = await fetch('/api/map/layers');
if (!layersResponse.ok) {
console.error('Layers API error:', layersResponse.status, layersResponse.statusText);
throw new Error(`Fehler beim Laden der Layer: ${layersResponse.status}`);
}
let layerData;
try {
layerData = await layersResponse.json();
} catch (jsonErr) {
console.error('Layers JSON parse error:', jsonErr);
throw new Error('Fehler beim Verarbeiten der Layer-Daten');
}
setLayers(layerData);
// Load markers
const markersResponse = await fetch('/api/map/markers/public');
if (!markersResponse.ok) {
console.error('Markers API error:', markersResponse.status, markersResponse.statusText);
throw new Error(`Fehler beim Laden der Marker: ${markersResponse.status}`);
}
let markerData;
try {
markerData = await markersResponse.json();
} catch (jsonErr) {
console.error('Markers JSON parse error:', jsonErr);
throw new Error('Fehler beim Verarbeiten der Marker-Daten');
}
setMarkers(markerData);
} catch (err) {
console.error('Error loading map data:', err);
setError(err instanceof Error ? err.message : 'Unbekannter Fehler');
} finally {
setLoading(false);
}
};
loadData();
}, []);
// Map interaction handlers
const handleMouseDown = (e: React.MouseEvent) => {
if (!mapContainerRef.current) return;
setIsDragging(true);
setDragStart({
x: e.clientX - mapPosition.x,
y: e.clientY - mapPosition.y
});
};
const handleMouseMove = (e: MouseEvent) => {
if (!isDragging || !mapContainerRef.current) return;
const newX = e.clientX - dragStart.x;
const newY = e.clientY - dragStart.y;
setMapPosition({ x: newX, y: newY });
};
const handleMouseUp = () => {
setIsDragging(false);
};
const handleWheel = (e: WheelEvent) => {
if (!mapContainerRef.current) return;
e.preventDefault();
const delta = e.deltaY > 0 ? 0.9 : 1.1;
const newScale = Math.max(0.5, Math.min(3, scale * delta));
// Adjust position to zoom towards mouse cursor
const mouseX = e.clientX;
const mouseY = e.clientY;
const containerRect = mapContainerRef.current.getBoundingClientRect();
const containerCenterX = containerRect.width / 2;
const containerCenterY = containerRect.height / 2;
const currentScale = scale;
const newScaleValue = newScale;
const offsetX = (mouseX - containerCenterX) * (1 - newScaleValue / currentScale);
const offsetY = (mouseY - containerCenterY) * (1 - newScaleValue / currentScale);
setMapPosition({
x: mapPosition.x - offsetX,
y: mapPosition.y - offsetY
});
setScale(newScale);
};
useEffect(() => {
if (isDragging) {
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
document.addEventListener('mouseleave', handleMouseUp);
} else {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.removeEventListener('mouseleave', handleMouseUp);
}
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.removeEventListener('mouseleave', handleMouseUp);
};
}, [isDragging, dragStart.x, dragStart.y, mapPosition.x, mapPosition.y]);
useEffect(() => {
const container = mapContainerRef.current;
if (container) {
container.addEventListener('wheel', handleWheel, { passive: false });
}
return () => {
if (container) {
container.removeEventListener('wheel', handleWheel);
}
};
}, [scale, mapPosition.x, mapPosition.y]);
// Handle marker clicks
const handleMarkerClick = (marker: MapMarker) => {
setSelectedMarker(marker);
// Navigate to linked entity if exists
if (marker.linked_entity_type && marker.linked_entity_id) {
if (marker.linked_entity_type === 'organization' || marker.linked_entity_type === 'city') {
navigate(`/cities/${marker.linked_entity_id}`);
}
}
};
// Admin functions
const handleAddMarker = (e: React.MouseEvent) => {
if (!user?.isAdmin || !mapContainerRef.current || !mapImageRef.current) return;
const rect = mapContainerRef.current.getBoundingClientRect();
const imageRect = mapImageRef.current.getBoundingClientRect();
// Calculate relative position within the image
const relativeX = e.clientX - imageRect.left;
const relativeY = e.clientY - imageRect.top;
// Convert to percentage
const percentX = (relativeX / imageRect.width) * 100;
const percentY = (relativeY / imageRect.height) * 100;
// Convert percentage to Minecraft coordinates using metadata
if (mapMetadata) {
const pixelX = (percentX / 100) * mapMetadata.width;
const pixelY = (percentY / 100) * mapMetadata.height;
// Convert back to Minecraft coordinates
const minecraftX = pixelX - mapMetadata.offsetX;
const minecraftZ = pixelY - mapMetadata.offsetZ;
// Create new marker
const newMarker = {
name: 'Neuer Marker',
type: 'poi' as const,
x_coord: Math.round(minecraftX),
z_coord: Math.round(minecraftZ),
description: '',
linked_entity_type: null,
linked_entity_id: null,
icon_type: 'flag',
color: '#2563eb',
is_public: 1
};
// Send to server
fetch('/api/map/markers', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(newMarker)
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Reload markers
return fetch('/api/map/markers/public');
} else {
throw new Error(data.message || 'Fehler beim Erstellen des Markers');
}
})
.then(response => response.json())
.then(markerData => {
setMarkers(markerData);
})
.catch(err => {
console.error('Error creating marker:', err);
setError(err.message);
});
}
};
const handleDeleteMarker = (markerId: string) => {
if (!user?.isAdmin) return;
fetch(`/api/map/markers/${markerId}`, {
method: 'DELETE'
})
.then(response => response.json())
.then(data => {
if (data.success) {
setMarkers(markers.filter(m => m.id !== markerId));
setSelectedMarker(null);
} else {
throw new Error(data.message || 'Fehler beim Löschen des Markers');
}
})
.catch(err => {
console.error('Error deleting marker:', err);
setError(err.message);
});
};
// Map assembly function with progress tracking
const handleAssembleMap = async () => {
if (!user?.isAdmin || isAssembling) return;
setIsAssembling(true);
setAssemblyProgress('Starte Karten-Zusammenstellung...');
try {
setAssemblyProgress('Überprüfe Kacheln...');
// First check if tiles exist
const tilesResponse = await fetch('/api/map/metadata');
if (!tilesResponse.ok) {
setAssemblyProgress('Keine Kacheln gefunden. Bitte stellen Sie sicher, dass PNG-Dateien im Ordner backend/uploads/map/ vorhanden sind.');
setTimeout(() => {
setIsAssembling(false);
setAssemblyProgress('');
}, 3000);
return;
}
setAssemblyProgress('Starte Karten-Zusammenstellung...');
const response = await fetch('/api/map/assemble', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
const errorMessage = errorData.error || `Server-Fehler: ${response.status}`;
const errorDetails = errorData.details || '';
const errorStack = errorData.stack || '';
console.error('Map assembly error details:', {
status: response.status,
error: errorMessage,
details: errorDetails,
stack: errorStack
});
setAssemblyProgress(`Fehler: ${errorMessage}`);
// Show detailed error in alert for debugging
if (errorDetails) {
alert(`Karten-Zusammenstellung fehlgeschlagen:\n\nFehler: ${errorMessage}\n\nDetails: ${errorDetails}\n\nStack: ${errorStack}`);
} else {
alert(`Karten-Zusammenstellung fehlgeschlagen:\n\nFehler: ${errorMessage}`);
}
setTimeout(() => {
setIsAssembling(false);
setAssemblyProgress('');
}, 5000);
return;
}
const result = await response.json();
if (result.success) {
setAssemblyProgress('Karte erfolgreich zusammengestellt!');
// Reload map metadata and markers
setTimeout(async () => {
try {
const metadataResponse = await fetch('/api/map/metadata');
if (metadataResponse.ok) {
const metadata = await metadataResponse.json();
setMapMetadata(metadata);
}
const markersResponse = await fetch('/api/map/markers/public');
if (markersResponse.ok) {
const markerData = await markersResponse.json();
setMarkers(markerData);
}
setIsAssembling(false);
setAssemblyProgress('');
alert('Weltkarte wurde erfolgreich zusammengestellt!');
} catch (err) {
console.error('Error reloading map data:', err);
setIsAssembling(false);
setAssemblyProgress('');
}
}, 1000);
} else {
throw new Error(result.message || 'Fehler beim Zusammensetzen der Karte');
}
} catch (err) {
console.error('Error assembling map:', err);
setAssemblyProgress(`Fehler: ${err.message}`);
setTimeout(() => {
setIsAssembling(false);
setAssemblyProgress('');
}, 3000);
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-screen">
<div className="text-textMuted">Lade Weltkarte...</div>
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center h-screen">
<div className="text-red-500">Fehler: {error}</div>
</div>
);
}
return (
<div className="flex h-screen bg-bgPrimary">
{/* Sidebar */}
<div className="w-80 bg-bgSecondary border-r border-border p-4 overflow-y-auto">
<div className="flex items-center justify-between mb-4">
<h1 className="text-xl font-bold text-textPrimary">Weltkarte</h1>
{user?.isAdmin && (
<button
onClick={() => setShowAdminPanel(!showAdminPanel)}
className="px-3 py-1 bg-accentInfo text-white rounded hover:bg-accentInfo/90"
>
Admin
</button>
)}
</div>
{/* Map Controls */}
<div className="mb-4 p-3 bg-bgPrimary rounded">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-textMuted">Zoom</span>
<div className="flex gap-2">
<button
onClick={() => setScale(Math.max(0.1, scale - 0.1))}
className="px-2 py-1 bg-bgSecondary rounded hover:bg-bgTertiary"
>
-
</button>
<button
onClick={() => setScale(Math.min(10, scale + 0.1))}
className="px-2 py-1 bg-bgSecondary rounded hover:bg-bgTertiary"
>
+
</button>
</div>
</div>
<div className="text-xs text-textMuted">
Mausrad zum Zoomen, Klicken zum Verschieben
</div>
</div>
{/* Origin Controls */}
<div className="mb-4 p-3 bg-bgPrimary rounded">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-textMuted">Origin (0,0)</span>
<button
onClick={() => {
if (mapMetadata && mapContainerRef.current) {
const container = mapContainerRef.current;
const containerRect = container.getBoundingClientRect();
// Calculate where the origin (0,0) is in the map image pixels
const originXInPixels = mapMetadata.offsetX;
const originYInPixels = mapMetadata.offsetZ;
// Calculate the center of the viewport
const viewportCenterX = containerRect.width / 2;
const viewportCenterY = containerRect.height / 2;
// Position the map so that origin (0,0) is in the center of the viewport
const mapX = viewportCenterX - originXInPixels * scale;
const mapY = viewportCenterY - originYInPixels * scale;
setMapPosition({
x: mapX,
y: mapY
});
}
}}
className={`px-3 py-1 rounded text-sm ${
showOriginPin ? 'bg-accentInfo text-white' : 'bg-bgSecondary text-textPrimary'
}`}
>
{showOriginPin ? 'Origin zentrieren' : 'Origin zentrieren'}
</button>
</div>
<div className="text-xs text-textMuted">
Positioniert den Ursprung (0,0) in der Mitte der Karte
</div>
</div>
{/* Layers */}
<div className="mb-4">
<h3 className="text-sm font-semibold text-textPrimary mb-2">Layer</h3>
{layers.map(layer => (
<label key={layer.id} className="flex items-center gap-2 text-sm text-textPrimary mb-1">
<input type="checkbox" defaultChecked={layer.is_active} />
{layer.name}
</label>
))}
</div>
{/* Markers List */}
<div>
<h3 className="text-sm font-semibold text-textPrimary mb-2">Orte</h3>
{markers.length === 0 ? (
<p className="text-textMuted text-sm">Keine Marker gefunden</p>
) : (
markers.map(marker => (
<div
key={marker.id}
onClick={() => handleMarkerClick(marker)}
className={`p-2 rounded mb-2 cursor-pointer hover:bg-bgPrimary ${
selectedMarker?.id === marker.id ? 'bg-bgPrimary' : ''
}`}
>
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: marker.color }}
></div>
<div>
<div className="font-medium text-textPrimary">{marker.name}</div>
<div className="text-xs text-textMuted">
{marker.type} {marker.x_coord}, {marker.z_coord}
</div>
</div>
</div>
{marker.description && (
<p className="text-xs text-textMuted mt-1">{marker.description}</p>
)}
</div>
))
)}
</div>
</div>
{/* Map Container */}
<div
ref={mapContainerRef}
className="flex-1 relative overflow-hidden cursor-grab active:cursor-grabbing"
onMouseDown={user?.isAdmin && isAddingMarker ? handleAddMarker : handleMouseDown}
>
{mapMetadata ? (
<img
ref={mapImageRef}
src="/api/map/world-map"
alt="Weltkarte"
className="absolute"
style={{
left: mapPosition.x,
top: mapPosition.y,
transform: `scale(${scale})`,
transformOrigin: 'top left',
cursor: 'grab'
}}
onLoad={() => {
// Center the map and position origin (0,0) in the center
if (mapImageRef.current && mapContainerRef.current && mapMetadata) {
const img = mapImageRef.current;
const container = mapContainerRef.current;
const containerRect = container.getBoundingClientRect();
// Calculate where the origin (0,0) is in the map image pixels
const originXInPixels = mapMetadata.offsetX;
const originYInPixels = mapMetadata.offsetZ;
// Calculate the center of the viewport
const viewportCenterX = containerRect.width / 2;
const viewportCenterY = containerRect.height / 2;
// Position the map so that origin (0,0) is in the center of the viewport
// We need to position the image so that the origin pixel is at the viewport center
const mapX = viewportCenterX - originXInPixels;
const mapY = viewportCenterY - originYInPixels;
setMapPosition({
x: mapX,
y: mapY
});
}
}}
/>
) : (
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center p-8 bg-bgSecondary border border-border rounded-xl">
<div className="text-6xl mb-4">🗺</div>
<h3 className="text-xl font-bold text-textPrimary mb-2">Weltkarte nicht verfügbar</h3>
<p className="text-textMuted mb-4">
Die Weltkarte muss zuerst im Admin-Bereich zusammengestellt werden.
</p>
{user?.isAdmin ? (
<div className="space-y-2">
<p className="text-sm text-textMuted">
1. Klicken Sie auf "Admin" oben rechts
</p>
<p className="text-sm text-textMuted">
2. Klicken Sie auf "Karte neu zusammensetzen"
</p>
<p className="text-sm text-textMuted">
3. Warten Sie, bis die Verarbeitung abgeschlossen ist
</p>
</div>
) : (
<p className="text-sm text-textMuted">
Bitte wenden Sie sich an einen Administrator, um die Weltkarte zu erstellen.
</p>
)}
</div>
</div>
)}
{/* Markers Overlay */}
{markers.map(marker => (
<div
key={marker.id}
className="absolute w-6 h-6 cursor-pointer transform -translate-x-3 -translate-y-3 hover:scale-125 transition-transform"
style={{
left: `${marker.coordinates?.x || 0}%`,
top: `${marker.coordinates?.y || 0}%`,
zIndex: 10
}}
onClick={() => handleMarkerClick(marker)}
title={marker.name}
>
<div
className="w-full h-full rounded-full border-2 border-white shadow-lg"
style={{ backgroundColor: marker.color }}
></div>
<div className="absolute -bottom-6 left-1/2 transform -translate-x-1/2 text-xs bg-black bg-opacity-75 text-white px-2 py-1 rounded whitespace-nowrap">
{marker.name}
</div>
</div>
))}
{/* Admin Panel */}
{showAdminPanel && user?.isAdmin && (
<div className="absolute top-4 right-4 bg-bgSecondary border border-border p-4 rounded shadow-lg max-w-sm">
<h3 className="text-sm font-semibold text-textPrimary mb-2">Admin-Tools</h3>
{/* Map Assembly Section */}
<div className="mb-3 p-3 bg-bgPrimary rounded">
<h4 className="text-xs font-semibold text-textPrimary mb-2">Karten-Zusammenstellung</h4>
{isAssembling ? (
<div className="space-y-2">
<div className="flex items-center gap-2">
<div className="w-4 h-4 border-2 border-accentSuccess border-t-transparent rounded-full animate-spin"></div>
<span className="text-sm text-textPrimary">{assemblyProgress}</span>
</div>
<div className="text-xs text-textMuted">
Bitte warten Sie, bis der Vorgang abgeschlossen ist...
</div>
</div>
) : (
<div className="space-y-2">
<button
onClick={handleAssembleMap}
disabled={isAssembling}
className="w-full px-3 py-2 bg-accentSuccess text-white rounded text-sm hover:bg-accentSuccess/90 disabled:opacity-50 disabled:cursor-not-allowed"
>
{mapMetadata ? 'Karte neu zusammensetzen' : 'Karte zusammensetzen'}
</button>
<div className="text-xs text-textMuted">
{mapMetadata
? 'Aktualisiert die bestehende Weltkarte mit neuen Kacheln'
: 'Erstellt die Weltkarte aus den PNG-Kacheln im Upload-Ordner'
}
</div>
</div>
)}
</div>
{/* Marker Management Section */}
<div className="space-y-2">
<button
onClick={() => setIsAddingMarker(!isAddingMarker)}
className={`w-full px-3 py-2 rounded text-sm ${
isAddingMarker ? 'bg-accentInfo text-white' : 'bg-bgPrimary text-textPrimary'
}`}
>
{isAddingMarker ? 'Marker hinzufügen (aktiv)' : 'Marker hinzufügen'}
</button>
<div className="text-xs text-textMuted">
Klicken Sie auf die Karte, um neue Marker hinzuzufügen
</div>
</div>
</div>
)}
{/* Selected Marker Info */}
{selectedMarker && (
<div className="absolute bottom-4 left-4 bg-bgSecondary border border-border p-4 rounded shadow-lg max-w-sm">
<div className="flex items-center justify-between mb-2">
<h3 className="font-semibold text-textPrimary">{selectedMarker.name}</h3>
<button
onClick={() => setSelectedMarker(null)}
className="text-textMuted hover:text-textPrimary"
>
</button>
</div>
<div className="text-sm text-textMuted mb-2">
<div>{selectedMarker.type}</div>
<div>Koordinaten: {selectedMarker.x_coord}, {selectedMarker.z_coord}</div>
</div>
{selectedMarker.description && (
<p className="text-sm text-textPrimary mb-3">{selectedMarker.description}</p>
)}
{user?.isAdmin && (
<div className="flex gap-2">
<button
onClick={() => navigate(`/admin/edit-marker/${selectedMarker.id}`)}
className="px-3 py-1 bg-accentInfo text-white rounded text-sm hover:bg-accentInfo/90"
>
Bearbeiten
</button>
<button
onClick={() => handleDeleteMarker(selectedMarker.id)}
className="px-3 py-1 bg-red-500 text-white rounded text-sm hover:bg-red-600"
>
Löschen
</button>
</div>
)}
</div>
)}
</div>
</div>
);
};
export default WorldMap;

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 923 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 814 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1018 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 912 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1013 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 939 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 935 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 800 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 908 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1020 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 989 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 858 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 775 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 938 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 986 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 531 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 944 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 810 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 856 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 839 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 962 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 788 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 889 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 902 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 961 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 869 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 956 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 810 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 799 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 654 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 798 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 973 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 880 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 581 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 754 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 943 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 828 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 648 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 711 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 937 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 827 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 711 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 739 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 783 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 926 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 945 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 726 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 509 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 827 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 849 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 968 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 771 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 761 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Some files were not shown because too many files have changed in this diff Show More