mirror of
https://github.com/ceratic/project_vollidioten_website.git
synced 2026-05-14 00:16:47 +02:00
- Added world map page with interactive marker display - Implemented admin map management for marker CRUD operations - Added map layers and markers seed data to database - Integrated new routes for map functionality - Updated database configuration for production environment - Added documentation page route - Enhanced package.json with required dependencies for map features
360 lines
19 KiB
TypeScript
360 lines
19 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { useParams, useNavigate } from 'react-router-dom';
|
|
import { Organization, Project, Player } from '../types';
|
|
import { Icons } from '../components/IconSet';
|
|
import { dbService } from '../services/DatabaseService';
|
|
|
|
const CityProfile: React.FC = () => {
|
|
const { id } = useParams<{ id: string }>();
|
|
const navigate = useNavigate();
|
|
const [city, setCity] = useState<Organization | null>(null);
|
|
const backLabel = 'Zurück zu Städte';
|
|
|
|
const onSelectPlayer = (playerId: string) => navigate(`/players/${playerId}`);
|
|
const onSelectProject = (projectId: string) => navigate(`/projects/${projectId}`);
|
|
const [activeTab, setActiveTab] = useState<'overview' | 'residents' | 'ventures'>('overview');
|
|
const [residents, setResidents] = useState<Player[]>([]);
|
|
const [ventures, setVentures] = useState<Project[]>([]);
|
|
const [cityStats, setCityStats] = useState<any>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
if (!id) {
|
|
navigate('/cities');
|
|
return;
|
|
}
|
|
|
|
// Fetch fresh data from database on mount/reload
|
|
const loadCityData = async () => {
|
|
try {
|
|
await dbService.fetchAll();
|
|
const cityData = dbService.getOrg(id);
|
|
if (cityData) {
|
|
setCity(cityData);
|
|
} else {
|
|
navigate('/cities');
|
|
return;
|
|
}
|
|
} catch (error) {
|
|
console.warn('Failed to fetch fresh city data:', error);
|
|
// Fallback to cached data
|
|
const cityData = dbService.getOrg(id);
|
|
if (cityData) {
|
|
setCity(cityData);
|
|
} else {
|
|
navigate('/cities');
|
|
return;
|
|
}
|
|
}
|
|
setLoading(false);
|
|
};
|
|
|
|
loadCityData();
|
|
}, [id, navigate]);
|
|
|
|
useEffect(() => {
|
|
if (!city) return;
|
|
|
|
const loadCityData = () => {
|
|
// Load residents (players in this city)
|
|
const allPlayers = dbService.getPlayers();
|
|
const cityResidents = allPlayers.filter(p => p.organizationId === city.id);
|
|
setResidents(cityResidents);
|
|
|
|
// Load ventures (projects in this city)
|
|
const allProjects = dbService.getProjects();
|
|
const cityVentures = allProjects.filter(p => p.associatedOrgId === city.id);
|
|
setVentures(cityVentures);
|
|
|
|
// Calculate dynamic city statistics
|
|
const stats = {
|
|
citizens: cityResidents.length,
|
|
businesses: cityVentures.length,
|
|
activeBusinesses: cityVentures.filter(p => p.status === 'active').length,
|
|
recruitingBusinesses: cityVentures.filter(p => p.hiring).length,
|
|
// Merge with existing static stats
|
|
...city.cityStats
|
|
};
|
|
setCityStats(stats);
|
|
};
|
|
|
|
// Initial load
|
|
loadCityData();
|
|
|
|
// Subscribe to updates
|
|
const unsub = dbService.subscribe(loadCityData);
|
|
return unsub;
|
|
}, [city]);
|
|
|
|
if (loading || !city) {
|
|
return (
|
|
<div className="max-w-4xl mx-auto animate-in slide-in-from-right-4 duration-300">
|
|
<div className="flex justify-center py-20">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-accentInfo"></div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="animate-in slide-in-from-right-4 duration-300">
|
|
<button onClick={() => navigate('/cities')} className="flex items-center gap-2 text-sm text-textMuted hover:text-textMain mb-6 transition-colors group">
|
|
<span className="group-hover:-translate-x-1 transition-transform">←</span> {backLabel}
|
|
</button>
|
|
|
|
{/* Hero Header */}
|
|
<div className="relative h-64 md:h-80 rounded-2xl overflow-hidden border border-border mb-8 shadow-card">
|
|
<img
|
|
src={city.bannerUrl || 'https://images.unsplash.com/photo-1562774053-701939374585?q=80&w=2086&auto=format&fit=crop'}
|
|
alt={city.name}
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
<div className="absolute inset-0 bg-gradient-to-t from-background via-background/50 to-transparent" />
|
|
|
|
<div className="absolute bottom-0 left-0 right-0 p-8">
|
|
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4">
|
|
<div>
|
|
{city.establishedYear && (
|
|
<div className="text-accentInfo font-mono text-xs mb-2 bg-black/40 inline-block px-2 py-1 rounded backdrop-blur border border-white/5">
|
|
GEGR. {city.establishedYear}
|
|
</div>
|
|
)}
|
|
<h1 className="text-4xl md:text-6xl font-bold text-white tracking-tight mb-2">{city.name}</h1>
|
|
<div className="flex items-center gap-4 text-sm text-gray-300">
|
|
<span className="flex items-center gap-1.5">
|
|
<Icons.Users className="w-4 h-4" /> {city.memberCount} Mitglieder
|
|
</span>
|
|
<span className="flex items-center gap-1.5">
|
|
<div className="w-2 h-2 rounded-full bg-accentSuccess animate-pulse" /> {city.status}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
{city.mayor && (
|
|
<div className="text-right">
|
|
<div className="text-xs text-textMuted uppercase tracking-widest mb-1">Aktueller Anführer</div>
|
|
<div className="flex items-center gap-2 text-lg font-medium text-amber-100 bg-surfaceHighlight/60 px-4 py-2 rounded-lg backdrop-blur border border-amber-500/30 shadow-[0_0_15px_rgba(245,158,11,0.15)] group hover:border-amber-500/50 transition-colors">
|
|
<Icons.Crown className="w-5 h-5 text-amber-400 drop-shadow-md" />
|
|
{city.mayor}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Navigation Tabs */}
|
|
<div className="flex gap-1 border-b border-border mb-8 overflow-x-auto">
|
|
<button
|
|
onClick={() => setActiveTab('overview')}
|
|
className={`px-6 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${activeTab === 'overview' ? 'border-accentInfo text-white' : 'border-transparent text-textMuted hover:text-white'}`}
|
|
>
|
|
Übersicht & Galerie
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('residents')}
|
|
className={`px-6 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${activeTab === 'residents' ? 'border-accentInfo text-white' : 'border-transparent text-textMuted hover:text-white'}`}
|
|
>
|
|
Bewohner ({residents.length})
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('ventures')}
|
|
className={`px-6 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${activeTab === 'ventures' ? 'border-accentInfo text-white' : 'border-transparent text-textMuted hover:text-white'}`}
|
|
>
|
|
Lokale Unternehmen ({ventures.length})
|
|
</button>
|
|
</div>
|
|
|
|
{/* Content Area */}
|
|
<div className="min-h-[400px]">
|
|
{activeTab === 'overview' && (
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
|
<div className="lg:col-span-2 space-y-8">
|
|
<section>
|
|
<h3 className="text-xl font-bold mb-4 flex items-center gap-2">
|
|
<Icons.Map className="w-5 h-5 text-accentInfo" /> Über {city.name}
|
|
</h3>
|
|
<p className="text-textMuted leading-relaxed text-lg">
|
|
{city.description}
|
|
</p>
|
|
</section>
|
|
|
|
{city.gallery && city.gallery.length > 0 && (
|
|
<section>
|
|
<h3 className="text-xl font-bold mb-4">Bildarchiv</h3>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
{city.gallery.map((url, idx) => (
|
|
<div key={idx} className="rounded-xl overflow-hidden aspect-video border border-border group relative">
|
|
<img src={url} alt={`Gallery ${idx}`} className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110" />
|
|
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
|
|
</div>
|
|
))}
|
|
</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 && (
|
|
<div className="bg-surface border border-border rounded-xl p-6 h-fit">
|
|
<h4 className="text-sm font-bold uppercase text-textMuted mb-6 flex items-center gap-2">
|
|
<Icons.Terminal className="w-4 h-4" /> Stadt-Statistiken
|
|
</h4>
|
|
<div className="space-y-6">
|
|
|
|
{/* Tax Rate */}
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-2 bg-green-500/10 rounded text-green-400">
|
|
<Icons.Coins className="w-4 h-4" />
|
|
</div>
|
|
<span className="text-sm text-gray-400">Steuersatz</span>
|
|
</div>
|
|
<div className="text-right">
|
|
<span className="font-mono text-lg text-white font-medium">{city.cityStats.taxRate}%</span>
|
|
<div className="text-[10px] text-textMuted">Pro Transaktion</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="w-full bg-white/5 h-px" />
|
|
|
|
{/* Defense */}
|
|
<div>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-2 bg-blue-500/10 rounded text-blue-400">
|
|
<Icons.Shield className="w-4 h-4" />
|
|
</div>
|
|
<span className="text-sm text-gray-400">Verteidigungswert</span>
|
|
</div>
|
|
<span className="font-mono text-white font-medium">{city.cityStats.defenseRating}/10</span>
|
|
</div>
|
|
<div className="h-1.5 w-full bg-surfaceHighlight rounded-full overflow-hidden">
|
|
<div className="h-full bg-blue-500/70" style={{ width: `${(city.cityStats.defenseRating / 10) * 100}%` }} />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="w-full bg-white/5 h-px" />
|
|
|
|
{/* Gov Type */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-2 bg-purple-500/10 rounded text-purple-400">
|
|
<Icons.Scroll className="w-4 h-4" />
|
|
</div>
|
|
<span className="text-sm text-gray-400">Regierungsform</span>
|
|
</div>
|
|
<span className="text-sm text-white font-medium">{city.cityStats.government}</span>
|
|
</div>
|
|
|
|
<div className="w-full bg-white/5 h-px" />
|
|
|
|
{/* Biome */}
|
|
<div className="flex justify-between items-center py-1">
|
|
<span className="text-sm text-gray-400">Biom</span>
|
|
<span className="text-sm text-white">{city.cityStats.biome}</span>
|
|
</div>
|
|
<div className="flex justify-between items-center py-1">
|
|
<span className="text-sm text-gray-400">Spezialität</span>
|
|
<span className="text-sm text-white">{city.cityStats.specialty}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'residents' && (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
|
{residents.map(player => (
|
|
<div
|
|
key={player.uuid}
|
|
onClick={() => onSelectPlayer(player.uuid)}
|
|
className="bg-surface border border-border p-4 rounded-xl flex items-center gap-4 hover:border-accentInfo/50 transition-colors cursor-pointer group hover:shadow-card hover:bg-surfaceHighlight/30"
|
|
>
|
|
<div className="w-10 h-10 flex items-center justify-center font-bold text-lg text-textMuted group-hover:text-textMain transition-colors">
|
|
<img src={"https://minotar.net/armor/bust/"+player.username+"/500.png"}></img>
|
|
</div>
|
|
<div>
|
|
<div className="font-medium text-white group-hover:text-accentInfo transition-colors">{player.username}</div>
|
|
<div className="text-xs text-textMuted">{player.stats.role}</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
{residents.length === 0 && <div className="text-textMuted italic">Keine Bewohner öffentlich registriert.</div>}
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'ventures' && (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
{ventures.map(project => (
|
|
<div
|
|
key={project.id}
|
|
onClick={() => onSelectProject(project.id)}
|
|
className="bg-surface border border-border rounded-xl p-5 hover:border-accentInfo/50 transition-all cursor-pointer group hover:shadow-card hover:bg-surfaceHighlight/30"
|
|
>
|
|
<div className="flex justify-between items-start mb-2">
|
|
<span className={`text-[10px] uppercase font-bold px-2 py-0.5 rounded ${
|
|
project.category === 'Story Arc' ? 'bg-orange-500/10 text-orange-400' : 'bg-blue-500/10 text-blue-400'
|
|
}`}>
|
|
{project.category}
|
|
</span>
|
|
{project.hiring && (
|
|
<span className="text-[10px] font-bold text-accentSuccess animate-pulse">STELLEN</span>
|
|
)}
|
|
</div>
|
|
<h3 className="text-lg font-bold text-white mb-1 group-hover:text-accentInfo transition-colors">{project.title}</h3>
|
|
<p className="text-sm text-textMuted mb-4 line-clamp-2">{project.description}</p>
|
|
<div className="flex items-center justify-between pt-4 border-t border-white/5">
|
|
<span className="text-xs text-textMuted">Geführt von {project.owner}</span>
|
|
<div className="text-xs font-mono text-white">{project.progress}% Fortschr.</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
{ventures.length === 0 && <div className="text-textMuted italic">Keine aktiven Unternehmen in dieser Organisation.</div>}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default CityProfile;
|