mirror of
https://github.com/ceratic/project_vollidioten_website.git
synced 2026-05-14 00:16:47 +02:00
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:
@@ -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 */}
|
||||
|
||||
696
pages/AdminMapManagement.tsx
Normal file
696
pages/AdminMapManagement.tsx
Normal 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;
|
||||
@@ -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
469
pages/Dokumentation.tsx
Normal 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
366
pages/EditMarker.tsx
Normal 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;
|
||||
@@ -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
724
pages/WorldMap.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user