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:
1
client/Logos/Logo_Horizontal.svg
Normal file
1
client/Logos/Logo_Horizontal.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 18 KiB |
1
client/Logos/Logo_Vertikal.svg
Normal file
1
client/Logos/Logo_Vertikal.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 18 KiB |
1
client/Logos/Logo_ohne_Text.svg
Normal file
1
client/Logos/Logo_ohne_Text.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 6.5 KiB |
2
client/Logos/Zeasy/logo.svg
Normal file
2
client/Logos/Zeasy/logo.svg
Normal file
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="793.44598" height="791.01794" version="1.1" viewBox="0 0 793.44598 791.01794" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><linearGradient id="b" x1="488.39264" x2="284.4079" y1="-170.60948" y2="590.67194" gradientUnits="userSpaceOnUse"><stop stop-color="#6e2ad8" offset=".21782178"/><stop stop-color="#8d19c9" offset=".84158415"/></linearGradient><linearGradient id="a" x1="541.3728" x2="220.80882" y1="83.038246" y2="803.0368" gradientUnits="userSpaceOnUse"><stop stop-color="#a608c2" offset="0"/><stop stop-color="#fb4ced" offset="1"/></linearGradient></defs><g transform="translate(12.977401,68.974609)" stroke-width="1.97972"><path d="m72.022599-68.974609c-46.944204 0-85 38.055796-85 85 1e-6 46.944203 38.055797 84.999999 85 84.999999 28.907141-2.4e-4 55.832701-14.691726 71.476561-38.999999h501.30553c17.80803 0 33.56211 10.526042 40.37695 26.978515 6.81484 16.452474 3.11757 35.032824-9.47461 47.625004l-260.4082 260.4082h-50.79883l-92 92h161.85352c12.19994-2.8e-4 23.90021-4.84647 32.52734-13.47266l273.88086-273.88086c38.71717-38.71716 50.36959-97.3003 29.41601-147.88672-20.95357-50.58641-70.6187-83.771484-125.37304-83.771484h-501.30553c-15.64386-24.308273-42.56942-38.999759-71.476561-39zm0 45.5c21.815247 1e-6 39.500001 17.684753 39.500001 39.5 0 21.815247-17.684754 39.499999-39.500001 39.5-21.815248 0-39.5-17.684752-39.5-39.5 0-21.815248 17.684752-39.5 39.5-39.5z" fill="url(#b)"/><path d="m695.46858 722.04333c46.94421 0 85-38.0558 85-85s-38.05579-85-85-85c-28.90713 2.4e-4 -55.83269 14.69173-71.47656 39h-501.30554c-17.80803 0-33.562109-10.52604-40.376949-26.97852-6.81484-16.45247-3.11757-35.03282 9.47461-47.625l260.4082-260.4082h50.79883l92-92h-161.85352c-12.19994 2.8e-4 -23.90021 4.84647-32.52734 13.47266l-273.88086 273.88086c-38.71717 38.71716-50.36961 97.3003-29.41601 147.88672 20.95357 50.58641 70.6187 83.77148 125.37304 83.77148h501.30554c15.64387 24.30827 42.56943 38.99976 71.47656 39zm0-45.5c-21.81524 0-39.5-17.68475-39.5-39.5s17.68476-39.5 39.5-39.5c21.81525 0 39.5 17.68475 39.5 39.5s-17.68475 39.5-39.5 39.5z" fill="url(#a)"/></g></svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
2
client/Logos/Zeasy/logo_text_horizontal.svg
Normal file
2
client/Logos/Zeasy/logo_text_horizontal.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.6 KiB |
2
client/Logos/Zeasy/logo_text_vertical.svg
Normal file
2
client/Logos/Zeasy/logo_text_vertical.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.6 KiB |
@@ -8,7 +8,7 @@
|
||||
<meta name="theme-color" content="#c26dbc" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@400;500;600;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<title>Dimension47</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
13
client/package-lock.json
generated
13
client/package-lock.json
generated
@@ -68,7 +68,6 @@
|
||||
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.28.6",
|
||||
"@babel/generator": "^7.28.6",
|
||||
@@ -1747,7 +1746,6 @@
|
||||
"integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
@@ -1758,7 +1756,6 @@
|
||||
"integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -1818,7 +1815,6 @@
|
||||
"integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.53.0",
|
||||
"@typescript-eslint/types": "8.53.0",
|
||||
@@ -2070,7 +2066,6 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -2193,7 +2188,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -2575,7 +2569,6 @@
|
||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -3763,7 +3756,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -3831,7 +3823,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -3841,7 +3832,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -4147,7 +4137,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -4234,7 +4223,6 @@
|
||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -4385,7 +4373,6 @@
|
||||
"integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
111
client/src/features/campaigns/components/add-member-modal.tsx
Normal file
111
client/src/features/campaigns/components/add-member-modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
109
client/src/features/campaigns/components/edit-campaign-modal.tsx
Normal file
109
client/src/features/campaigns/components/edit-campaign-modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
3
client/src/features/characters/index.ts
Normal file
3
client/src/features/characters/index.ts
Normal 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';
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user