feat: Charaktere-Modul mit Pathbuilder Import

Backend:
- Characters-Modul (CRUD, HP-Tracking, Conditions)
- Pathbuilder 2e JSON Import Service
- Claude API Integration für automatische Übersetzungen
- Translations-Modul mit Datenbank-Caching
- Prisma Schema erweitert (Character, Abilities, Skills, Feats, Items, Resources)

Frontend:
- Kampagnen-Detailseite mit Mitglieder- und Charakterverwaltung
- Charakter erstellen Modal
- Pathbuilder Import Modal (Datei-Upload + JSON-Paste)
- Logo-Integration (Dimension 47 + Zeasy)
- Cinzel Font für Branding

Weitere Änderungen:
- Auth 401 Redirect Fix für Login-Seite
- PROGRESS.md mit Projektfortschritt

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Alexander Zielonka
2026-01-18 20:36:44 +01:00
parent 090aae53d8
commit 94335ecd12
53 changed files with 4581 additions and 114 deletions

View File

@@ -2,7 +2,8 @@ import { useEffect } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { LoginPage, RegisterPage, useAuthStore } from '@/features/auth';
import { CampaignsPage } from '@/features/campaigns';
import { CampaignsPage, CampaignDetailPage } from '@/features/campaigns';
import { CharacterSheetPage } from '@/features/characters';
import { ProtectedRoute } from '@/shared/components/protected-route';
import { Layout } from '@/shared/components/layout';
@@ -43,7 +44,8 @@ function AppContent() {
<Route element={<ProtectedRoute />}>
<Route element={<Layout />}>
<Route path="/" element={<CampaignsPage />} />
<Route path="/campaigns/:id" element={<div>Campaign Detail (TODO)</div>} />
<Route path="/campaigns/:id" element={<CampaignDetailPage />} />
<Route path="/campaigns/:id/characters/:characterId" element={<CharacterSheetPage />} />
<Route path="/library" element={<div>Library (TODO)</div>} />
</Route>
</Route>

View File

@@ -2,6 +2,7 @@ import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { Button, Input, Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/shared/components/ui';
import { useAuthStore } from '../hooks/use-auth-store';
import LogoVertical from '../../../../Logos/Logo_Vertikal.svg';
export function LoginPage() {
const navigate = useNavigate();
@@ -30,26 +31,17 @@ export function LoginPage() {
};
return (
<div className="min-h-screen flex items-center justify-center px-4 py-12 bg-bg-primary">
<div className="min-h-screen flex flex-col items-center justify-center px-4 py-12 bg-bg-primary">
{/* Logo */}
<img
src={LogoVertical}
alt="Dimension 47"
className="h-48 mb-10"
/>
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 h-12 w-12 rounded-xl bg-primary-500/10 flex items-center justify-center">
<svg
className="h-6 w-6 text-primary-500"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.75 5.25a3 3 0 0 1 3 3m3 0a6 6 0 0 1-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1 1 21.75 8.25Z"
/>
</svg>
</div>
<CardTitle>Willkommen zur\u00fcck</CardTitle>
<CardTitle>Willkommen zurück</CardTitle>
<CardDescription>
Melde dich an, um fortzufahren
</CardDescription>

View File

@@ -2,6 +2,7 @@ import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { Button, Input, Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/shared/components/ui';
import { useAuthStore } from '../hooks/use-auth-store';
import LogoVertical from '../../../../Logos/Logo_Vertikal.svg';
export function RegisterPage() {
const navigate = useNavigate();
@@ -41,25 +42,16 @@ export function RegisterPage() {
};
return (
<div className="min-h-screen flex items-center justify-center px-4 py-12 bg-bg-primary">
<div className="min-h-screen flex flex-col items-center justify-center px-4 py-12 bg-bg-primary">
{/* Logo */}
<img
src={LogoVertical}
alt="Dimension 47"
className="h-48 mb-10"
/>
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 h-12 w-12 rounded-xl bg-primary-500/10 flex items-center justify-center">
<svg
className="h-6 w-6 text-primary-500"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M18 7.5v3m0 0v3m0-3h3m-3 0h-3m-2.25-4.125a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0ZM3 19.235v-.11a6.375 6.375 0 0 1 12.75 0v.109A12.318 12.318 0 0 1 9.374 21c-2.331 0-4.512-.645-6.374-1.766Z"
/>
</svg>
</div>
<CardTitle>Konto erstellen</CardTitle>
<CardDescription>
Registriere dich f\u00fcr Dimension47

View File

@@ -0,0 +1,111 @@
import { useState } from 'react';
import { X, Search, UserPlus } from 'lucide-react';
import { Button, Input, Spinner } from '@/shared/components/ui';
import { api } from '@/shared/lib/api';
import type { User } from '@/shared/types';
interface AddMemberModalProps {
campaignId: string;
onClose: () => void;
onMemberAdded: () => void;
}
export function AddMemberModal({ campaignId, onClose, onMemberAdded }: AddMemberModalProps) {
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<User[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [isAdding, setIsAdding] = useState<string | null>(null);
const handleSearch = async () => {
if (searchQuery.length < 2) return;
setIsSearching(true);
try {
const results = await api.searchUsers(searchQuery);
setSearchResults(results);
} catch (error) {
console.error('Failed to search users:', error);
} finally {
setIsSearching(false);
}
};
const handleAddMember = async (userId: string) => {
setIsAdding(userId);
try {
await api.addCampaignMember(campaignId, userId);
onMemberAdded();
onClose();
} catch (error) {
console.error('Failed to add member:', error);
setIsAdding(null);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
<div className="relative bg-bg-primary border border-border rounded-xl p-6 w-full max-w-md mx-4 shadow-xl">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold text-text-primary">Mitglied hinzufügen</h2>
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="h-5 w-5" />
</Button>
</div>
<div className="space-y-4">
<div className="flex gap-2">
<Input
placeholder="Benutzername oder E-Mail suchen..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
/>
<Button onClick={handleSearch} disabled={searchQuery.length < 2 || isSearching}>
{isSearching ? <Spinner size="sm" /> : <Search className="h-4 w-4" />}
</Button>
</div>
{searchResults.length > 0 && (
<div className="space-y-2 max-h-64 overflow-y-auto">
{searchResults.map((user) => (
<div
key={user.id}
className="flex items-center justify-between p-3 rounded-lg bg-bg-secondary"
>
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-full bg-primary-500/20 flex items-center justify-center">
<span className="text-primary-500 text-sm font-medium">
{user.username.charAt(0).toUpperCase()}
</span>
</div>
<div>
<p className="font-medium text-text-primary text-sm">{user.username}</p>
<p className="text-xs text-text-secondary">{user.email}</p>
</div>
</div>
<Button
size="sm"
onClick={() => handleAddMember(user.id)}
disabled={isAdding === user.id}
>
{isAdding === user.id ? (
<Spinner size="sm" />
) : (
<UserPlus className="h-4 w-4" />
)}
</Button>
</div>
))}
</div>
)}
{searchQuery.length >= 2 && searchResults.length === 0 && !isSearching && (
<p className="text-center text-text-secondary py-4">
Keine Benutzer gefunden
</p>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,337 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
ArrowLeft,
Settings,
Users,
Swords,
UserPlus,
Trash2,
Crown,
Shield,
Heart,
FileJson
} from 'lucide-react';
import {
Button,
Card,
CardHeader,
CardTitle,
CardContent,
Spinner
} from '@/shared/components/ui';
import { api } from '@/shared/lib/api';
import { useAuthStore } from '@/features/auth';
import type { Campaign, CharacterSummary } from '@/shared/types';
import { AddMemberModal } from './add-member-modal';
import { EditCampaignModal } from './edit-campaign-modal';
import { CreateCharacterModal, ImportCharacterModal } from '@/features/characters';
export function CampaignDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { user } = useAuthStore();
const [campaign, setCampaign] = useState<Campaign | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [showAddMember, setShowAddMember] = useState(false);
const [showEditCampaign, setShowEditCampaign] = useState(false);
const [showCreateCharacter, setShowCreateCharacter] = useState(false);
const [showImportCharacter, setShowImportCharacter] = useState(false);
const isGM = campaign?.gmId === user?.id;
const isAdmin = user?.role === 'ADMIN';
const canManage = isGM || isAdmin;
const fetchCampaign = async () => {
if (!id) return;
try {
const data = await api.getCampaign(id);
setCampaign(data);
} catch (error) {
console.error('Failed to fetch campaign:', error);
navigate('/');
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchCampaign();
}, [id]);
const handleRemoveMember = async (userId: string) => {
if (!id || !confirm('Mitglied wirklich entfernen?')) return;
try {
await api.removeCampaignMember(id, userId);
fetchCampaign();
} catch (error) {
console.error('Failed to remove member:', error);
}
};
const handleDeleteCampaign = async () => {
if (!id || !confirm('Kampagne wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.')) return;
try {
await api.deleteCampaign(id);
navigate('/');
} catch (error) {
console.error('Failed to delete campaign:', error);
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<Spinner size="lg" />
</div>
);
}
if (!campaign) {
return (
<div className="text-center py-12">
<p className="text-text-secondary">Kampagne nicht gefunden</p>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-4">
<Button variant="ghost" size="icon" onClick={() => navigate('/')}>
<ArrowLeft className="h-5 w-5" />
</Button>
<div>
<h1 className="text-2xl font-bold text-text-primary">{campaign.name}</h1>
{campaign.description && (
<p className="text-text-secondary mt-1">{campaign.description}</p>
)}
<div className="flex items-center gap-2 mt-2 text-sm text-text-secondary">
<Crown className="h-4 w-4 text-primary-500" />
<span>GM: {campaign.gm.username}</span>
</div>
</div>
</div>
{canManage && (
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setShowEditCampaign(true)}>
<Settings className="h-4 w-4" />
Bearbeiten
</Button>
<Button variant="destructive" size="sm" onClick={handleDeleteCampaign}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
</div>
{/* Campaign Image */}
{campaign.imageUrl && (
<div className="h-48 rounded-xl overflow-hidden bg-bg-tertiary">
<img
src={campaign.imageUrl}
alt={campaign.name}
className="w-full h-full object-cover"
/>
</div>
)}
<div className="grid gap-6 lg:grid-cols-2">
{/* Members Section */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5" />
Mitglieder ({campaign.members.length})
</CardTitle>
{canManage && (
<Button size="sm" onClick={() => setShowAddMember(true)}>
<UserPlus className="h-4 w-4" />
Hinzufügen
</Button>
)}
</CardHeader>
<CardContent>
<div className="space-y-3">
{campaign.members.map((member) => (
<div
key={member.userId}
className="flex items-center justify-between p-3 rounded-lg bg-bg-secondary"
>
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-full bg-primary-500/20 flex items-center justify-center">
{member.user.avatarUrl ? (
<img
src={member.user.avatarUrl}
alt={member.user.username}
className="h-10 w-10 rounded-full object-cover"
/>
) : (
<span className="text-primary-500 font-medium">
{member.user.username.charAt(0).toUpperCase()}
</span>
)}
</div>
<div>
<p className="font-medium text-text-primary flex items-center gap-2">
{member.user.username}
{member.userId === campaign.gmId && (
<Crown className="h-4 w-4 text-primary-500" />
)}
</p>
<p className="text-sm text-text-secondary">{member.user.email}</p>
</div>
</div>
{canManage && member.userId !== campaign.gmId && (
<Button
variant="ghost"
size="icon"
onClick={() => handleRemoveMember(member.userId)}
>
<Trash2 className="h-4 w-4 text-text-secondary hover:text-red-500" />
</Button>
)}
</div>
))}
</div>
</CardContent>
</Card>
{/* Characters Section */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Swords className="h-5 w-5" />
Charaktere ({campaign.characters?.length || 0})
</CardTitle>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={() => setShowImportCharacter(true)}>
<FileJson className="h-4 w-4" />
Import
</Button>
<Button size="sm" onClick={() => setShowCreateCharacter(true)}>
<UserPlus className="h-4 w-4" />
Neuer Charakter
</Button>
</div>
</CardHeader>
<CardContent>
{!campaign.characters?.length ? (
<div className="text-center py-8">
<Swords className="h-8 w-8 text-text-secondary mx-auto mb-2" />
<p className="text-text-secondary">Noch keine Charaktere</p>
</div>
) : (
<div className="space-y-3">
{campaign.characters.map((character: CharacterSummary) => (
<div
key={character.id}
className="flex items-center justify-between p-3 rounded-lg bg-bg-secondary hover:bg-bg-tertiary cursor-pointer transition-colors"
onClick={() => navigate(`/campaigns/${id}/characters/${character.id}`)}
>
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-full bg-primary-500/20 flex items-center justify-center">
{character.avatarUrl ? (
<img
src={character.avatarUrl}
alt={character.name}
className="h-10 w-10 rounded-full object-cover"
/>
) : (
<Shield className="h-5 w-5 text-primary-500" />
)}
</div>
<div>
<p className="font-medium text-text-primary">{character.name}</p>
<p className="text-sm text-text-secondary">
Level {character.level} {character.type}
{character.owner && `${character.owner.username}`}
</p>
</div>
</div>
<div className="flex items-center gap-2 text-sm">
<Heart className="h-4 w-4 text-red-500" />
<span className={character.hpCurrent < character.hpMax / 2 ? 'text-red-500' : 'text-text-secondary'}>
{character.hpCurrent}/{character.hpMax}
</span>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
{/* Quick Actions */}
<div className="grid gap-4 sm:grid-cols-3">
<Card className="p-4 hover:border-border-hover cursor-pointer transition-colors" onClick={() => navigate(`/campaigns/${id}/battle`)}>
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-lg bg-red-500/20 flex items-center justify-center">
<Swords className="h-5 w-5 text-red-500" />
</div>
<div>
<p className="font-medium text-text-primary">Kampfbildschirm</p>
<p className="text-sm text-text-secondary">Kämpfe verwalten</p>
</div>
</div>
</Card>
<Card className="p-4 hover:border-border-hover cursor-pointer transition-colors opacity-50">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-lg bg-blue-500/20 flex items-center justify-center">
<Users className="h-5 w-5 text-blue-500" />
</div>
<div>
<p className="font-medium text-text-primary">Dokumente</p>
<p className="text-sm text-text-secondary">Bald verfügbar</p>
</div>
</div>
</Card>
<Card className="p-4 hover:border-border-hover cursor-pointer transition-colors opacity-50">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-lg bg-green-500/20 flex items-center justify-center">
<Users className="h-5 w-5 text-green-500" />
</div>
<div>
<p className="font-medium text-text-primary">Notizen</p>
<p className="text-sm text-text-secondary">Bald verfügbar</p>
</div>
</div>
</Card>
</div>
{/* Modals */}
{showAddMember && (
<AddMemberModal
campaignId={campaign.id}
onClose={() => setShowAddMember(false)}
onMemberAdded={fetchCampaign}
/>
)}
{showEditCampaign && (
<EditCampaignModal
campaign={campaign}
onClose={() => setShowEditCampaign(false)}
onUpdated={fetchCampaign}
/>
)}
{showCreateCharacter && (
<CreateCharacterModal
campaignId={campaign.id}
onClose={() => setShowCreateCharacter(false)}
onCreated={fetchCampaign}
/>
)}
{showImportCharacter && (
<ImportCharacterModal
campaignId={campaign.id}
onClose={() => setShowImportCharacter(false)}
onImported={fetchCampaign}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,109 @@
import { useState } from 'react';
import { X } from 'lucide-react';
import { Button, Input, Spinner } from '@/shared/components/ui';
import { api } from '@/shared/lib/api';
import type { Campaign } from '@/shared/types';
interface EditCampaignModalProps {
campaign: Campaign;
onClose: () => void;
onUpdated: () => void;
}
export function EditCampaignModal({ campaign, onClose, onUpdated }: EditCampaignModalProps) {
const [name, setName] = useState(campaign.name);
const [description, setDescription] = useState(campaign.description || '');
const [imageUrl, setImageUrl] = useState(campaign.imageUrl || '');
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) {
setError('Name ist erforderlich');
return;
}
setIsSubmitting(true);
setError('');
try {
await api.updateCampaign(campaign.id, {
name: name.trim(),
description: description.trim() || undefined,
imageUrl: imageUrl.trim() || undefined,
});
onUpdated();
onClose();
} catch (err) {
setError('Fehler beim Aktualisieren der Kampagne');
console.error('Failed to update campaign:', err);
} finally {
setIsSubmitting(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
<div className="relative bg-bg-primary border border-border rounded-xl p-6 w-full max-w-md mx-4 shadow-xl">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold text-text-primary">Kampagne bearbeiten</h2>
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="h-5 w-5" />
</Button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1.5">
Name *
</label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Name der Kampagne"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1.5">
Beschreibung
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Optionale Beschreibung"
rows={3}
className="w-full px-3 py-2 rounded-lg bg-bg-secondary border border-border text-text-primary placeholder:text-text-secondary focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent resize-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1.5">
Bild-URL
</label>
<Input
value={imageUrl}
onChange={(e) => setImageUrl(e.target.value)}
placeholder="https://example.com/image.jpg"
/>
</div>
{error && (
<p className="text-sm text-red-500">{error}</p>
)}
<div className="flex justify-end gap-3 pt-2">
<Button type="button" variant="outline" onClick={onClose}>
Abbrechen
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? <Spinner size="sm" /> : 'Speichern'}
</Button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -1,2 +1,5 @@
export { CampaignsPage } from './components/campaigns-page';
export { CampaignDetailPage } from './components/campaign-detail-page';
export { CreateCampaignModal } from './components/create-campaign-modal';
export { AddMemberModal } from './components/add-member-modal';
export { EditCampaignModal } from './components/edit-campaign-modal';

View File

@@ -0,0 +1,718 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
ArrowLeft,
Heart,
Shield,
Zap,
Swords,
BookOpen,
Package,
AlertCircle,
Edit2,
Trash2,
Plus,
Minus,
Sparkles,
FlaskConical,
User,
Star,
} from 'lucide-react';
import {
Button,
Card,
CardHeader,
CardTitle,
CardContent,
Spinner,
Input,
} from '@/shared/components/ui';
import { api } from '@/shared/lib/api';
import { useAuthStore } from '@/features/auth';
import type { Character } from '@/shared/types';
type TabType = 'status' | 'inventory' | 'feats' | 'spells' | 'alchemy' | 'actions';
const TABS: { id: TabType; label: string; icon: React.ReactNode }[] = [
{ id: 'status', label: 'Status', icon: <User className="h-4 w-4" /> },
{ id: 'inventory', label: 'Inventar', icon: <Package className="h-4 w-4" /> },
{ id: 'feats', label: 'Talente', icon: <Star className="h-4 w-4" /> },
{ id: 'spells', label: 'Zauber', icon: <Sparkles className="h-4 w-4" /> },
{ id: 'alchemy', label: 'Alchemie', icon: <FlaskConical className="h-4 w-4" /> },
{ id: 'actions', label: 'Aktionen', icon: <Swords className="h-4 w-4" /> },
];
const ABILITY_NAMES: Record<string, string> = {
STR: 'Stärke',
DEX: 'Geschicklichkeit',
CON: 'Konstitution',
INT: 'Intelligenz',
WIS: 'Weisheit',
CHA: 'Charisma',
};
const PROFICIENCY_NAMES: Record<string, string> = {
UNTRAINED: 'Ungeübt',
TRAINED: 'Geübt',
EXPERT: 'Experte',
MASTER: 'Meister',
LEGENDARY: 'Legendär',
};
const PROFICIENCY_COLORS: Record<string, string> = {
UNTRAINED: 'text-text-secondary',
TRAINED: 'text-blue-500',
EXPERT: 'text-purple-500',
MASTER: 'text-orange-500',
LEGENDARY: 'text-red-500',
};
export function CharacterSheetPage() {
const { id: campaignId, characterId } = useParams<{ id: string; characterId: string }>();
const navigate = useNavigate();
const { user } = useAuthStore();
const [character, setCharacter] = useState<Character | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [activeTab, setActiveTab] = useState<TabType>('status');
const [hpEdit, setHpEdit] = useState<number | null>(null);
const isOwner = character?.ownerId === user?.id;
const fetchCharacter = async () => {
if (!campaignId || !characterId) return;
try {
const data = await api.getCharacter(campaignId, characterId);
setCharacter(data);
} catch (error) {
console.error('Failed to fetch character:', error);
navigate(`/campaigns/${campaignId}`);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchCharacter();
}, [campaignId, characterId]);
const handleHpChange = async (delta: number) => {
if (!character || !campaignId) return;
const newHp = Math.max(0, Math.min(character.hpMax, character.hpCurrent + delta));
try {
await api.updateCharacterHp(campaignId, character.id, newHp);
setCharacter({ ...character, hpCurrent: newHp });
} catch (error) {
console.error('Failed to update HP:', error);
}
};
const handleHpSet = async () => {
if (hpEdit === null || !character || !campaignId) return;
const newHp = Math.max(0, Math.min(character.hpMax, hpEdit));
try {
await api.updateCharacterHp(campaignId, character.id, newHp);
setCharacter({ ...character, hpCurrent: newHp });
setHpEdit(null);
} catch (error) {
console.error('Failed to update HP:', error);
}
};
const handleDelete = async () => {
if (!character || !campaignId || !confirm('Charakter wirklich löschen?')) return;
try {
await api.deleteCharacter(campaignId, character.id);
navigate(`/campaigns/${campaignId}`);
} catch (error) {
console.error('Failed to delete character:', error);
}
};
const handleRemoveCondition = async (conditionId: string) => {
if (!character || !campaignId) return;
try {
await api.removeCharacterCondition(campaignId, character.id, conditionId);
setCharacter({
...character,
conditions: character.conditions.filter((c) => c.id !== conditionId),
});
} catch (error) {
console.error('Failed to remove condition:', error);
}
};
const getAbilityModifier = (score: number) => {
const mod = Math.floor((score - 10) / 2);
return mod >= 0 ? `+${mod}` : `${mod}`;
};
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<Spinner size="lg" />
</div>
);
}
if (!character) {
return (
<div className="text-center py-12">
<p className="text-text-secondary">Charakter nicht gefunden</p>
</div>
);
}
const hpPercentage = (character.hpCurrent / character.hpMax) * 100;
// Tab Content Renderers
const renderStatusTab = () => (
<div className="space-y-6">
{/* HP Bar */}
<Card>
<CardContent className="py-4">
<div className="flex items-center gap-4">
<Heart className="h-6 w-6 text-red-500" />
<div className="flex-1">
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium text-text-primary">Trefferpunkte</span>
<div className="flex items-center gap-2">
{hpEdit !== null ? (
<>
<Input
type="number"
value={hpEdit}
onChange={(e) => setHpEdit(parseInt(e.target.value) || 0)}
className="w-20 h-8 text-center"
onKeyDown={(e) => e.key === 'Enter' && handleHpSet()}
/>
<Button size="sm" onClick={handleHpSet}>OK</Button>
<Button size="sm" variant="ghost" onClick={() => setHpEdit(null)}>X</Button>
</>
) : (
<>
<Button size="icon" variant="ghost" onClick={() => handleHpChange(-1)}>
<Minus className="h-4 w-4" />
</Button>
<span
className={`font-bold cursor-pointer ${hpPercentage < 25 ? 'text-red-500' : hpPercentage < 50 ? 'text-yellow-500' : 'text-text-primary'}`}
onClick={() => setHpEdit(character.hpCurrent)}
>
{character.hpCurrent} / {character.hpMax}
</span>
<Button size="icon" variant="ghost" onClick={() => handleHpChange(1)}>
<Plus className="h-4 w-4" />
</Button>
</>
)}
{character.hpTemp > 0 && (
<span className="text-sm text-blue-500">(+{character.hpTemp} temp)</span>
)}
</div>
</div>
<div className="h-3 bg-bg-tertiary rounded-full overflow-hidden">
<div
className={`h-full transition-all ${hpPercentage < 25 ? 'bg-red-500' : hpPercentage < 50 ? 'bg-yellow-500' : 'bg-green-500'}`}
style={{ width: `${hpPercentage}%` }}
/>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Abilities */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Zap className="h-5 w-5" />
Attribute
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 sm:grid-cols-6 gap-3">
{character.abilities.map((ability) => (
<div
key={ability.ability}
className="text-center p-3 rounded-lg bg-bg-secondary"
>
<p className="text-xs text-text-secondary mb-1">
{ABILITY_NAMES[ability.ability]}
</p>
<p className="text-2xl font-bold text-text-primary">
{getAbilityModifier(ability.score)}
</p>
<p className="text-xs text-text-secondary">{ability.score}</p>
</div>
))}
</div>
</CardContent>
</Card>
{/* Conditions */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2">
<AlertCircle className="h-5 w-5" />
Zustände ({character.conditions.length})
</CardTitle>
<Button size="sm" variant="outline">
<Plus className="h-4 w-4" />
</Button>
</CardHeader>
<CardContent>
{character.conditions.length === 0 ? (
<p className="text-center text-text-secondary py-4">Keine aktiven Zustände</p>
) : (
<div className="flex flex-wrap gap-2">
{character.conditions.map((condition) => (
<div
key={condition.id}
className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-red-500/20 text-red-400"
>
<span className="text-sm font-medium">
{condition.nameGerman || condition.name}
{condition.value && ` ${condition.value}`}
</span>
<button
onClick={() => handleRemoveCondition(condition.id)}
className="hover:text-red-300"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Skills */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BookOpen className="h-5 w-5" />
Fertigkeiten ({character.skills.length})
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-1 sm:grid-cols-2">
{character.skills.map((skill) => (
<div
key={skill.skillName}
className="flex items-center justify-between py-1.5 px-2 rounded hover:bg-bg-secondary"
>
<span className="text-text-primary">{skill.skillName}</span>
<span className={`text-sm font-medium ${PROFICIENCY_COLORS[skill.proficiency]}`}>
{PROFICIENCY_NAMES[skill.proficiency]}
</span>
</div>
))}
</div>
</CardContent>
</Card>
</div>
);
const renderInventoryTab = () => (
<div className="space-y-6">
{/* Equipped Items */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Shield className="h-5 w-5" />
Ausrüstung
</CardTitle>
<Button size="sm" variant="outline">
<Plus className="h-4 w-4" />
Hinzufügen
</Button>
</CardHeader>
<CardContent>
{character.items.filter(i => i.equipped).length === 0 ? (
<p className="text-center text-text-secondary py-4">Keine ausgerüsteten Gegenstände</p>
) : (
<div className="grid gap-2 sm:grid-cols-2">
{character.items.filter(i => i.equipped).map((item) => (
<div
key={item.id}
className="p-3 rounded-lg bg-bg-secondary border border-primary-500/50"
>
<div className="flex items-center justify-between">
<span className="font-medium text-text-primary">
{item.nameGerman || item.name}
</span>
<span className="text-xs bg-primary-500/20 text-primary-500 px-2 py-0.5 rounded">
Ausgerüstet
</span>
</div>
{item.notes && (
<p className="text-xs text-text-secondary mt-1">{item.notes}</p>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* All Items */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Package className="h-5 w-5" />
Inventar ({character.items.length})
</CardTitle>
</CardHeader>
<CardContent>
{character.items.length === 0 ? (
<p className="text-center text-text-secondary py-4">Keine Gegenstände</p>
) : (
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{character.items.filter(i => !i.equipped).map((item) => (
<div
key={item.id}
className="p-3 rounded-lg bg-bg-secondary"
>
<div className="flex items-center justify-between">
<span className="font-medium text-text-primary">
{item.nameGerman || item.name}
{item.quantity > 1 && ` (×${item.quantity})`}
</span>
</div>
{item.notes && (
<p className="text-xs text-text-secondary mt-1">{item.notes}</p>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
const renderFeatsTab = () => (
<div className="space-y-6">
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Star className="h-5 w-5" />
Talente ({character.feats.length})
</CardTitle>
<Button size="sm" variant="outline">
<Plus className="h-4 w-4" />
Hinzufügen
</Button>
</CardHeader>
<CardContent>
{character.feats.length === 0 ? (
<p className="text-center text-text-secondary py-4">Keine Talente</p>
) : (
<div className="space-y-2">
{character.feats.map((feat) => (
<div
key={feat.id}
className="p-3 rounded-lg bg-bg-secondary hover:bg-bg-tertiary transition-colors cursor-pointer"
>
<div className="flex items-center justify-between">
<div>
<span className="font-medium text-text-primary">
{feat.nameGerman || feat.name}
</span>
<span className="ml-2 text-xs text-text-secondary">
Level {feat.level}
</span>
</div>
<span className="text-xs px-2 py-0.5 rounded bg-bg-tertiary text-text-secondary">
{feat.source}
</span>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
const renderSpellsTab = () => (
<div className="space-y-6">
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5" />
Zauber ({character.spells.length})
</CardTitle>
<Button size="sm" variant="outline">
<Plus className="h-4 w-4" />
Hinzufügen
</Button>
</CardHeader>
<CardContent>
{character.spells.length === 0 ? (
<p className="text-center text-text-secondary py-4">Keine Zauber</p>
) : (
<div className="space-y-4">
{/* Group by spell level */}
{[...new Set(character.spells.map(s => s.spellLevel))].sort().map(level => (
<div key={level}>
<h4 className="text-sm font-medium text-text-secondary mb-2">
{level === 0 ? 'Zaubertricks' : `Grad ${level}`}
</h4>
<div className="space-y-1">
{character.spells.filter(s => s.spellLevel === level).map((spell) => (
<div
key={spell.id}
className="flex items-center justify-between p-2 rounded-lg bg-bg-secondary hover:bg-bg-tertiary"
>
<div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${spell.prepared ? 'bg-green-500' : 'bg-text-secondary'}`} />
<span className="text-text-primary">
{spell.nameGerman || spell.name}
</span>
</div>
<span className="text-xs text-text-secondary">
{spell.tradition}
</span>
</div>
))}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
const renderAlchemyTab = () => (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FlaskConical className="h-5 w-5" />
Alchemie-Ressourcen
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{character.resources.filter(r => r.name.toLowerCase().includes('vial') || r.name.toLowerCase().includes('alchemy')).length === 0 ? (
<p className="text-center text-text-secondary py-4">
Keine Alchemie-Ressourcen verfügbar
</p>
) : (
character.resources.filter(r => r.name.toLowerCase().includes('vial') || r.name.toLowerCase().includes('alchemy')).map((resource) => (
<div key={resource.id} className="flex items-center justify-between p-3 rounded-lg bg-bg-secondary">
<span className="font-medium text-text-primary">{resource.name}</span>
<div className="flex items-center gap-2">
<Button size="icon" variant="ghost" className="h-8 w-8">
<Minus className="h-4 w-4" />
</Button>
<span className="font-bold text-text-primary min-w-[60px] text-center">
{resource.current} / {resource.max}
</span>
<Button size="icon" variant="ghost" className="h-8 w-8">
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
))
)}
</div>
</CardContent>
</Card>
{/* Alchemical Items from Inventory */}
<Card>
<CardHeader>
<CardTitle>Alchemistische Gegenstände</CardTitle>
</CardHeader>
<CardContent>
{character.items.filter(i => i.name.toLowerCase().includes('bomb') || i.name.toLowerCase().includes('elixir') || i.name.toLowerCase().includes('mutagen')).length === 0 ? (
<p className="text-center text-text-secondary py-4">
Keine alchemistischen Gegenstände
</p>
) : (
<div className="grid gap-2 sm:grid-cols-2">
{character.items.filter(i => i.name.toLowerCase().includes('bomb') || i.name.toLowerCase().includes('elixir') || i.name.toLowerCase().includes('mutagen')).map((item) => (
<div key={item.id} className="p-3 rounded-lg bg-bg-secondary">
<span className="font-medium text-text-primary">
{item.nameGerman || item.name}
{item.quantity > 1 && ` (×${item.quantity})`}
</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
const renderActionsTab = () => {
const basicActions = [
{ name: 'Angriff', actions: 1, type: 'Aktion' },
{ name: 'Schritt', actions: 1, type: 'Aktion' },
{ name: 'Bewegung', actions: 1, type: 'Aktion' },
{ name: 'Interagieren', actions: 1, type: 'Aktion' },
{ name: 'Schild heben', actions: 1, type: 'Aktion' },
{ name: 'Aufstehen', actions: 1, type: 'Aktion' },
{ name: 'Springen', actions: 1, type: 'Aktion' },
{ name: 'Fallenlassen', actions: 0, type: 'Freie Aktion' },
{ name: 'Verzögern', actions: 0, type: 'Freie Aktion' },
{ name: 'Gelegenheitsangriff', actions: -1, type: 'Reaktion' },
];
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Swords className="h-5 w-5" />
Grundaktionen
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-2 sm:grid-cols-2">
{basicActions.map((action) => (
<div
key={action.name}
className="flex items-center justify-between p-3 rounded-lg bg-bg-secondary hover:bg-bg-tertiary cursor-pointer"
>
<span className="font-medium text-text-primary">{action.name}</span>
<div className="flex items-center gap-2">
{action.actions === -1 ? (
<span className="text-xs px-2 py-0.5 rounded bg-yellow-500/20 text-yellow-500">
Reaktion
</span>
) : action.actions === 0 ? (
<span className="text-xs px-2 py-0.5 rounded bg-green-500/20 text-green-500">
Frei
</span>
) : (
<div className="flex gap-0.5">
{[...Array(action.actions)].map((_, i) => (
<div key={i} className="w-4 h-4 rounded-full bg-primary-500" />
))}
</div>
)}
</div>
</div>
))}
</div>
</CardContent>
</Card>
{/* Character-specific actions from feats */}
{character.feats.filter(f => f.source === 'CLASS').length > 0 && (
<Card>
<CardHeader>
<CardTitle>Klassenaktionen</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{character.feats.filter(f => f.source === 'CLASS').map((feat) => (
<div
key={feat.id}
className="p-3 rounded-lg bg-bg-secondary hover:bg-bg-tertiary cursor-pointer"
>
<span className="font-medium text-text-primary">
{feat.nameGerman || feat.name}
</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
);
};
const renderTabContent = () => {
switch (activeTab) {
case 'status':
return renderStatusTab();
case 'inventory':
return renderInventoryTab();
case 'feats':
return renderFeatsTab();
case 'spells':
return renderSpellsTab();
case 'alchemy':
return renderAlchemyTab();
case 'actions':
return renderActionsTab();
default:
return null;
}
};
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-4">
<Button variant="ghost" size="icon" onClick={() => navigate(`/campaigns/${campaignId}`)}>
<ArrowLeft className="h-5 w-5" />
</Button>
<div className="flex items-center gap-4">
<div className="h-14 w-14 rounded-full bg-primary-500/20 flex items-center justify-center">
{character.avatarUrl ? (
<img
src={character.avatarUrl}
alt={character.name}
className="h-14 w-14 rounded-full object-cover"
/>
) : (
<Shield className="h-7 w-7 text-primary-500" />
)}
</div>
<div>
<h1 className="text-xl font-bold text-text-primary">{character.name}</h1>
<p className="text-sm text-text-secondary">
Level {character.level} {character.type}
</p>
</div>
</div>
</div>
{isOwner && (
<div className="flex items-center gap-2">
<Button variant="outline" size="sm">
<Edit2 className="h-4 w-4" />
</Button>
<Button variant="destructive" size="sm" onClick={handleDelete}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
</div>
{/* Tab Navigation */}
<div className="flex gap-1 overflow-x-auto pb-2 -mx-4 px-4 sm:mx-0 sm:px-0">
{TABS.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium whitespace-nowrap transition-colors ${
activeTab === tab.id
? 'bg-primary-500 text-white'
: 'bg-bg-secondary text-text-secondary hover:bg-bg-tertiary hover:text-text-primary'
}`}
>
{tab.icon}
{tab.label}
</button>
))}
</div>
{/* Tab Content */}
<div className="min-h-[400px]">
{renderTabContent()}
</div>
</div>
);
}

View File

@@ -0,0 +1,137 @@
import { useState } from 'react';
import { X } from 'lucide-react';
import { Button, Input, Spinner } from '@/shared/components/ui';
import { api } from '@/shared/lib/api';
interface CreateCharacterModalProps {
campaignId: string;
onClose: () => void;
onCreated: () => void;
}
export function CreateCharacterModal({ campaignId, onClose, onCreated }: CreateCharacterModalProps) {
const [name, setName] = useState('');
const [type, setType] = useState<'PC' | 'NPC'>('PC');
const [level, setLevel] = useState(1);
const [hpMax, setHpMax] = useState(20);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) {
setError('Name ist erforderlich');
return;
}
setIsSubmitting(true);
setError('');
try {
await api.createCharacter(campaignId, {
name: name.trim(),
type,
level,
hpCurrent: hpMax,
hpMax,
});
onCreated();
onClose();
} catch (err) {
setError('Fehler beim Erstellen des Charakters');
console.error('Failed to create character:', err);
} finally {
setIsSubmitting(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
<div className="relative bg-bg-primary border border-border rounded-xl p-6 w-full max-w-md mx-4 shadow-xl">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold text-text-primary">Neuer Charakter</h2>
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="h-5 w-5" />
</Button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1.5">
Name *
</label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Name des Charakters"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1.5">
Typ
</label>
<div className="flex gap-2">
<Button
type="button"
variant={type === 'PC' ? 'default' : 'outline'}
onClick={() => setType('PC')}
className="flex-1"
>
Spielercharakter (PC)
</Button>
<Button
type="button"
variant={type === 'NPC' ? 'default' : 'outline'}
onClick={() => setType('NPC')}
className="flex-1"
>
NPC
</Button>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1.5">
Level
</label>
<Input
type="number"
min={1}
max={20}
value={level}
onChange={(e) => setLevel(parseInt(e.target.value) || 1)}
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1.5">
Max HP
</label>
<Input
type="number"
min={1}
value={hpMax}
onChange={(e) => setHpMax(parseInt(e.target.value) || 1)}
/>
</div>
</div>
{error && (
<p className="text-sm text-red-500">{error}</p>
)}
<div className="flex justify-end gap-3 pt-2">
<Button type="button" variant="outline" onClick={onClose}>
Abbrechen
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? <Spinner size="sm" /> : 'Erstellen'}
</Button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,245 @@
import { useState, useRef } from 'react';
import { X, Upload, FileJson, AlertCircle, CheckCircle } from 'lucide-react';
import { Button, Spinner } from '@/shared/components/ui';
import { api } from '@/shared/lib/api';
interface ImportCharacterModalProps {
campaignId: string;
onClose: () => void;
onImported: () => void;
}
interface PathbuilderJson {
success: boolean;
build: {
name: string;
class: string;
level: number;
ancestry: string;
heritage: string;
background: string;
};
}
export function ImportCharacterModal({ campaignId, onClose, onImported }: ImportCharacterModalProps) {
const [jsonText, setJsonText] = useState('');
const [parsedData, setParsedData] = useState<PathbuilderJson | null>(null);
const [error, setError] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [importSuccess, setImportSuccess] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const validateAndParseJson = (text: string) => {
try {
const data = JSON.parse(text);
// Validate Pathbuilder structure
if (!data.build) {
throw new Error('Kein "build" Objekt gefunden. Ist das eine Pathbuilder Export-Datei?');
}
if (!data.build.name || !data.build.class || !data.build.level) {
throw new Error('Fehlende Pflichtfelder (name, class, level)');
}
setParsedData(data);
setError('');
return data;
} catch (err) {
if (err instanceof SyntaxError) {
setError('Ungültiges JSON Format');
} else if (err instanceof Error) {
setError(err.message);
}
setParsedData(null);
return null;
}
};
const handleTextChange = (text: string) => {
setJsonText(text);
if (text.trim()) {
validateAndParseJson(text);
} else {
setParsedData(null);
setError('');
}
};
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > 1024 * 1024) {
setError('Datei ist zu groß (max. 1MB)');
return;
}
const reader = new FileReader();
reader.onload = (event) => {
const text = event.target?.result as string;
setJsonText(text);
validateAndParseJson(text);
};
reader.onerror = () => {
setError('Fehler beim Lesen der Datei');
};
reader.readAsText(file);
};
const handleImport = async () => {
if (!parsedData) return;
setIsSubmitting(true);
setError('');
try {
await api.importCharacterFromPathbuilder(campaignId, parsedData);
setImportSuccess(true);
setTimeout(() => {
onImported();
onClose();
}, 1500);
} catch (err) {
console.error('Import failed:', err);
setError('Import fehlgeschlagen. Bitte versuche es erneut.');
} finally {
setIsSubmitting(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
<div className="relative bg-bg-primary border border-border rounded-xl p-6 w-full max-w-2xl mx-4 shadow-xl max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold text-text-primary flex items-center gap-2">
<FileJson className="h-5 w-5 text-primary-500" />
Pathbuilder Import
</h2>
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="h-5 w-5" />
</Button>
</div>
{importSuccess ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<CheckCircle className="h-16 w-16 text-green-500 mb-4" />
<h3 className="text-xl font-semibold text-text-primary mb-2">
Import erfolgreich!
</h3>
<p className="text-text-secondary">
{parsedData?.build.name} wurde importiert.
</p>
</div>
) : (
<>
{/* File Upload */}
<div className="mb-4">
<input
ref={fileInputRef}
type="file"
accept=".json"
onChange={handleFileUpload}
className="hidden"
/>
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => fileInputRef.current?.click()}
>
<Upload className="h-4 w-4 mr-2" />
JSON-Datei hochladen
</Button>
</div>
<div className="relative mb-4">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-border" />
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-bg-primary text-text-secondary">oder</span>
</div>
</div>
{/* JSON Text Area */}
<div className="mb-4">
<label className="block text-sm font-medium text-text-primary mb-1.5">
JSON einfügen
</label>
<textarea
value={jsonText}
onChange={(e) => handleTextChange(e.target.value)}
placeholder='{"success": true, "build": {...}}'
rows={8}
className="w-full px-3 py-2 bg-bg-secondary border border-border rounded-lg text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent font-mono text-sm"
/>
</div>
{/* Error Message */}
{error && (
<div className="mb-4 p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-500 text-sm flex items-start gap-2">
<AlertCircle className="h-5 w-5 flex-shrink-0 mt-0.5" />
<span>{error}</span>
</div>
)}
{/* Preview */}
{parsedData && (
<div className="mb-6 p-4 rounded-lg bg-bg-secondary border border-border">
<h3 className="text-sm font-medium text-text-primary mb-3">Vorschau</h3>
<div className="grid grid-cols-2 gap-2 text-sm">
<div>
<span className="text-text-secondary">Name:</span>{' '}
<span className="text-text-primary font-medium">{parsedData.build.name}</span>
</div>
<div>
<span className="text-text-secondary">Level:</span>{' '}
<span className="text-text-primary font-medium">{parsedData.build.level}</span>
</div>
<div>
<span className="text-text-secondary">Klasse:</span>{' '}
<span className="text-text-primary font-medium">{parsedData.build.class}</span>
</div>
<div>
<span className="text-text-secondary">Abstammung:</span>{' '}
<span className="text-text-primary font-medium">{parsedData.build.ancestry}</span>
</div>
<div>
<span className="text-text-secondary">Erbe:</span>{' '}
<span className="text-text-primary font-medium">{parsedData.build.heritage}</span>
</div>
<div>
<span className="text-text-secondary">Hintergrund:</span>{' '}
<span className="text-text-primary font-medium">{parsedData.build.background}</span>
</div>
</div>
</div>
)}
{/* Actions */}
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" onClick={onClose}>
Abbrechen
</Button>
<Button
onClick={handleImport}
disabled={!parsedData || isSubmitting}
>
{isSubmitting ? (
<>
<Spinner size="sm" />
<span className="ml-2">Importiere...</span>
</>
) : (
'Importieren'
)}
</Button>
</div>
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,3 @@
export { CreateCharacterModal } from './components/create-character-modal';
export { ImportCharacterModal } from './components/import-character-modal';
export { CharacterSheetPage } from './components/character-sheet-page';

View File

@@ -1,6 +1,8 @@
import { Outlet, Link, useNavigate } from 'react-router-dom';
import { useAuthStore } from '@/features/auth';
import { Button } from './ui';
import LogoIcon from '../../../Logos/Logo_ohne_Text.svg';
import ZeasyLogo from '../../../Logos/Zeasy/logo_text_horizontal.svg';
export function Layout() {
const navigate = useNavigate();
@@ -18,12 +20,17 @@ export function Layout() {
<div className="container mx-auto px-4">
<div className="flex h-16 items-center justify-between">
{/* Logo */}
<Link to="/" className="flex items-center gap-2">
<div className="h-8 w-8 rounded-lg bg-primary-500 flex items-center justify-center">
<span className="text-white font-bold text-sm">D47</span>
</div>
<span className="font-semibold text-text-primary hidden sm:block">
Dimension47
<Link to="/" className="flex items-center gap-3">
<img
src={LogoIcon}
alt="Dimension 47"
className="h-10 w-10"
/>
<span
className="text-xl font-semibold text-text-primary tracking-wide hidden sm:block"
style={{ fontFamily: 'Cinzel, serif' }}
>
Dimension 47
</span>
</Link>
@@ -73,7 +80,17 @@ export function Layout() {
<div className="container mx-auto px-4">
<div className="flex items-center justify-center gap-2 text-text-muted text-sm">
<span>Powered by</span>
<span className="font-medium text-text-secondary">Zeasy Software</span>
<a
href="https://zeasy.software/"
target="_blank"
rel="noopener noreferrer"
>
<img
src={ZeasyLogo}
alt="Zeasy Software"
className="h-5 opacity-70 hover:opacity-100 transition-opacity"
/>
</a>
</div>
</div>
</footer>

View File

@@ -30,7 +30,11 @@ class ApiClient {
this.client.interceptors.response.use(
(response) => response,
(error: AxiosError<ApiError>) => {
if (error.response?.status === 401) {
// Don't redirect on 401 for auth endpoints (login, register)
const isAuthEndpoint = error.config?.url?.startsWith('/auth/login') ||
error.config?.url?.startsWith('/auth/register');
if (error.response?.status === 401 && !isAuthEndpoint) {
this.clearToken();
window.location.href = '/login';
}
@@ -115,14 +119,80 @@ class ApiClient {
return response.data;
}
// Character endpoints (placeholder - to be expanded)
// Character endpoints
async getCharacters(campaignId: string) {
const response = await this.client.get(`/characters/${campaignId}`);
const response = await this.client.get(`/campaigns/${campaignId}/characters`);
return response.data;
}
async getCharacter(campaignId: string, characterId: string) {
const response = await this.client.get(`/characters/${campaignId}/${characterId}`);
const response = await this.client.get(`/campaigns/${campaignId}/characters/${characterId}`);
return response.data;
}
async createCharacter(campaignId: string, data: {
name: string;
type?: 'PC' | 'NPC';
level?: number;
avatarUrl?: string;
hpCurrent: number;
hpMax: number;
hpTemp?: number;
ancestryId?: string;
heritageId?: string;
classId?: string;
backgroundId?: string;
experiencePoints?: number;
pathbuilderData?: unknown;
}) {
const response = await this.client.post(`/campaigns/${campaignId}/characters`, data);
return response.data;
}
async importCharacterFromPathbuilder(campaignId: string, pathbuilderJson: unknown) {
const response = await this.client.post(`/campaigns/${campaignId}/characters/import`, {
pathbuilderJson,
});
return response.data;
}
async updateCharacter(campaignId: string, characterId: string, data: Partial<{
name: string;
type: 'PC' | 'NPC';
level: number;
avatarUrl: string;
hpCurrent: number;
hpMax: number;
hpTemp: number;
}>) {
const response = await this.client.put(`/campaigns/${campaignId}/characters/${characterId}`, data);
return response.data;
}
async deleteCharacter(campaignId: string, characterId: string) {
const response = await this.client.delete(`/campaigns/${campaignId}/characters/${characterId}`);
return response.data;
}
async updateCharacterHp(campaignId: string, characterId: string, hpCurrent: number, hpTemp?: number) {
const response = await this.client.patch(`/campaigns/${campaignId}/characters/${characterId}/hp`, { hpCurrent, hpTemp });
return response.data;
}
// Conditions
async addCharacterCondition(campaignId: string, characterId: string, data: {
name: string;
nameGerman?: string;
value?: number;
duration?: string;
source?: string;
}) {
const response = await this.client.post(`/campaigns/${campaignId}/characters/${characterId}/conditions`, data);
return response.data;
}
async removeCharacterCondition(campaignId: string, characterId: string, conditionId: string) {
const response = await this.client.delete(`/campaigns/${campaignId}/characters/${characterId}/conditions/${conditionId}`);
return response.data;
}
}