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

@@ -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 */}

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;

View File

@@ -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
View 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
View 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;

View File

@@ -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>
@@ -280,8 +317,7 @@ 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="lg:col-span-1 space-y-6">
<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
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;