feat: add world map functionality and admin map management

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

724
pages/WorldMap.tsx Normal file
View File

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