mirror of
https://github.com/ceratic/project_vollidioten_website.git
synced 2026-05-14 00:16:47 +02:00
505 lines
24 KiB
TypeScript
505 lines
24 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { useParams, useNavigate } from 'react-router-dom';
|
|
import { Player } from '../types';
|
|
import { dbService } from '../services/DatabaseService';
|
|
import { authService } from '../services/AuthService';
|
|
import InventoryGrid from '../components/InventoryGrid';
|
|
import EditModal from '../components/EditModal';
|
|
import { Icons } from '../components/IconSet';
|
|
|
|
const PlayerProfile: React.FC = () => {
|
|
const { id } = useParams<{ id: string }>();
|
|
const navigate = useNavigate();
|
|
const [player, setPlayer] = useState<Player | null>(null);
|
|
const [currentUser, setCurrentUser] = useState(authService.getUser());
|
|
|
|
// Edit State
|
|
const [isEditStoryOpen, setIsEditStoryOpen] = useState(false);
|
|
const [isEditTagsOpen, setIsEditTagsOpen] = useState(false);
|
|
const [isEditOrgOpen, setIsEditOrgOpen] = useState(false);
|
|
const [ownedProjects, setOwnedProjects] = useState<any[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [activeTab, setActiveTab] = useState<'story' | 'stats' | 'projects'>('story');
|
|
|
|
// Is this the logged-in user's profile?
|
|
const isOwner = currentUser?.linkedPlayerUuid === player?.uuid;
|
|
const playerOrg = player ? dbService.getOrg(player.organizationId || '') : null;
|
|
|
|
// Check if player is already linked to anyone in our mock/real DB logic
|
|
// Since the Player object doesn't expose 'discordId' publicly in types yet (it's hidden in DB),
|
|
// we use a heuristic or add it. For now, we assume if currentUser has NO link, they can claim.
|
|
const canClaim = !!currentUser && !currentUser.linkedPlayerUuid && !isOwner;
|
|
|
|
useEffect(() => {
|
|
if (!id) {
|
|
navigate('/players');
|
|
return;
|
|
}
|
|
|
|
// Load player data
|
|
const playerData = dbService.getPlayer(id);
|
|
if (playerData) {
|
|
setPlayer(playerData);
|
|
} else {
|
|
navigate('/players');
|
|
return;
|
|
}
|
|
|
|
console.log(playerData);
|
|
setLoading(false);
|
|
}, [id, navigate]);
|
|
|
|
useEffect(() => {
|
|
if (!player) return;
|
|
|
|
console.log(player);
|
|
|
|
// Load owned projects
|
|
const allProjects = dbService.getProjects();
|
|
const playerProjects = allProjects.filter(p => p.owner === player.username);
|
|
setOwnedProjects(playerProjects);
|
|
|
|
// Subscribe to auth to show/hide edit buttons
|
|
const unsub = authService.subscribe(u => setCurrentUser(u));
|
|
return unsub;
|
|
}, [player]);
|
|
|
|
if (loading || !player) {
|
|
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>
|
|
);
|
|
}
|
|
|
|
const handleSaveStory = (newStory: string) => {
|
|
// Update local DB
|
|
dbService.updatePlayer(player.uuid, { storyMarkdown: newStory });
|
|
// Update local state
|
|
setPlayer(prev => ({ ...prev, storyMarkdown: newStory }));
|
|
};
|
|
|
|
const handleSaveTags = async (tagsString: string) => {
|
|
const tags = tagsString.split(',').map(tag => tag.trim()).filter(tag => tag.length > 0);
|
|
// Update via API
|
|
const response = await fetch(`https://vollidioten.ceraticsoft.de/api/players/${player.uuid}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'include',
|
|
body: JSON.stringify({ tags })
|
|
});
|
|
|
|
if (response.ok) {
|
|
// Update local state
|
|
setPlayer(prev => ({ ...prev, tags }));
|
|
} else {
|
|
alert('Fehler beim Aktualisieren der Tags');
|
|
}
|
|
};
|
|
|
|
const handleSaveOrganization = async (orgId: string) => {
|
|
const response = await fetch(`https://vollidioten.ceraticsoft.de/api/players/${player.uuid}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'include',
|
|
body: JSON.stringify({ organizationId: orgId || null })
|
|
});
|
|
|
|
if (response.ok) {
|
|
// Update local state
|
|
setPlayer(prev => ({ ...prev, organizationId: orgId || undefined }));
|
|
} else {
|
|
alert('Fehler beim Aktualisieren der Zugehörigkeit');
|
|
}
|
|
};
|
|
|
|
const handleClaim = async () => {
|
|
if (confirm(`Möchtest du den Charakter "${player.username}" mit deinem Discord-Account verknüpfen?`)) {
|
|
const success = await dbService.linkPlayer(player.uuid);
|
|
if (success) {
|
|
// Force refresh auth to get updated linkedPlayerUuid
|
|
await authService.checkSession();
|
|
alert("Erfolgreich verknüpft! Du kannst nun Bearbeitungen vornehmen.");
|
|
} else {
|
|
alert("Fehler beim Verknüpfen. Bitte Backend Logs prüfen.");
|
|
}
|
|
}
|
|
};
|
|
|
|
// Simple markdown renderer replacement for demo purposes
|
|
const renderMarkdown = (text: string) => {
|
|
return text.split('\n').map((line, i) => {
|
|
if (line.startsWith('# ')) return <h1 key={i} className="text-2xl font-bold mt-6 mb-3 text-textMain border-b border-border pb-2">{line.replace('# ', '')}</h1>;
|
|
if (line.startsWith('### ')) return <h3 key={i} className="text-lg font-semibold mt-4 mb-2 text-textMain">{line.replace('### ', '')}</h3>;
|
|
if (line.startsWith('> ')) return <blockquote key={i} className="border-l-2 border-accentInfo pl-4 italic text-textMuted my-4 bg-surfaceHighlight/30 py-2 pr-2 rounded-r">{line.replace('> ', '')}</blockquote>;
|
|
if (line.startsWith('* ')) return <li key={i} className="ml-4 list-disc text-textMuted mb-1 marker:text-accentInfo">{line.replace('* ', '')}</li>;
|
|
return <p key={i} className="mb-2 text-textMuted leading-relaxed">{line}</p>;
|
|
});
|
|
};
|
|
|
|
console.log(player);
|
|
return (
|
|
<div className="max-w-8xl mx-auto animate-in slide-in-from-right-4 duration-300">
|
|
<div className="flex justify-between items-center mb-6">
|
|
<button onClick={() => navigate('/players')} className="flex items-center gap-2 text-sm text-textMuted hover:text-textMain transition-colors">
|
|
<span className="text-lg">←</span> Zurück zur Liste
|
|
</button>
|
|
{canClaim && (
|
|
<button
|
|
onClick={handleClaim}
|
|
className="text-xs bg-accentInfo hover:bg-accentInfo/80 text-white px-3 py-1.5 rounded flex items-center gap-2 transition-colors shadow-glow animate-pulse"
|
|
>
|
|
<Icons.Shield className="w-3 h-3" />
|
|
Charakter beanspruchen (Test)
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Header */}
|
|
<div className="bg-surface border border-border rounded-xl p-6 shadow-card mb-6 relative overflow-hidden">
|
|
{isOwner && (
|
|
<div className="absolute top-0 right-0 bg-accentInfo text-white text-xs font-bold px-3 py-1 rounded-bl-xl shadow-lg flex items-center gap-1 z-10">
|
|
<Icons.Edit className="w-3 h-3" /> Dein Profil
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex flex-col md:flex-row gap-6 items-start md:items-center">
|
|
<div className="w-20 h-20 rounded-lg flex items-center justify-center shadow-inner shrink-0 relative group">
|
|
<img src={"https://minotar.net/armor/bust/"+player.username+"/500.png"} className="rounded-lg shadow-lg"></img>
|
|
{isOwner && (
|
|
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center rounded-lg cursor-pointer">
|
|
<span className="text-[10px] text-white font-bold">Ändern</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex-1">
|
|
<div className="flex flex-wrap items-center gap-3 mb-1">
|
|
<h1 className="text-3xl font-bold text-textMain tracking-tight">{player.username}</h1>
|
|
{player.isOnline && (
|
|
<span className="flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-accentSuccess/10 border border-accentSuccess/20 text-accentSuccess text-xs font-medium">
|
|
<span className="w-1.5 h-1.5 rounded-full bg-accentSuccess animate-pulse"></span>
|
|
Online
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex flex-wrap gap-2 mb-4">
|
|
{(player.tags || []).map(tag => (
|
|
<span key={tag} className="text-xs px-2 py-1 bg-surfaceHighlight rounded text-textMuted border border-white/5 font-mono">
|
|
{tag}
|
|
</span>
|
|
))}
|
|
{isOwner && (
|
|
<button
|
|
onClick={() => setIsEditTagsOpen(true)}
|
|
className="text-xs px-2 py-1 border border-dashed border-textMuted/50 text-textMuted hover:text-white hover:border-white/50 rounded transition-colors"
|
|
>
|
|
+ Tag bearbeiten
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex gap-6 text-sm">
|
|
<div className="flex items-center gap-2 text-textMuted">
|
|
<Icons.Terminal className="w-4 h-4" />
|
|
<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`
|
|
}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 text-textMuted">
|
|
<Icons.Box className="w-4 h-4" />
|
|
<span className="font-mono text-textMain">
|
|
Lvl {player.minecraftStats?.char?.xpLevel || player.minecraftStats?.level || 1}
|
|
</span>
|
|
</div>
|
|
{player.minecraftStats && player.minecraftStats.char && player.minecraftStats.statistics && (
|
|
<>
|
|
<div className="flex items-center gap-2 text-textMuted">
|
|
<Icons.Shield className="w-4 h-4" />
|
|
<span className="font-mono text-textMain">
|
|
{player.minecraftStats.char.health}/{player.minecraftStats.char.maxHealth} HP
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 text-textMuted">
|
|
<Icons.Coins className="w-4 h-4" />
|
|
<span className="font-mono text-textMain">
|
|
{player.minecraftStats.char.foodLevel}/20 Food
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 text-textMuted">
|
|
<Icons.Hammer className="w-4 h-4" />
|
|
<span className="font-mono text-textMain">
|
|
{player.minecraftStats.statistics.general?.["minecraft:mob_kills"] || 0} Kills
|
|
</span>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<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="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>
|
|
<div className="flex items-center gap-3">
|
|
<div className={`w-10 h-10 rounded flex items-center justify-center text-lg font-bold border border-white/5 ${
|
|
playerOrg
|
|
? (playerOrg.type === 'City' ? 'bg-blue-500/10 text-blue-400' :
|
|
playerOrg.type === 'Guild' ? 'bg-amber-500/10 text-amber-400' :
|
|
'bg-purple-500/10 text-purple-400')
|
|
: 'bg-surfaceHighlight text-textMuted'
|
|
}`}>
|
|
{playerOrg ? playerOrg.name.charAt(0) : <Icons.Map className="w-5 h-5 opacity-50" />}
|
|
</div>
|
|
<div>
|
|
<div className="text-sm font-medium text-textMain">{player.minecraftStats?.role || 'Unbekannt'}</div>
|
|
<div className="text-xs text-textMuted">
|
|
{playerOrg ? (
|
|
<span className="group-hover:text-accentInfo transition-colors">{playerOrg.name}</span>
|
|
) : (
|
|
'Freiberufler / Keine Zugehörigkeit'
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right Col: Tabs */}
|
|
<div className="lg:col-span-2">
|
|
{/* Tab Navigation */}
|
|
<div className="bg-surface border border-border rounded-xl shadow-card mb-6">
|
|
<div className="flex border-b border-border">
|
|
<button
|
|
onClick={() => setActiveTab('story')}
|
|
className={`flex-1 px-4 py-3 text-sm font-medium transition-colors ${
|
|
activeTab === 'story'
|
|
? 'text-accentInfo border-b-2 border-accentInfo bg-accentInfo/5'
|
|
: 'text-textMuted hover:text-textMain hover:bg-surfaceHighlight/50'
|
|
}`}
|
|
>
|
|
<Icons.Scroll className="w-4 h-4 inline mr-2" />
|
|
Charakter-Journal
|
|
</button>
|
|
{player.minecraftStats && (
|
|
<button
|
|
onClick={() => setActiveTab('stats')}
|
|
className={`flex-1 px-4 py-3 text-sm font-medium transition-colors ${
|
|
activeTab === 'stats'
|
|
? 'text-accentInfo border-b-2 border-accentInfo bg-accentInfo/5'
|
|
: 'text-textMuted hover:text-textMain hover:bg-surfaceHighlight/50'
|
|
}`}
|
|
>
|
|
<Icons.Terminal className="w-4 h-4 inline mr-2" />
|
|
Minecraft Stats
|
|
</button>
|
|
)}
|
|
{ownedProjects.length > 0 && (
|
|
<button
|
|
onClick={() => setActiveTab('projects')}
|
|
className={`flex-1 px-4 py-3 text-sm font-medium transition-colors ${
|
|
activeTab === 'projects'
|
|
? 'text-accentInfo border-b-2 border-accentInfo bg-accentInfo/5'
|
|
: 'text-textMuted hover:text-textMain hover:bg-surfaceHighlight/50'
|
|
}`}
|
|
>
|
|
<Icons.ShoppingBag className="w-4 h-4 inline mr-2" />
|
|
Projekte ({ownedProjects.length})
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Tab Content */}
|
|
<div className="p-6">
|
|
{activeTab === 'story' && (
|
|
<div className="min-h-[400px]">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-xs text-textMuted font-mono">Markdown</span>
|
|
{isOwner && (
|
|
<button
|
|
onClick={() => setIsEditStoryOpen(true)}
|
|
className="flex items-center gap-1.5 text-xs bg-surfaceHighlight hover:bg-white/10 border border-white/10 px-2 py-1 rounded transition-colors text-white"
|
|
>
|
|
<Icons.Edit className="w-3 h-3" /> Bearbeiten
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="prose-custom text-sm">
|
|
{renderMarkdown(player.storyMarkdown || '')}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'stats' && player.minecraftStats && (
|
|
<div className="space-y-6">
|
|
{/* Last Sync */}
|
|
<div className="p-3 bg-surfaceHighlight/30 rounded-lg border border-border">
|
|
<div className="text-sm text-textMuted mb-1">Letzte Synchronisation</div>
|
|
<div className="text-sm font-mono text-textMain">
|
|
{new Date(player.minecraftStats.lastSync).toLocaleString('de-DE', {
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
second: '2-digit'
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Statistics */}
|
|
{player.minecraftStats.statistics && (
|
|
<div className="space-y-6">
|
|
{/* General Stats */}
|
|
{player.minecraftStats.statistics.general && Object.keys(player.minecraftStats.statistics.general).length > 0 && (
|
|
<div>
|
|
<h4 className="text-md font-semibold mb-3 text-textMain">Allgemein</h4>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
|
{Object.entries(player.minecraftStats.statistics.general)
|
|
.sort(([, a], [, b]) => (b as number) - (a as number))
|
|
.map(([key, value]) => (
|
|
<div key={key} className="flex justify-between items-center p-2 bg-surfaceHighlight/20 rounded border border-white/5">
|
|
<span className="text-sm text-textMuted font-mono">{key.replace('minecraft:', '')}</span>
|
|
<span className="text-sm font-bold text-textMain">{value.toLocaleString()}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Kills */}
|
|
{player.minecraftStats.statistics.kills && Object.keys(player.minecraftStats.statistics.kills).length > 0 && (
|
|
<div>
|
|
<h4 className="text-md font-semibold mb-3 text-textMain">Kills</h4>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
|
{Object.entries(player.minecraftStats.statistics.kills)
|
|
.sort(([, a], [, b]) => (b as number) - (a as number))
|
|
.map(([key, value]) => (
|
|
<div key={key} className="flex justify-between items-center p-2 bg-surfaceHighlight/20 rounded border border-white/5">
|
|
<span className="text-sm text-textMuted font-mono">{key.replace('minecraft:', '')}</span>
|
|
<span className="text-sm font-bold text-textMain">{value}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Killed By */}
|
|
{player.minecraftStats.statistics.killed_by && Object.keys(player.minecraftStats.statistics.killed_by).length > 0 && (
|
|
<div>
|
|
<h4 className="text-md font-semibold mb-3 text-textMain">Gestorben durch</h4>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
|
{Object.entries(player.minecraftStats.statistics.killed_by)
|
|
.sort(([, a], [, b]) => (b as number) - (a as number))
|
|
.map(([key, value]) => (
|
|
<div key={key} className="flex justify-between items-center p-2 bg-surfaceHighlight/20 rounded border border-white/5">
|
|
<span className="text-sm text-textMuted font-mono">{key.replace('minecraft:', '')}</span>
|
|
<span className="text-sm font-bold text-textMain">{value}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Advancements */}
|
|
{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>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'projects' && ownedProjects.length > 0 && (
|
|
<div>
|
|
<div className="grid grid-cols-1 gap-4">
|
|
{ownedProjects.map((project) => (
|
|
<div key={project.id} className="bg-surfaceHighlight/30 border border-border rounded-lg p-4 hover:border-accentInfo/50 transition-all">
|
|
<div className="flex justify-between items-start mb-2">
|
|
<h4 className="font-medium text-textMain">{project.title}</h4>
|
|
<span className={`text-xs px-2 py-1 rounded border ${
|
|
project.status === 'active' ? 'bg-green-500/10 text-green-400 border-green-500/20' :
|
|
project.status === 'recruiting' ? 'bg-blue-500/10 text-blue-400 border-blue-500/20' :
|
|
'bg-gray-500/10 text-gray-400 border-gray-500/20'
|
|
}`}>
|
|
{project.category}
|
|
</span>
|
|
</div>
|
|
<p className="text-sm text-textMuted mb-3 line-clamp-2">
|
|
{project.description.split(' ').length > 50
|
|
? project.description.split(' ').slice(0, 50).join(' ') + '...'
|
|
: project.description}
|
|
</p>
|
|
<div className="flex items-center justify-between text-xs text-textMuted">
|
|
<span>Gegründet: {project.foundedDate}</span>
|
|
<div className="flex items-center gap-2">
|
|
{project.shopCatalog && project.shopCatalog.length > 0 && (
|
|
<span className="flex items-center gap-1 text-accentInfo">
|
|
<Icons.ShoppingBag className="w-3 h-3" />
|
|
Shop ({project.shopCatalog.length})
|
|
</span>
|
|
)}
|
|
<span className="flex items-center gap-1">
|
|
<Icons.Users className="w-3 h-3" />
|
|
{project.employees.length + 1}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Modals */}
|
|
<EditModal
|
|
isOpen={isEditStoryOpen}
|
|
title="Journal bearbeiten"
|
|
initialValue={player.storyMarkdown}
|
|
multiline={true}
|
|
markdown={true}
|
|
onClose={() => setIsEditStoryOpen(false)}
|
|
onSave={handleSaveStory}
|
|
/>
|
|
|
|
<EditModal
|
|
isOpen={isEditTagsOpen}
|
|
title="Tags bearbeiten"
|
|
initialValue={player.tags.join(', ')}
|
|
multiline={false}
|
|
onClose={() => setIsEditTagsOpen(false)}
|
|
onSave={handleSaveTags}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default PlayerProfile;
|