mirror of
https://github.com/ceratic/project_vollidioten_website.git
synced 2026-05-14 00:16:47 +02:00
- 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
725 lines
32 KiB
TypeScript
725 lines
32 KiB
TypeScript
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;
|