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

View File

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