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
12
App.tsx
@@ -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
@@ -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
|
||||
@@ -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
|
||||
|
||||
52
backend/debug-map-detailed.js
Normal 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
@@ -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
@@ -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;
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
696
pages/AdminMapManagement.tsx
Normal 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;
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
@@ -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>
|
||||
@@ -281,7 +318,6 @@ 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="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
@@ -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;
|
||||
BIN
public/assets/advancement/advancement_categories.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
public/assets/advancement/advancement_groups.png
Normal file
|
After Width: | Height: | Size: 618 B |
BIN
public/assets/advancement/adventure/adventure_time.png
Normal file
|
After Width: | Height: | Size: 923 B |
BIN
public/assets/advancement/adventure/arbalistic.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
public/assets/advancement/adventure/avoid_vibration.png
Normal file
|
After Width: | Height: | Size: 814 B |
BIN
public/assets/advancement/adventure/blowback.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
public/assets/advancement/adventure/brush_armadillo.png
Normal file
|
After Width: | Height: | Size: 1018 B |
BIN
public/assets/advancement/adventure/bullseye.png
Normal file
|
After Width: | Height: | Size: 912 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
BIN
public/assets/advancement/adventure/fall_from_world_height.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
public/assets/advancement/adventure/heart_transplanter.png
Normal file
|
After Width: | Height: | Size: 1013 B |
BIN
public/assets/advancement/adventure/hero_of_the_village.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
public/assets/advancement/adventure/honey_block_slide.png
Normal file
|
After Width: | Height: | Size: 939 B |
BIN
public/assets/advancement/adventure/kill_a_mob.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
public/assets/advancement/adventure/kill_all_mobs.png
Normal file
|
After Width: | Height: | Size: 935 B |
|
After Width: | Height: | Size: 800 B |
BIN
public/assets/advancement/adventure/lighten_up.png
Normal file
|
After Width: | Height: | Size: 908 B |
|
After Width: | Height: | Size: 1020 B |
BIN
public/assets/advancement/adventure/minecraft_trials_edition.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
public/assets/advancement/adventure/ol_betsy.png
Normal file
|
After Width: | Height: | Size: 989 B |
BIN
public/assets/advancement/adventure/overoverkill.png
Normal file
|
After Width: | Height: | Size: 721 B |
BIN
public/assets/advancement/adventure/play_jukebox_in_meadows.png
Normal file
|
After Width: | Height: | Size: 858 B |
|
After Width: | Height: | Size: 982 B |
BIN
public/assets/advancement/adventure/revaulting.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
public/assets/advancement/adventure/root.png
Normal file
|
After Width: | Height: | Size: 775 B |
BIN
public/assets/advancement/adventure/salvage_sherd.png
Normal file
|
After Width: | Height: | Size: 938 B |
BIN
public/assets/advancement/adventure/shoot_arrow.png
Normal file
|
After Width: | Height: | Size: 986 B |
BIN
public/assets/advancement/adventure/sleep_in_bed.png
Normal file
|
After Width: | Height: | Size: 531 B |
BIN
public/assets/advancement/adventure/sniper_duel.png
Normal file
|
After Width: | Height: | Size: 944 B |
BIN
public/assets/advancement/adventure/spear_many_mobs.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
public/assets/advancement/adventure/spyglass_at_dragon.png
Normal file
|
After Width: | Height: | Size: 810 B |
BIN
public/assets/advancement/adventure/spyglass_at_ghast.png
Normal file
|
After Width: | Height: | Size: 856 B |
BIN
public/assets/advancement/adventure/spyglass_at_parrot.png
Normal file
|
After Width: | Height: | Size: 839 B |
BIN
public/assets/advancement/adventure/summon_iron_golem.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
public/assets/advancement/adventure/throw_trident.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
public/assets/advancement/adventure/totem_of_undying.png
Normal file
|
After Width: | Height: | Size: 962 B |
BIN
public/assets/advancement/adventure/trade.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
public/assets/advancement/adventure/trade_at_world_height.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
BIN
public/assets/advancement/adventure/two_birds_one_arrow.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
public/assets/advancement/adventure/under_lock_and_key.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
public/assets/advancement/adventure/use_lodestone.png
Normal file
|
After Width: | Height: | Size: 788 B |
BIN
public/assets/advancement/adventure/very_very_frightening.png
Normal file
|
After Width: | Height: | Size: 889 B |
BIN
public/assets/advancement/adventure/voluntary_exile.png
Normal file
|
After Width: | Height: | Size: 902 B |
|
After Width: | Height: | Size: 961 B |
BIN
public/assets/advancement/adventure/who_needs_rockets.png
Normal file
|
After Width: | Height: | Size: 869 B |
BIN
public/assets/advancement/adventure/whos_the_pillager_now.png
Normal file
|
After Width: | Height: | Size: 956 B |
BIN
public/assets/advancement/end/dragon_breath.png
Normal file
|
After Width: | Height: | Size: 810 B |
BIN
public/assets/advancement/end/dragon_egg.png
Normal file
|
After Width: | Height: | Size: 721 B |
BIN
public/assets/advancement/end/elytra.png
Normal file
|
After Width: | Height: | Size: 799 B |
BIN
public/assets/advancement/end/enter_end_gateway.png
Normal file
|
After Width: | Height: | Size: 654 B |
BIN
public/assets/advancement/end/find_end_city.png
Normal file
|
After Width: | Height: | Size: 798 B |
BIN
public/assets/advancement/end/kill_dragon.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
public/assets/advancement/end/levitate.png
Normal file
|
After Width: | Height: | Size: 973 B |
BIN
public/assets/advancement/end/respawn_dragon.png
Normal file
|
After Width: | Height: | Size: 880 B |
BIN
public/assets/advancement/end/root.png
Normal file
|
After Width: | Height: | Size: 581 B |
|
After Width: | Height: | Size: 754 B |
|
After Width: | Height: | Size: 943 B |
BIN
public/assets/advancement/husbandry/axolotl_in_a_bucket.png
Normal file
|
After Width: | Height: | Size: 828 B |
BIN
public/assets/advancement/husbandry/balanced_diet.png
Normal file
|
After Width: | Height: | Size: 648 B |
BIN
public/assets/advancement/husbandry/breed_all_animals.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
public/assets/advancement/husbandry/breed_an_animal.png
Normal file
|
After Width: | Height: | Size: 711 B |
BIN
public/assets/advancement/husbandry/complete_catalogue.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
public/assets/advancement/husbandry/feed_snifflet.png
Normal file
|
After Width: | Height: | Size: 937 B |
BIN
public/assets/advancement/husbandry/fishy_business.png
Normal file
|
After Width: | Height: | Size: 827 B |
BIN
public/assets/advancement/husbandry/froglights.png
Normal file
|
After Width: | Height: | Size: 711 B |
BIN
public/assets/advancement/husbandry/kill_axolotl_target.png
Normal file
|
After Width: | Height: | Size: 739 B |
BIN
public/assets/advancement/husbandry/leash_all_frog_variants.png
Normal file
|
After Width: | Height: | Size: 783 B |
BIN
public/assets/advancement/husbandry/make_a_sign_glow.png
Normal file
|
After Width: | Height: | Size: 926 B |
BIN
public/assets/advancement/husbandry/netherite_hoe.png
Normal file
|
After Width: | Height: | Size: 945 B |
BIN
public/assets/advancement/husbandry/obtain_sniffer_egg.png
Normal file
|
After Width: | Height: | Size: 726 B |
|
After Width: | Height: | Size: 509 B |
BIN
public/assets/advancement/husbandry/plant_any_sniffer_seed.png
Normal file
|
After Width: | Height: | Size: 827 B |
BIN
public/assets/advancement/husbandry/plant_seed.png
Normal file
|
After Width: | Height: | Size: 849 B |
BIN
public/assets/advancement/husbandry/remove_wolf_armor.png
Normal file
|
After Width: | Height: | Size: 968 B |
BIN
public/assets/advancement/husbandry/repair_wolf_armor.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
public/assets/advancement/husbandry/ride_a_boat_with_a_goat.png
Normal file
|
After Width: | Height: | Size: 771 B |
BIN
public/assets/advancement/husbandry/root.png
Normal file
|
After Width: | Height: | Size: 761 B |
BIN
public/assets/advancement/husbandry/safely_harvest_honey.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |