Files
Lars Behrends 065a6e657d 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
2026-01-02 05:08:07 +01:00

725 lines
32 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;