feat: Complete character system, animated login, WebSocket sync
Character System: - Inventory system with 5,482 equipment items - Feats tab with categories and details - Actions tab with 99 PF2e actions - Item detail modal with equipment info - Feat detail modal with descriptions - Edit character modal with image cropping Auth & UI: - Animated login screen with splash → form transition - Letter-by-letter "DIMENSION 47" animation - Starfield background with floating orbs - Logo tap glow effect - "Remember me" functionality (localStorage/sessionStorage) Real-time Sync: - WebSocket gateway for character updates - Live sync for HP, conditions, inventory, equipment status, money, level Database: - Added credits field to characters - Added custom fields for items - Added feat fields and relations - Included full database backup Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1267
client/public/data/actions_german.json
Normal file
BIN
client/public/icons/action_double_black.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
client/public/icons/action_free_black.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
client/public/icons/action_reaction_black.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
client/public/icons/action_single_black.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
client/public/icons/action_triple_black.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
client/public/icons/free_action.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
client/public/icons/one_action.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
client/public/icons/reaction.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
client/public/icons/three_actions.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
@@ -1,8 +1,53 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useMemo } 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';
|
||||
import LogoOhneText from '../../../../Logos/Logo_ohne_Text.svg';
|
||||
|
||||
// Generate random stars for the background
|
||||
function generateStars(count: number) {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
id: i,
|
||||
x: Math.random() * 100,
|
||||
y: Math.random() * 100,
|
||||
duration: 2 + Math.random() * 4,
|
||||
delay: Math.random() * 5,
|
||||
size: Math.random() > 0.8 ? 3 : Math.random() > 0.5 ? 2 : 1,
|
||||
}));
|
||||
}
|
||||
|
||||
// Animated title component
|
||||
function AnimatedTitle() {
|
||||
const letters = 'DIMENSION'.split('');
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2 mb-6">
|
||||
<h1
|
||||
className="text-4xl md:text-5xl font-bold tracking-[0.2em] auth-title-dimension"
|
||||
style={{ fontFamily: 'Cinzel, serif' }}
|
||||
>
|
||||
{letters.map((letter, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="auth-title-letter"
|
||||
style={{
|
||||
animationDelay: `${index * 0.1}s`,
|
||||
}}
|
||||
>
|
||||
{letter}
|
||||
</span>
|
||||
))}
|
||||
</h1>
|
||||
<div className="flex items-center gap-4 mt-1">
|
||||
<div className="h-px w-16 bg-gradient-to-r from-transparent via-primary-500/50 to-primary-500" />
|
||||
<span className="text-5xl md:text-6xl auth-title-47">
|
||||
47
|
||||
</span>
|
||||
<div className="h-px w-16 bg-gradient-to-l from-transparent via-primary-500/50 to-primary-500" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
@@ -10,6 +55,23 @@ export function LoginPage() {
|
||||
const [identifier, setIdentifier] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [formError, setFormError] = useState('');
|
||||
const [rememberMe, setRememberMe] = useState(false);
|
||||
const [stage, setStage] = useState<'splash' | 'transition' | 'form'>('splash');
|
||||
const [logoTapped, setLogoTapped] = useState(false);
|
||||
|
||||
const stars = useMemo(() => generateStars(100), []);
|
||||
|
||||
const handleLogoTap = () => {
|
||||
setLogoTapped(true);
|
||||
setTimeout(() => setLogoTapped(false), 600);
|
||||
};
|
||||
|
||||
const handleEnter = () => {
|
||||
setStage('transition');
|
||||
setTimeout(() => {
|
||||
setStage('form');
|
||||
}, 800);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -17,81 +79,190 @@ export function LoginPage() {
|
||||
clearError();
|
||||
|
||||
if (!identifier.trim() || !password) {
|
||||
setFormError('Bitte alle Felder ausf\u00fcllen');
|
||||
setFormError('Bitte alle Felder ausfüllen');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await login(identifier.trim(), password);
|
||||
await login(identifier.trim(), password, rememberMe);
|
||||
navigate('/');
|
||||
} catch (err) {
|
||||
// Error is handled in the store
|
||||
console.error('Login failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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"
|
||||
/>
|
||||
<div className="min-h-screen flex flex-col items-center justify-center px-4 py-12 relative">
|
||||
{/* Animated Background */}
|
||||
<div className="auth-background">
|
||||
{/* Stars */}
|
||||
<div className="auth-stars">
|
||||
{stars.map((star) => (
|
||||
<div
|
||||
key={star.id}
|
||||
className="auth-star"
|
||||
style={{
|
||||
left: `${star.x}%`,
|
||||
top: `${star.y}%`,
|
||||
width: `${star.size}px`,
|
||||
height: `${star.size}px`,
|
||||
['--duration' as string]: `${star.duration}s`,
|
||||
['--delay' as string]: `${star.delay}s`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle>Willkommen zurück</CardTitle>
|
||||
<CardDescription>
|
||||
Melde dich an, um fortzufahren
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<CardContent className="space-y-4">
|
||||
{(error || formError) && (
|
||||
<div className="p-3 rounded-lg bg-error-500/10 border border-error-500/20 text-error-500 text-sm">
|
||||
{error || formError}
|
||||
</div>
|
||||
)}
|
||||
<Input
|
||||
label="Benutzername oder E-Mail"
|
||||
type="text"
|
||||
placeholder="dein-username"
|
||||
value={identifier}
|
||||
onChange={(e) => setIdentifier(e.target.value)}
|
||||
autoComplete="username"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<Input
|
||||
label="Passwort"
|
||||
type="password"
|
||||
placeholder="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</CardContent>
|
||||
<CardFooter className="flex-col gap-4">
|
||||
{/* Floating Orbs */}
|
||||
<div className="auth-orb auth-orb-1" />
|
||||
<div className="auth-orb auth-orb-2" />
|
||||
<div className="auth-orb auth-orb-3" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative z-10 flex flex-col items-center w-full max-w-md auth-content">
|
||||
{/* Logo without text */}
|
||||
<img
|
||||
src={LogoOhneText}
|
||||
alt="Dimension 47"
|
||||
onClick={handleLogoTap}
|
||||
className={`mb-4 auth-logo cursor-pointer transition-all duration-[1200ms] ease-in-out ${
|
||||
stage === 'splash' ? 'h-40 md:h-48' : 'h-20 md:h-24'
|
||||
} ${logoTapped ? 'auth-logo-burst' : ''}`}
|
||||
/>
|
||||
|
||||
{/* Animated Title */}
|
||||
<AnimatedTitle />
|
||||
|
||||
{/* Splash Screen: Button */}
|
||||
{stage === 'splash' && (
|
||||
<div className="mt-8 animate-[fade-in_0.5s_ease-out]">
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
isLoading={isLoading}
|
||||
onClick={handleEnter}
|
||||
className="px-12 h-14 text-lg font-semibold shadow-lg shadow-primary-500/30 hover:shadow-primary-500/50 transition-all duration-300 hover:scale-105 auth-enter-button"
|
||||
>
|
||||
Anmelden
|
||||
Los geht's
|
||||
</Button>
|
||||
<p className="text-sm text-text-secondary text-center">
|
||||
Noch kein Konto?{' '}
|
||||
<Link
|
||||
to="/register"
|
||||
className="text-primary-500 hover:text-primary-400 font-medium"
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transition: Fading out */}
|
||||
{stage === 'transition' && (
|
||||
<div className="mt-8 animate-[fade-out_0.6s_ease-in-out_forwards]">
|
||||
<Button
|
||||
className="px-12 h-14 text-lg font-semibold shadow-lg shadow-primary-500/30 auth-enter-button pointer-events-none"
|
||||
>
|
||||
Los geht's
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form Stage */}
|
||||
{stage === 'form' && (
|
||||
<Card className="w-full auth-card rounded-2xl mt-6 animate-[fade-in_0.5s_ease-out]">
|
||||
<CardHeader className="text-center pt-6 pb-2">
|
||||
<CardTitle
|
||||
className="text-xl font-semibold text-text-primary animate-[slide-up_0.4s_ease-out_both]"
|
||||
style={{ animationDelay: '0.1s' }}
|
||||
>
|
||||
Registrieren
|
||||
</Link>
|
||||
</p>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
Willkommen zurück
|
||||
</CardTitle>
|
||||
<CardDescription
|
||||
className="text-text-secondary text-sm animate-[slide-up_0.4s_ease-out_both]"
|
||||
style={{ animationDelay: '0.2s' }}
|
||||
>
|
||||
Melde dich an, um fortzufahren
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<CardContent className="space-y-4 px-6">
|
||||
{(error || formError) && (
|
||||
<div className="p-3 rounded-lg bg-error-500/10 border border-error-500/20 text-error-500 text-sm animate-[slide-down_0.3s_ease-out]">
|
||||
{error || formError}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="auth-input-glow rounded-lg animate-[slide-up_0.4s_ease-out_both]"
|
||||
style={{ animationDelay: '0.15s' }}
|
||||
>
|
||||
<Input
|
||||
label="Benutzername oder E-Mail"
|
||||
type="text"
|
||||
placeholder="dein-username"
|
||||
value={identifier}
|
||||
onChange={(e) => setIdentifier(e.target.value)}
|
||||
autoComplete="username"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="auth-input-glow rounded-lg animate-[slide-up_0.4s_ease-out_both]"
|
||||
style={{ animationDelay: '0.25s' }}
|
||||
>
|
||||
<Input
|
||||
label="Passwort"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<label
|
||||
className="flex items-center gap-3 cursor-pointer select-none group animate-[slide-up_0.4s_ease-out_both]"
|
||||
style={{ animationDelay: '0.35s' }}
|
||||
>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rememberMe}
|
||||
onChange={(e) => setRememberMe(e.target.checked)}
|
||||
className="sr-only peer"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<div className="w-5 h-5 rounded border-2 border-border bg-bg-secondary transition-all duration-200 peer-checked:bg-primary-500 peer-checked:border-primary-500 group-hover:border-primary-400" />
|
||||
<svg
|
||||
className="absolute top-0.5 left-0.5 w-4 h-4 text-white opacity-0 peer-checked:opacity-100 transition-opacity duration-200"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={3}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-sm text-text-secondary group-hover:text-text-primary transition-colors">
|
||||
Anmeldung speichern
|
||||
</span>
|
||||
</label>
|
||||
</CardContent>
|
||||
<CardFooter className="flex-col gap-4 px-6 pb-6">
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full h-12 text-base font-semibold shadow-lg shadow-primary-500/25 hover:shadow-primary-500/50 transition-all duration-300 hover:scale-[1.02] animate-[slide-up_0.4s_ease-out_both]"
|
||||
style={{ animationDelay: '0.4s' }}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
Anmelden
|
||||
</Button>
|
||||
<p
|
||||
className="text-sm text-text-secondary text-center animate-[slide-up_0.4s_ease-out_both]"
|
||||
style={{ animationDelay: '0.5s' }}
|
||||
>
|
||||
Noch kein Konto?{' '}
|
||||
<Link
|
||||
to="/register"
|
||||
className="text-primary-400 hover:text-primary-300 font-medium transition-colors"
|
||||
>
|
||||
Registrieren
|
||||
</Link>
|
||||
</p>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,53 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useMemo } 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';
|
||||
import LogoOhneText from '../../../../Logos/Logo_ohne_Text.svg';
|
||||
|
||||
// Generate random stars for the background
|
||||
function generateStars(count: number) {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
id: i,
|
||||
x: Math.random() * 100,
|
||||
y: Math.random() * 100,
|
||||
duration: 2 + Math.random() * 4,
|
||||
delay: Math.random() * 5,
|
||||
size: Math.random() > 0.8 ? 3 : Math.random() > 0.5 ? 2 : 1,
|
||||
}));
|
||||
}
|
||||
|
||||
// Animated title component
|
||||
function AnimatedTitle() {
|
||||
const letters = 'DIMENSION'.split('');
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2 mb-4">
|
||||
<h1
|
||||
className="text-3xl md:text-4xl font-bold tracking-[0.2em] auth-title-dimension"
|
||||
style={{ fontFamily: 'Cinzel, serif' }}
|
||||
>
|
||||
{letters.map((letter, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="auth-title-letter"
|
||||
style={{
|
||||
animationDelay: `${index * 0.1}s`,
|
||||
}}
|
||||
>
|
||||
{letter}
|
||||
</span>
|
||||
))}
|
||||
</h1>
|
||||
<div className="flex items-center gap-4 mt-1">
|
||||
<div className="h-px w-12 bg-gradient-to-r from-transparent via-primary-500/50 to-primary-500" />
|
||||
<span className="text-4xl md:text-5xl auth-title-47">
|
||||
47
|
||||
</span>
|
||||
<div className="h-px w-12 bg-gradient-to-l from-transparent via-primary-500/50 to-primary-500" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RegisterPage() {
|
||||
const navigate = useNavigate();
|
||||
@@ -13,18 +58,20 @@ export function RegisterPage() {
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [formError, setFormError] = useState('');
|
||||
|
||||
const stars = useMemo(() => generateStars(100), []);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setFormError('');
|
||||
clearError();
|
||||
|
||||
if (!username.trim() || !email.trim() || !password) {
|
||||
setFormError('Bitte alle Felder ausf\u00fcllen');
|
||||
setFormError('Bitte alle Felder ausfüllen');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setFormError('Passw\u00f6rter stimmen nicht \u00fcberein');
|
||||
setFormError('Passwörter stimmen nicht überein');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -42,85 +89,127 @@ export function RegisterPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<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"
|
||||
/>
|
||||
<div className="min-h-screen flex flex-col items-center justify-center px-4 py-8 relative">
|
||||
{/* Animated Background */}
|
||||
<div className="auth-background">
|
||||
{/* Stars */}
|
||||
<div className="auth-stars">
|
||||
{stars.map((star) => (
|
||||
<div
|
||||
key={star.id}
|
||||
className="auth-star"
|
||||
style={{
|
||||
left: `${star.x}%`,
|
||||
top: `${star.y}%`,
|
||||
width: `${star.size}px`,
|
||||
height: `${star.size}px`,
|
||||
['--duration' as string]: `${star.duration}s`,
|
||||
['--delay' as string]: `${star.delay}s`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle>Konto erstellen</CardTitle>
|
||||
<CardDescription>
|
||||
Registriere dich f\u00fcr Dimension47
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<CardContent className="space-y-4">
|
||||
{(error || formError) && (
|
||||
<div className="p-3 rounded-lg bg-error-500/10 border border-error-500/20 text-error-500 text-sm">
|
||||
{error || formError}
|
||||
{/* Floating Orbs */}
|
||||
<div className="auth-orb auth-orb-1" />
|
||||
<div className="auth-orb auth-orb-2" />
|
||||
<div className="auth-orb auth-orb-3" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative z-10 flex flex-col items-center w-full max-w-md auth-content">
|
||||
{/* Logo without text */}
|
||||
<img
|
||||
src={LogoOhneText}
|
||||
alt="Dimension 47"
|
||||
className="h-24 md:h-32 mb-3 auth-logo"
|
||||
/>
|
||||
|
||||
{/* Animated Title */}
|
||||
<AnimatedTitle />
|
||||
|
||||
<Card className="w-full auth-card rounded-2xl">
|
||||
<CardHeader className="text-center pt-5 pb-1">
|
||||
<CardTitle className="text-xl font-semibold text-text-primary">
|
||||
Konto erstellen
|
||||
</CardTitle>
|
||||
<CardDescription className="text-text-secondary text-sm">
|
||||
Werde Teil der Dimension
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<CardContent className="space-y-3 px-6">
|
||||
{(error || formError) && (
|
||||
<div className="p-3 rounded-lg bg-error-500/10 border border-error-500/20 text-error-500 text-sm animate-[slide-down_0.3s_ease-out]">
|
||||
{error || formError}
|
||||
</div>
|
||||
)}
|
||||
<div className="auth-input-glow rounded-lg transition-shadow duration-300">
|
||||
<Input
|
||||
label="Benutzername"
|
||||
type="text"
|
||||
placeholder="dein-username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="username"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Input
|
||||
label="Benutzername"
|
||||
type="text"
|
||||
placeholder="dein-username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="username"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<Input
|
||||
label="E-Mail"
|
||||
type="email"
|
||||
placeholder="deine@email.de"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
autoComplete="email"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<Input
|
||||
label="Passwort"
|
||||
type="password"
|
||||
placeholder="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<Input
|
||||
label="Passwort best\u00e4tigen"
|
||||
type="password"
|
||||
placeholder="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</CardContent>
|
||||
<CardFooter className="flex-col gap-4">
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
isLoading={isLoading}
|
||||
>
|
||||
Registrieren
|
||||
</Button>
|
||||
<p className="text-sm text-text-secondary text-center">
|
||||
Bereits ein Konto?{' '}
|
||||
<Link
|
||||
to="/login"
|
||||
className="text-primary-500 hover:text-primary-400 font-medium"
|
||||
<div className="auth-input-glow rounded-lg transition-shadow duration-300">
|
||||
<Input
|
||||
label="E-Mail"
|
||||
type="email"
|
||||
placeholder="deine@email.de"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
autoComplete="email"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="auth-input-glow rounded-lg transition-shadow duration-300">
|
||||
<Input
|
||||
label="Passwort"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="auth-input-glow rounded-lg transition-shadow duration-300">
|
||||
<Input
|
||||
label="Passwort bestätigen"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex-col gap-3 px-6 pb-5">
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full h-12 text-base font-semibold shadow-lg shadow-primary-500/25 hover:shadow-primary-500/50 transition-all duration-300 hover:scale-[1.02]"
|
||||
isLoading={isLoading}
|
||||
>
|
||||
Anmelden
|
||||
</Link>
|
||||
</p>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
Registrieren
|
||||
</Button>
|
||||
<p className="text-sm text-text-secondary text-center">
|
||||
Bereits ein Konto?{' '}
|
||||
<Link
|
||||
to="/login"
|
||||
className="text-primary-400 hover:text-primary-300 font-medium transition-colors"
|
||||
>
|
||||
Anmelden
|
||||
</Link>
|
||||
</p>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ interface AuthState {
|
||||
error: string | null;
|
||||
|
||||
// Actions
|
||||
login: (identifier: string, password: string) => Promise<void>;
|
||||
login: (identifier: string, password: string, remember?: boolean) => Promise<void>;
|
||||
register: (username: string, email: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
checkAuth: () => Promise<void>;
|
||||
@@ -25,11 +25,11 @@ export const useAuthStore = create<AuthState>()(
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
login: async (identifier: string, password: string) => {
|
||||
login: async (identifier: string, password: string, remember: boolean = false) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const response = await api.login(identifier, password);
|
||||
api.setToken(response.token);
|
||||
api.setToken(response.token, remember);
|
||||
set({
|
||||
user: response.user,
|
||||
isAuthenticated: true,
|
||||
|
||||
503
client/src/features/characters/components/actions-tab.tsx
Normal file
@@ -0,0 +1,503 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { Search, ChevronDown, ChevronUp, Filter, Swords, Compass, Clock, Zap } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card';
|
||||
import { Input } from '@/shared/components/ui/input';
|
||||
import { Button } from '@/shared/components/ui/button';
|
||||
import { ActionIcon, ActionTypeBadge } from '@/shared/components/ui/action-icon';
|
||||
|
||||
interface ActionResult {
|
||||
criticalSuccess?: string;
|
||||
success?: string;
|
||||
failure?: string;
|
||||
criticalFailure?: string;
|
||||
}
|
||||
|
||||
interface Action {
|
||||
id: string;
|
||||
name: string;
|
||||
actions: number | 'reaction' | 'free' | 'varies' | null;
|
||||
actionType: 'action' | 'reaction' | 'free' | 'exploration' | 'downtime' | 'varies';
|
||||
traits: string[];
|
||||
rarity: string;
|
||||
skill?: string;
|
||||
requirements?: string;
|
||||
trigger?: string;
|
||||
description: string;
|
||||
results?: ActionResult;
|
||||
}
|
||||
|
||||
interface ActionsData {
|
||||
actions: Action[];
|
||||
}
|
||||
|
||||
type ActionTypeFilter = 'all' | 'action' | 'reaction' | 'free' | 'exploration' | 'downtime';
|
||||
|
||||
const ACTION_TYPE_CONFIG: Record<ActionTypeFilter, { label: string; icon: React.ReactNode }> = {
|
||||
all: { label: 'Alle', icon: <Swords className="h-4 w-4" /> },
|
||||
action: { label: 'Aktionen', icon: <Swords className="h-4 w-4" /> },
|
||||
reaction: { label: 'Reaktionen', icon: <Zap className="h-4 w-4" /> },
|
||||
free: { label: 'Freie', icon: <Zap className="h-4 w-4" /> },
|
||||
exploration: { label: 'Erkundung', icon: <Compass className="h-4 w-4" /> },
|
||||
downtime: { label: 'Auszeit', icon: <Clock className="h-4 w-4" /> },
|
||||
};
|
||||
|
||||
function ActionCard({ action, isExpanded, onToggle }: { action: Action; isExpanded: boolean; onToggle: () => void }) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg bg-bg-secondary hover:bg-bg-tertiary transition-colors cursor-pointer"
|
||||
onClick={onToggle}
|
||||
>
|
||||
<div className="flex items-center justify-between p-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<ActionIcon actions={action.actions} size="md" />
|
||||
<div className="min-w-0">
|
||||
<span className="font-medium text-text-primary block truncate">{action.name}</span>
|
||||
{action.skill && (
|
||||
<span className="text-xs text-text-muted">{action.skill}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<ActionTypeBadge type={action.actionType} />
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="h-4 w-4 text-text-muted" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-text-muted" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="px-3 pb-3 space-y-3 border-t border-border-primary pt-3">
|
||||
{action.traits.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{action.traits.map((trait) => (
|
||||
<span
|
||||
key={trait}
|
||||
className="text-xs px-1.5 py-0.5 rounded bg-bg-tertiary text-text-secondary"
|
||||
>
|
||||
{trait}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{action.trigger && (
|
||||
<div>
|
||||
<span className="text-xs font-medium text-text-secondary">Auslöser: </span>
|
||||
<span className="text-sm text-text-primary">{action.trigger}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{action.requirements && (
|
||||
<div>
|
||||
<span className="text-xs font-medium text-text-secondary">Voraussetzungen: </span>
|
||||
<span className="text-sm text-text-primary">{action.requirements}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-sm text-text-primary leading-relaxed">{action.description}</p>
|
||||
|
||||
{action.results && (
|
||||
<div className="space-y-1.5 pt-2 border-t border-border-primary">
|
||||
{action.results.criticalSuccess && (
|
||||
<div>
|
||||
<span className="text-xs font-medium text-green-400">Kritischer Erfolg: </span>
|
||||
<span className="text-sm text-text-primary">{action.results.criticalSuccess}</span>
|
||||
</div>
|
||||
)}
|
||||
{action.results.success && (
|
||||
<div>
|
||||
<span className="text-xs font-medium text-blue-400">Erfolg: </span>
|
||||
<span className="text-sm text-text-primary">{action.results.success}</span>
|
||||
</div>
|
||||
)}
|
||||
{action.results.failure && (
|
||||
<div>
|
||||
<span className="text-xs font-medium text-orange-400">Fehlschlag: </span>
|
||||
<span className="text-sm text-text-primary">{action.results.failure}</span>
|
||||
</div>
|
||||
)}
|
||||
{action.results.criticalFailure && (
|
||||
<div>
|
||||
<span className="text-xs font-medium text-red-400">Kritischer Fehlschlag: </span>
|
||||
<span className="text-sm text-text-primary">{action.results.criticalFailure}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ActionsTabProps {
|
||||
characterFeats?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
nameGerman?: string | null;
|
||||
source: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
type CategoryKey = 'class' | 'action' | 'reaction' | 'free' | 'exploration' | 'downtime' | 'varies';
|
||||
|
||||
function CollapsibleCategory({
|
||||
title,
|
||||
icon,
|
||||
count,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
count: number;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader
|
||||
className="pb-2 cursor-pointer hover:bg-bg-secondary/50 transition-colors"
|
||||
onClick={onToggle}
|
||||
>
|
||||
<CardTitle className="text-base flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{icon}
|
||||
{title} ({count})
|
||||
</div>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="h-4 w-4 text-text-muted" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-text-muted" />
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
{isExpanded && <CardContent className="pt-0">{children}</CardContent>}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function ActionsTab({ characterFeats = [] }: ActionsTabProps) {
|
||||
const [actions, setActions] = useState<Action[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState<ActionTypeFilter>('all');
|
||||
const [expandedActionId, setExpandedActionId] = useState<string | null>(null);
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<CategoryKey>>(new Set());
|
||||
|
||||
const toggleCategory = (category: CategoryKey) => {
|
||||
setExpandedCategories((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(category)) {
|
||||
next.delete(category);
|
||||
} else {
|
||||
next.add(category);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/data/actions_german.json')
|
||||
.then((res) => res.json())
|
||||
.then((data: ActionsData) => {
|
||||
setActions(data.actions);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to load actions:', err);
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const filteredActions = useMemo(() => {
|
||||
return actions.filter((action) => {
|
||||
// Type filter
|
||||
if (typeFilter !== 'all') {
|
||||
if (typeFilter === 'free' && action.actionType !== 'free' && action.actions !== 'free') {
|
||||
return false;
|
||||
}
|
||||
if (typeFilter !== 'free' && action.actionType !== typeFilter) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Search filter
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
return (
|
||||
action.name.toLowerCase().includes(query) ||
|
||||
action.description.toLowerCase().includes(query) ||
|
||||
action.skill?.toLowerCase().includes(query) ||
|
||||
action.traits.some((t) => t.toLowerCase().includes(query))
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [actions, searchQuery, typeFilter]);
|
||||
|
||||
const groupedActions = useMemo(() => {
|
||||
const groups: Record<string, Action[]> = {
|
||||
action: [],
|
||||
reaction: [],
|
||||
free: [],
|
||||
exploration: [],
|
||||
downtime: [],
|
||||
varies: [],
|
||||
};
|
||||
|
||||
filteredActions.forEach((action) => {
|
||||
const type = action.actionType;
|
||||
if (groups[type]) {
|
||||
groups[type].push(action);
|
||||
}
|
||||
});
|
||||
|
||||
return groups;
|
||||
}, [filteredActions]);
|
||||
|
||||
const handleToggleAction = (actionId: string) => {
|
||||
setExpandedActionId(expandedActionId === actionId ? null : actionId);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-48">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-500" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const classFeats = characterFeats.filter((f) => f.source === 'CLASS');
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Search and Filter */}
|
||||
<Card>
|
||||
<CardContent className="p-3">
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-text-muted" />
|
||||
<Input
|
||||
placeholder="Aktion suchen..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant={showFilters ? 'default' : 'outline'}
|
||||
size="icon"
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
>
|
||||
<Filter className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showFilters && (
|
||||
<div className="flex flex-wrap gap-2 mt-3 pt-3 border-t border-border-primary">
|
||||
{(Object.keys(ACTION_TYPE_CONFIG) as ActionTypeFilter[]).map((type) => (
|
||||
<Button
|
||||
key={type}
|
||||
variant={typeFilter === type ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setTypeFilter(type)}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
{ACTION_TYPE_CONFIG[type].icon}
|
||||
{ACTION_TYPE_CONFIG[type].label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Results count */}
|
||||
<p className="text-sm text-text-muted px-1">
|
||||
{filteredActions.length} {filteredActions.length === 1 ? 'Aktion' : 'Aktionen'} gefunden
|
||||
</p>
|
||||
|
||||
{/* Class Actions from Feats */}
|
||||
{classFeats.length > 0 && typeFilter === 'all' && !searchQuery && (
|
||||
<CollapsibleCategory
|
||||
title="Klassenaktionen"
|
||||
icon={<Swords className="h-4 w-4" />}
|
||||
count={classFeats.length}
|
||||
isExpanded={expandedCategories.has('class')}
|
||||
onToggle={() => toggleCategory('class')}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{classFeats.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>
|
||||
</CollapsibleCategory>
|
||||
)}
|
||||
|
||||
{/* Grouped Actions */}
|
||||
{typeFilter === 'all' ? (
|
||||
<>
|
||||
{groupedActions.action.length > 0 && (
|
||||
<CollapsibleCategory
|
||||
title="Aktionen"
|
||||
icon={<Swords className="h-4 w-4" />}
|
||||
count={groupedActions.action.length}
|
||||
isExpanded={expandedCategories.has('action')}
|
||||
onToggle={() => toggleCategory('action')}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{groupedActions.action.map((action) => (
|
||||
<ActionCard
|
||||
key={action.id}
|
||||
action={action}
|
||||
isExpanded={expandedActionId === action.id}
|
||||
onToggle={() => handleToggleAction(action.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleCategory>
|
||||
)}
|
||||
|
||||
{groupedActions.reaction.length > 0 && (
|
||||
<CollapsibleCategory
|
||||
title="Reaktionen"
|
||||
icon={<Zap className="h-4 w-4" />}
|
||||
count={groupedActions.reaction.length}
|
||||
isExpanded={expandedCategories.has('reaction')}
|
||||
onToggle={() => toggleCategory('reaction')}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{groupedActions.reaction.map((action) => (
|
||||
<ActionCard
|
||||
key={action.id}
|
||||
action={action}
|
||||
isExpanded={expandedActionId === action.id}
|
||||
onToggle={() => handleToggleAction(action.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleCategory>
|
||||
)}
|
||||
|
||||
{groupedActions.free.length > 0 && (
|
||||
<CollapsibleCategory
|
||||
title="Freie Aktionen"
|
||||
icon={<Zap className="h-4 w-4" />}
|
||||
count={groupedActions.free.length}
|
||||
isExpanded={expandedCategories.has('free')}
|
||||
onToggle={() => toggleCategory('free')}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{groupedActions.free.map((action) => (
|
||||
<ActionCard
|
||||
key={action.id}
|
||||
action={action}
|
||||
isExpanded={expandedActionId === action.id}
|
||||
onToggle={() => handleToggleAction(action.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleCategory>
|
||||
)}
|
||||
|
||||
{groupedActions.exploration.length > 0 && (
|
||||
<CollapsibleCategory
|
||||
title="Erkundungsaktivitäten"
|
||||
icon={<Compass className="h-4 w-4" />}
|
||||
count={groupedActions.exploration.length}
|
||||
isExpanded={expandedCategories.has('exploration')}
|
||||
onToggle={() => toggleCategory('exploration')}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{groupedActions.exploration.map((action) => (
|
||||
<ActionCard
|
||||
key={action.id}
|
||||
action={action}
|
||||
isExpanded={expandedActionId === action.id}
|
||||
onToggle={() => handleToggleAction(action.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleCategory>
|
||||
)}
|
||||
|
||||
{groupedActions.downtime.length > 0 && (
|
||||
<CollapsibleCategory
|
||||
title="Auszeitaktivitäten"
|
||||
icon={<Clock className="h-4 w-4" />}
|
||||
count={groupedActions.downtime.length}
|
||||
isExpanded={expandedCategories.has('downtime')}
|
||||
onToggle={() => toggleCategory('downtime')}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{groupedActions.downtime.map((action) => (
|
||||
<ActionCard
|
||||
key={action.id}
|
||||
action={action}
|
||||
isExpanded={expandedActionId === action.id}
|
||||
onToggle={() => handleToggleAction(action.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleCategory>
|
||||
)}
|
||||
|
||||
{groupedActions.varies.length > 0 && (
|
||||
<CollapsibleCategory
|
||||
title="Sonstige"
|
||||
icon={<Swords className="h-4 w-4" />}
|
||||
count={groupedActions.varies.length}
|
||||
isExpanded={expandedCategories.has('varies')}
|
||||
onToggle={() => toggleCategory('varies')}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{groupedActions.varies.map((action) => (
|
||||
<ActionCard
|
||||
key={action.id}
|
||||
action={action}
|
||||
isExpanded={expandedActionId === action.id}
|
||||
onToggle={() => handleToggleAction(action.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleCategory>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
/* Flat list when filtered */
|
||||
<Card>
|
||||
<CardContent className="p-3">
|
||||
<div className="space-y-2">
|
||||
{filteredActions.length === 0 ? (
|
||||
<p className="text-center text-text-muted py-8">Keine Aktionen gefunden</p>
|
||||
) : (
|
||||
filteredActions.map((action) => (
|
||||
<ActionCard
|
||||
key={action.id}
|
||||
action={action}
|
||||
isExpanded={expandedActionId === action.id}
|
||||
onToggle={() => handleToggleAction(action.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
586
client/src/features/characters/components/add-feat-modal.tsx
Normal file
@@ -0,0 +1,586 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X, Search, Star, Swords, Users, Sparkles, ChevronLeft, ChevronRight, ExternalLink, Eye, EyeOff } from 'lucide-react';
|
||||
import { Button, Input, Spinner } from '@/shared/components/ui';
|
||||
import { api } from '@/shared/lib/api';
|
||||
import type { Feat, FeatSearchResult, CharacterSkill, Proficiency } from '@/shared/types';
|
||||
|
||||
interface AddFeatModalProps {
|
||||
onClose: () => void;
|
||||
onAdd: (feat: {
|
||||
featId?: string;
|
||||
name: string;
|
||||
nameGerman?: string;
|
||||
level: number;
|
||||
source: 'CLASS' | 'ANCESTRY' | 'GENERAL' | 'SKILL' | 'BONUS' | 'ARCHETYPE';
|
||||
}) => Promise<void>;
|
||||
existingFeatNames: string[];
|
||||
characterLevel: number;
|
||||
characterClass?: string;
|
||||
characterAncestry?: string;
|
||||
characterSkills?: CharacterSkill[];
|
||||
}
|
||||
|
||||
// Proficiency levels in order (for comparison)
|
||||
const PROFICIENCY_ORDER: Proficiency[] = ['UNTRAINED', 'TRAINED', 'EXPERT', 'MASTER', 'LEGENDARY'];
|
||||
|
||||
// Check if character meets skill prerequisites
|
||||
function checkSkillPrerequisites(
|
||||
prerequisites: string | undefined,
|
||||
skills: CharacterSkill[]
|
||||
): { met: boolean; unmetReason?: string } {
|
||||
if (!prerequisites) return { met: true };
|
||||
|
||||
const prereqLower = prerequisites.toLowerCase();
|
||||
|
||||
// Patterns to check: "trained in X", "expert in X", "master in X", "legendary in X"
|
||||
const patterns = [
|
||||
{ regex: /legendary in (\w+(?:\s+\w+)?)/gi, required: 'LEGENDARY' as Proficiency },
|
||||
{ regex: /master in (\w+(?:\s+\w+)?)/gi, required: 'MASTER' as Proficiency },
|
||||
{ regex: /expert in (\w+(?:\s+\w+)?)/gi, required: 'EXPERT' as Proficiency },
|
||||
{ regex: /trained in (\w+(?:\s+\w+)?)/gi, required: 'TRAINED' as Proficiency },
|
||||
];
|
||||
|
||||
for (const { regex, required } of patterns) {
|
||||
let match;
|
||||
while ((match = regex.exec(prereqLower)) !== null) {
|
||||
const skillName = match[1].trim();
|
||||
|
||||
// Skip non-skill prerequisites like "trained in armor" or "trained in light armor"
|
||||
if (skillName.includes('armor') || skillName.includes('weapon') || skillName.includes('spell')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find the character's skill
|
||||
const characterSkill = skills.find(
|
||||
s => s.skillName.toLowerCase() === skillName ||
|
||||
s.skillName.toLowerCase().includes(skillName)
|
||||
);
|
||||
|
||||
const characterProficiency = characterSkill?.proficiency || 'UNTRAINED';
|
||||
const requiredIndex = PROFICIENCY_ORDER.indexOf(required);
|
||||
const characterIndex = PROFICIENCY_ORDER.indexOf(characterProficiency);
|
||||
|
||||
if (characterIndex < requiredIndex) {
|
||||
const requiredLabel = required.charAt(0) + required.slice(1).toLowerCase();
|
||||
return {
|
||||
met: false,
|
||||
unmetReason: `${requiredLabel} in ${skillName.charAt(0).toUpperCase() + skillName.slice(1)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { met: true };
|
||||
}
|
||||
|
||||
type FeatTypeFilter = 'all' | 'General' | 'Skill' | 'Class' | 'Ancestry' | 'Archetype';
|
||||
|
||||
const FEAT_TYPE_ICONS: Record<FeatTypeFilter, React.ReactNode> = {
|
||||
all: <Star className="h-4 w-4" />,
|
||||
General: <Star className="h-4 w-4" />,
|
||||
Skill: <Sparkles className="h-4 w-4" />,
|
||||
Class: <Swords className="h-4 w-4" />,
|
||||
Ancestry: <Users className="h-4 w-4" />,
|
||||
Archetype: <Star className="h-4 w-4" />,
|
||||
};
|
||||
|
||||
const FEAT_TYPE_LABELS: Record<FeatTypeFilter, string> = {
|
||||
all: 'Alle',
|
||||
General: 'Allgemein',
|
||||
Skill: 'Fertigkeit',
|
||||
Class: 'Klasse',
|
||||
Ancestry: 'Abstammung',
|
||||
Archetype: 'Archetyp',
|
||||
};
|
||||
|
||||
const FEAT_SOURCE_MAP: Record<string, 'CLASS' | 'ANCESTRY' | 'GENERAL' | 'SKILL' | 'BONUS' | 'ARCHETYPE'> = {
|
||||
General: 'GENERAL',
|
||||
Skill: 'SKILL',
|
||||
Class: 'CLASS',
|
||||
Ancestry: 'ANCESTRY',
|
||||
Archetype: 'ARCHETYPE',
|
||||
Heritage: 'ANCESTRY',
|
||||
};
|
||||
|
||||
export function AddFeatModal({
|
||||
onClose,
|
||||
onAdd,
|
||||
existingFeatNames,
|
||||
characterLevel,
|
||||
characterClass,
|
||||
characterAncestry,
|
||||
characterSkills = [],
|
||||
}: AddFeatModalProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [featType, setFeatType] = useState<FeatTypeFilter>('all');
|
||||
const [searchResult, setSearchResult] = useState<FeatSearchResult | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [selectedFeat, setSelectedFeat] = useState<Feat | null>(null);
|
||||
const [selectedSource, setSelectedSource] = useState<'CLASS' | 'ANCESTRY' | 'GENERAL' | 'SKILL' | 'BONUS' | 'ARCHETYPE'>('GENERAL');
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [manualMode, setManualMode] = useState(false);
|
||||
const [manualFeatName, setManualFeatName] = useState('');
|
||||
const [showUnavailable, setShowUnavailable] = useState(false);
|
||||
|
||||
// Search feats
|
||||
useEffect(() => {
|
||||
const searchFeats = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await api.searchFeats({
|
||||
query: searchQuery || undefined,
|
||||
featType: featType !== 'all' ? featType : undefined,
|
||||
className: featType === 'Class' ? characterClass : undefined,
|
||||
ancestryName: featType === 'Ancestry' ? characterAncestry : undefined,
|
||||
maxLevel: characterLevel,
|
||||
page,
|
||||
limit: 30,
|
||||
});
|
||||
setSearchResult(result);
|
||||
} catch (error) {
|
||||
console.error('Failed to search feats:', error);
|
||||
// If the API fails (e.g., no feats in DB), show empty result
|
||||
setSearchResult({ items: [], total: 0, page: 1, limit: 30, totalPages: 0 });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const debounce = setTimeout(searchFeats, 300);
|
||||
return () => clearTimeout(debounce);
|
||||
}, [searchQuery, featType, characterLevel, characterClass, characterAncestry, page]);
|
||||
|
||||
// Reset page when search/filter changes
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [searchQuery, featType]);
|
||||
|
||||
// Update source when feat type changes
|
||||
useEffect(() => {
|
||||
if (featType !== 'all') {
|
||||
setSelectedSource(FEAT_SOURCE_MAP[featType] || 'GENERAL');
|
||||
}
|
||||
}, [featType]);
|
||||
|
||||
const handleSelectFeat = (feat: Feat) => {
|
||||
setSelectedFeat(feat);
|
||||
setSelectedSource(FEAT_SOURCE_MAP[feat.featType || 'General'] || 'GENERAL');
|
||||
};
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (manualMode) {
|
||||
if (!manualFeatName.trim()) return;
|
||||
setIsAdding(true);
|
||||
try {
|
||||
await onAdd({
|
||||
name: manualFeatName.trim(),
|
||||
level: characterLevel,
|
||||
source: selectedSource,
|
||||
});
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Failed to add feat:', error);
|
||||
} finally {
|
||||
setIsAdding(false);
|
||||
}
|
||||
} else {
|
||||
if (!selectedFeat) return;
|
||||
setIsAdding(true);
|
||||
try {
|
||||
await onAdd({
|
||||
featId: selectedFeat.id,
|
||||
name: selectedFeat.name,
|
||||
nameGerman: selectedFeat.nameGerman,
|
||||
level: characterLevel,
|
||||
source: selectedSource,
|
||||
});
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Failed to add feat:', error);
|
||||
} finally {
|
||||
setIsAdding(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const isAlreadyOwned = (featName: string) =>
|
||||
existingFeatNames.some((n) => n.toLowerCase() === featName.toLowerCase());
|
||||
|
||||
const getFeatTypeColor = (type?: string) => {
|
||||
switch (type) {
|
||||
case 'Class':
|
||||
return 'text-red-400';
|
||||
case 'Ancestry':
|
||||
return 'text-blue-400';
|
||||
case 'Skill':
|
||||
return 'text-green-400';
|
||||
case 'General':
|
||||
return 'text-yellow-400';
|
||||
case 'Archetype':
|
||||
return 'text-purple-400';
|
||||
default:
|
||||
return 'text-text-secondary';
|
||||
}
|
||||
};
|
||||
|
||||
const getActionsDisplay = (actions?: string) => {
|
||||
if (!actions) return null;
|
||||
switch (actions) {
|
||||
case '1':
|
||||
return <span className="text-xs px-1.5 py-0.5 rounded bg-primary-500/20 text-primary-400">1 Aktion</span>;
|
||||
case '2':
|
||||
return <span className="text-xs px-1.5 py-0.5 rounded bg-primary-500/20 text-primary-400">2 Aktionen</span>;
|
||||
case '3':
|
||||
return <span className="text-xs px-1.5 py-0.5 rounded bg-primary-500/20 text-primary-400">3 Aktionen</span>;
|
||||
case 'free':
|
||||
return <span className="text-xs px-1.5 py-0.5 rounded bg-green-500/20 text-green-400">Freie Aktion</span>;
|
||||
case 'reaction':
|
||||
return <span className="text-xs px-1.5 py-0.5 rounded bg-yellow-500/20 text-yellow-400">Reaktion</span>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative w-full sm:max-w-2xl max-h-[90vh] bg-bg-secondary rounded-t-2xl sm:rounded-2xl flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||
<h2 className="text-lg font-semibold text-text-primary">
|
||||
{selectedFeat || manualMode ? 'Talent hinzufügen' : 'Talent suchen'}
|
||||
</h2>
|
||||
<Button variant="ghost" size="icon" onClick={onClose}>
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{selectedFeat ? (
|
||||
// Selected feat detail view
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
<button
|
||||
onClick={() => setSelectedFeat(null)}
|
||||
className="text-sm text-primary-500 hover:text-primary-400"
|
||||
>
|
||||
← Zurück zur Suche
|
||||
</button>
|
||||
|
||||
<div className="p-4 rounded-xl bg-bg-tertiary">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="font-semibold text-text-primary text-lg">
|
||||
{selectedFeat.nameGerman || selectedFeat.name}
|
||||
</h3>
|
||||
{selectedFeat.nameGerman && (
|
||||
<p className="text-sm text-text-muted">{selectedFeat.name}</p>
|
||||
)}
|
||||
</div>
|
||||
{getActionsDisplay(selectedFeat.actions)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<span className={`text-sm ${getFeatTypeColor(selectedFeat.featType)}`}>
|
||||
{selectedFeat.featType || 'Allgemein'}
|
||||
</span>
|
||||
{selectedFeat.level && (
|
||||
<span className="text-sm text-text-secondary">
|
||||
Level {selectedFeat.level}+
|
||||
</span>
|
||||
)}
|
||||
{selectedFeat.rarity && selectedFeat.rarity !== 'Common' && (
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded ${
|
||||
selectedFeat.rarity === 'Uncommon' ? 'bg-orange-500/20 text-orange-400' :
|
||||
selectedFeat.rarity === 'Rare' ? 'bg-blue-500/20 text-blue-400' :
|
||||
'bg-purple-500/20 text-purple-400'
|
||||
}`}>
|
||||
{selectedFeat.rarity}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Prerequisites */}
|
||||
{selectedFeat.prerequisites && (
|
||||
<div className="mt-3 text-sm">
|
||||
<span className="text-text-secondary">Voraussetzungen: </span>
|
||||
<span className="text-text-primary">{selectedFeat.prerequisites}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Traits */}
|
||||
{selectedFeat.traits.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-1">
|
||||
{selectedFeat.traits.map((trait) => (
|
||||
<span
|
||||
key={trait}
|
||||
className="px-2 py-0.5 text-xs rounded bg-primary-500/20 text-primary-400"
|
||||
>
|
||||
{trait}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary */}
|
||||
{selectedFeat.summary && (
|
||||
<p className="mt-3 text-sm text-text-secondary leading-relaxed">
|
||||
{selectedFeat.summaryGerman || selectedFeat.summary}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{selectedFeat.description && (
|
||||
<div className="mt-3 pt-3 border-t border-border">
|
||||
<p className="text-sm text-text-primary leading-relaxed">
|
||||
{selectedFeat.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Archives of Nethys Link */}
|
||||
{selectedFeat.url && (
|
||||
<a
|
||||
href={`https://2e.aonprd.com${selectedFeat.url}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-3 flex items-center gap-2 text-sm text-primary-400 hover:text-primary-300"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Auf Archives of Nethys anzeigen
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full h-12"
|
||||
onClick={handleAdd}
|
||||
disabled={isAdding}
|
||||
>
|
||||
{isAdding ? 'Wird hinzugefügt...' : `${selectedFeat.nameGerman || selectedFeat.name} hinzufügen`}
|
||||
</Button>
|
||||
</div>
|
||||
) : manualMode ? (
|
||||
// Manual entry mode
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
<button
|
||||
onClick={() => setManualMode(false)}
|
||||
className="text-sm text-primary-500 hover:text-primary-400"
|
||||
>
|
||||
← Zurück zur Suche
|
||||
</button>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">
|
||||
Talent Name
|
||||
</label>
|
||||
<Input
|
||||
value={manualFeatName}
|
||||
onChange={(e) => setManualFeatName(e.target.value)}
|
||||
placeholder="Name des Talents eingeben..."
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-text-primary">Typ</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(['CLASS', 'ANCESTRY', 'GENERAL', 'SKILL', 'ARCHETYPE', 'BONUS'] as const).map((src) => (
|
||||
<button
|
||||
key={src}
|
||||
onClick={() => setSelectedSource(src)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
selectedSource === src
|
||||
? 'bg-primary-500 text-white'
|
||||
: 'bg-bg-tertiary text-text-secondary hover:bg-bg-elevated'
|
||||
}`}
|
||||
>
|
||||
{src === 'CLASS' ? 'Klasse' :
|
||||
src === 'ANCESTRY' ? 'Abstammung' :
|
||||
src === 'GENERAL' ? 'Allgemein' :
|
||||
src === 'SKILL' ? 'Fertigkeit' :
|
||||
src === 'ARCHETYPE' ? 'Archetyp' : 'Bonus'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full h-12"
|
||||
onClick={handleAdd}
|
||||
disabled={isAdding || !manualFeatName.trim()}
|
||||
>
|
||||
{isAdding ? 'Wird hinzugefügt...' : 'Talent hinzufügen'}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
// Search view
|
||||
<>
|
||||
{/* Search */}
|
||||
<div className="p-4 border-b border-border space-y-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-text-secondary" />
|
||||
<Input
|
||||
placeholder="Nach Talent suchen..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Type Filter */}
|
||||
<div className="flex gap-1 overflow-x-auto pb-1">
|
||||
{(Object.keys(FEAT_TYPE_LABELS) as FeatTypeFilter[]).map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => setFeatType(type)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium whitespace-nowrap transition-colors ${
|
||||
featType === type
|
||||
? 'bg-primary-500 text-white'
|
||||
: 'bg-bg-tertiary text-text-secondary hover:bg-bg-elevated hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
{FEAT_TYPE_ICONS[type]}
|
||||
{FEAT_TYPE_LABELS[type]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toggle for unavailable feats */}
|
||||
<div className="px-4 pb-2">
|
||||
<button
|
||||
onClick={() => setShowUnavailable(!showUnavailable)}
|
||||
className="flex items-center gap-2 text-sm text-text-secondary hover:text-text-primary"
|
||||
>
|
||||
{showUnavailable ? (
|
||||
<Eye className="h-4 w-4" />
|
||||
) : (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
)}
|
||||
{showUnavailable ? 'Nicht erlernbare ausblenden' : 'Nicht erlernbare anzeigen'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div className="flex-1 overflow-y-auto p-4 pt-0">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : !searchResult?.items.length ? (
|
||||
<div className="text-center py-8 space-y-4">
|
||||
<p className="text-text-secondary">
|
||||
{searchResult?.total === 0
|
||||
? 'Keine Talente in der Datenbank gefunden.'
|
||||
: 'Keine passenden Talente gefunden.'}
|
||||
</p>
|
||||
<Button variant="outline" onClick={() => setManualMode(true)}>
|
||||
Talent manuell hinzufügen
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{searchResult.items.map((feat) => {
|
||||
const owned = isAlreadyOwned(feat.name);
|
||||
const prereqCheck = checkSkillPrerequisites(feat.prerequisites, characterSkills);
|
||||
const isUnavailable = !prereqCheck.met;
|
||||
|
||||
// Hide unavailable feats if toggle is off
|
||||
if (isUnavailable && !showUnavailable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isDisabled = owned || isUnavailable;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={feat.id}
|
||||
onClick={() => !isDisabled && handleSelectFeat(feat)}
|
||||
disabled={isDisabled}
|
||||
className={`w-full text-left p-3 rounded-lg transition-colors ${
|
||||
isDisabled
|
||||
? 'bg-bg-tertiary/50 opacity-50 cursor-not-allowed'
|
||||
: 'bg-bg-tertiary hover:bg-bg-elevated'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className={`font-medium truncate ${isDisabled ? 'text-text-muted' : 'text-text-primary'}`}>
|
||||
{feat.nameGerman || feat.name}
|
||||
</p>
|
||||
{getActionsDisplay(feat.actions)}
|
||||
</div>
|
||||
<p className={`text-xs ${isDisabled ? 'text-text-muted' : getFeatTypeColor(feat.featType)}`}>
|
||||
{feat.featType || 'Allgemein'}
|
||||
{feat.level && ` • Level ${feat.level}+`}
|
||||
{feat.className && ` • ${feat.className}`}
|
||||
{feat.ancestryName && ` • ${feat.ancestryName}`}
|
||||
</p>
|
||||
</div>
|
||||
{owned && (
|
||||
<span className="text-xs text-text-muted flex-shrink-0">Bereits vorhanden</span>
|
||||
)}
|
||||
{isUnavailable && !owned && (
|
||||
<span className="text-xs text-orange-400 flex-shrink-0">
|
||||
{prereqCheck.unmetReason}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Manual add button at bottom */}
|
||||
{searchResult && searchResult.items.length > 0 && (
|
||||
<div className="mt-4 pt-4 border-t border-border text-center">
|
||||
<Button variant="outline" onClick={() => setManualMode(true)}>
|
||||
Talent manuell hinzufügen
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{searchResult && searchResult.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between p-4 border-t border-border">
|
||||
<span className="text-sm text-text-secondary">
|
||||
{searchResult.total} Ergebnisse
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page <= 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-sm text-text-primary">
|
||||
{page} / {searchResult.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.min(searchResult.totalPages, p + 1))}
|
||||
disabled={page >= searchResult.totalPages}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,22 +17,24 @@ interface AddItemModalProps {
|
||||
existingItemNames: string[];
|
||||
}
|
||||
|
||||
type CategoryFilter = 'all' | 'Weapons' | 'Armor' | 'Consumables' | 'Equipment';
|
||||
type CategoryFilter = 'all' | 'Weapons' | 'Armor' | 'Shields' | 'Consumables' | 'Alchemical Items';
|
||||
|
||||
const CATEGORY_ICONS: Record<CategoryFilter, React.ReactNode> = {
|
||||
all: <Package className="h-4 w-4" />,
|
||||
Weapons: <Swords className="h-4 w-4" />,
|
||||
Armor: <Shield className="h-4 w-4" />,
|
||||
Shields: <Shield className="h-4 w-4" />,
|
||||
Consumables: <FlaskConical className="h-4 w-4" />,
|
||||
Equipment: <Package className="h-4 w-4" />,
|
||||
'Alchemical Items': <FlaskConical className="h-4 w-4" />,
|
||||
};
|
||||
|
||||
const CATEGORY_LABELS: Record<CategoryFilter, string> = {
|
||||
all: 'Alle',
|
||||
Weapons: 'Waffen',
|
||||
Armor: 'Rüstung',
|
||||
Shields: 'Schilde',
|
||||
Consumables: 'Verbrauchsgüter',
|
||||
Equipment: 'Ausrüstung',
|
||||
'Alchemical Items': 'Alchemie',
|
||||
};
|
||||
|
||||
function parseBulk(bulkStr?: string): number {
|
||||
@@ -84,13 +86,29 @@ export function AddItemModal({ onClose, onAdd, existingItemNames }: AddItemModal
|
||||
if (!selectedItem) return;
|
||||
setIsAdding(true);
|
||||
try {
|
||||
await onAdd({
|
||||
equipmentId: selectedItem.id,
|
||||
name: selectedItem.name,
|
||||
quantity,
|
||||
bulk: parseBulk(selectedItem.bulk),
|
||||
equipped: false,
|
||||
});
|
||||
const isIndividualItem = ['Weapons', 'Armor', 'Shields'].includes(selectedItem.itemCategory);
|
||||
|
||||
if (isIndividualItem && quantity > 1) {
|
||||
// Waffen/Rüstung/Schilde einzeln hinzufügen
|
||||
for (let i = 0; i < quantity; i++) {
|
||||
await onAdd({
|
||||
equipmentId: selectedItem.id,
|
||||
name: selectedItem.name,
|
||||
quantity: 1,
|
||||
bulk: parseBulk(selectedItem.bulk),
|
||||
equipped: false,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Normale Items mit Anzahl hinzufügen
|
||||
await onAdd({
|
||||
equipmentId: selectedItem.id,
|
||||
name: selectedItem.name,
|
||||
quantity,
|
||||
bulk: parseBulk(selectedItem.bulk),
|
||||
equipped: false,
|
||||
});
|
||||
}
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Failed to add item:', error);
|
||||
@@ -108,8 +126,12 @@ export function AddItemModal({ onClose, onAdd, existingItemNames }: AddItemModal
|
||||
return 'text-red-400';
|
||||
case 'Armor':
|
||||
return 'text-blue-400';
|
||||
case 'Shields':
|
||||
return 'text-cyan-400';
|
||||
case 'Consumables':
|
||||
return 'text-green-400';
|
||||
case 'Alchemical Items':
|
||||
return 'text-purple-400';
|
||||
default:
|
||||
return 'text-text-secondary';
|
||||
}
|
||||
@@ -143,21 +165,14 @@ export function AddItemModal({ onClose, onAdd, existingItemNames }: AddItemModal
|
||||
</button>
|
||||
|
||||
<div className="p-4 rounded-xl bg-bg-tertiary">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="font-semibold text-text-primary text-lg">
|
||||
{selectedItem.name}
|
||||
</h3>
|
||||
<p className={`text-sm ${getCategoryColor(selectedItem.itemCategory)}`}>
|
||||
{selectedItem.itemCategory}
|
||||
{selectedItem.itemSubcategory && ` • ${selectedItem.itemSubcategory}`}
|
||||
</p>
|
||||
</div>
|
||||
{selectedItem.level !== undefined && selectedItem.level !== null && (
|
||||
<span className="px-2 py-1 text-xs rounded bg-primary-500/20 text-primary-400">
|
||||
Stufe {selectedItem.level}
|
||||
</span>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="font-semibold text-text-primary text-lg">
|
||||
{selectedItem.name}
|
||||
</h3>
|
||||
<p className={`text-sm ${getCategoryColor(selectedItem.itemCategory)}`}>
|
||||
{selectedItem.itemCategory}
|
||||
{selectedItem.itemSubcategory && ` • ${selectedItem.itemSubcategory}`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Item Stats */}
|
||||
@@ -320,14 +335,9 @@ export function AddItemModal({ onClose, onAdd, existingItemNames }: AddItemModal
|
||||
{item.bulk && ` • ${item.bulk === 'L' ? 'Leicht' : `${item.bulk} Bulk`}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{item.level !== undefined && item.level !== null && (
|
||||
<span className="text-xs text-text-muted">Lv. {item.level}</span>
|
||||
)}
|
||||
{owned && (
|
||||
<span className="text-xs text-text-muted">Bereits im Inventar</span>
|
||||
)}
|
||||
</div>
|
||||
{owned && (
|
||||
<span className="text-xs text-text-muted flex-shrink-0">Bereits im Inventar</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { X, Upload, User } from 'lucide-react';
|
||||
import { Button, Input, Spinner } from '@/shared/components/ui';
|
||||
import { api } from '@/shared/lib/api';
|
||||
import { ImageCropModal } from './image-crop-modal';
|
||||
import type { Character } from '@/shared/types';
|
||||
|
||||
interface EditCharacterModalProps {
|
||||
campaignId: string;
|
||||
character: Character;
|
||||
onClose: () => void;
|
||||
onUpdated: (updated: Character) => void;
|
||||
}
|
||||
|
||||
export function EditCharacterModal({ campaignId, character, onClose, onUpdated }: EditCharacterModalProps) {
|
||||
const [name, setName] = useState(character.name);
|
||||
const [type, setType] = useState<'PC' | 'NPC'>(character.type);
|
||||
const [level, setLevel] = useState(character.level);
|
||||
const [hpMax, setHpMax] = useState(character.hpMax);
|
||||
const [avatarUrl, setAvatarUrl] = useState(character.avatarUrl || '');
|
||||
const [ancestry, setAncestry] = useState(character.ancestryId || '');
|
||||
const [heritage, setHeritage] = useState(character.heritageId || '');
|
||||
const [characterClass, setCharacterClass] = useState(character.classId || '');
|
||||
const [background, setBackground] = useState(character.backgroundId || '');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Image crop state
|
||||
const [showImageCrop, setShowImageCrop] = useState(false);
|
||||
const [selectedImage, setSelectedImage] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleImageSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
setError('Bilddatei ist zu groß. Maximum 10MB.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!file.type.startsWith('image/')) {
|
||||
setError('Bitte eine gültige Bilddatei auswählen.');
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
setSelectedImage(e.target?.result as string);
|
||||
setShowImageCrop(true);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
// Reset input so same file can be selected again
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageCropComplete = (croppedImage: string) => {
|
||||
setAvatarUrl(croppedImage);
|
||||
setShowImageCrop(false);
|
||||
setSelectedImage(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) {
|
||||
setError('Name ist erforderlich');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const updated = await api.updateCharacter(campaignId, character.id, {
|
||||
name: name.trim(),
|
||||
type,
|
||||
level,
|
||||
hpMax,
|
||||
avatarUrl: avatarUrl || undefined,
|
||||
ancestryId: ancestry.trim() || undefined,
|
||||
heritageId: heritage.trim() || undefined,
|
||||
classId: characterClass.trim() || undefined,
|
||||
backgroundId: background.trim() || undefined,
|
||||
});
|
||||
onUpdated(updated);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError('Fehler beim Speichern');
|
||||
console.error('Failed to update 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 max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-lg font-semibold text-text-primary">Charakter bearbeiten</h2>
|
||||
<Button variant="ghost" size="icon" onClick={onClose}>
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Avatar Upload */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-2">
|
||||
Avatar
|
||||
</label>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative">
|
||||
{avatarUrl ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={name}
|
||||
className="w-16 h-16 rounded-full object-cover border-2 border-border"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-16 h-16 rounded-full bg-bg-tertiary border-2 border-border flex items-center justify-center">
|
||||
<User className="h-8 w-8 text-text-muted" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleImageSelect}
|
||||
className="hidden"
|
||||
id="avatar-upload"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="w-full"
|
||||
>
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
Bild hochladen
|
||||
</Button>
|
||||
{avatarUrl && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setAvatarUrl('')}
|
||||
className="w-full text-text-secondary"
|
||||
>
|
||||
Entfernen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
{/* Ancestry & Heritage */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">
|
||||
Abstammung
|
||||
</label>
|
||||
<Input
|
||||
value={ancestry}
|
||||
onChange={(e) => setAncestry(e.target.value)}
|
||||
placeholder="z.B. Human"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">
|
||||
Erbe
|
||||
</label>
|
||||
<Input
|
||||
value={heritage}
|
||||
onChange={(e) => setHeritage(e.target.value)}
|
||||
placeholder="z.B. Skilled Human"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Class & Background */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">
|
||||
Klasse
|
||||
</label>
|
||||
<Input
|
||||
value={characterClass}
|
||||
onChange={(e) => setCharacterClass(e.target.value)}
|
||||
placeholder="z.B. Fighter"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">
|
||||
Hintergrund
|
||||
</label>
|
||||
<Input
|
||||
value={background}
|
||||
onChange={(e) => setBackground(e.target.value)}
|
||||
placeholder="z.B. Warrior"
|
||||
/>
|
||||
</div>
|
||||
</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
|
||||
</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" /> : 'Speichern'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image Crop Modal */}
|
||||
{showImageCrop && selectedImage && (
|
||||
<ImageCropModal
|
||||
image={selectedImage}
|
||||
onComplete={handleImageCropComplete}
|
||||
onCancel={() => {
|
||||
setShowImageCrop(false);
|
||||
setSelectedImage(null);
|
||||
}}
|
||||
loading={isSubmitting}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
249
client/src/features/characters/components/feat-detail-modal.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X, Star, Trash2, ExternalLink } from 'lucide-react';
|
||||
import { Button, Spinner } from '@/shared/components/ui';
|
||||
import { api } from '@/shared/lib/api';
|
||||
import type { CharacterFeat, Feat } from '@/shared/types';
|
||||
|
||||
interface FeatDetailModalProps {
|
||||
feat: CharacterFeat;
|
||||
onClose: () => void;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
CLASS: 'Klassentalent',
|
||||
ANCESTRY: 'Abstammungstalent',
|
||||
GENERAL: 'Allgemeines Talent',
|
||||
SKILL: 'Fertigkeitstalent',
|
||||
ARCHETYPE: 'Archetypentalent',
|
||||
BONUS: 'Bonustalent',
|
||||
};
|
||||
|
||||
const SOURCE_COLORS: Record<string, string> = {
|
||||
CLASS: 'bg-red-500/20 text-red-400',
|
||||
ANCESTRY: 'bg-blue-500/20 text-blue-400',
|
||||
GENERAL: 'bg-yellow-500/20 text-yellow-400',
|
||||
SKILL: 'bg-green-500/20 text-green-400',
|
||||
ARCHETYPE: 'bg-purple-500/20 text-purple-400',
|
||||
BONUS: 'bg-cyan-500/20 text-cyan-400',
|
||||
};
|
||||
|
||||
export function FeatDetailModal({ feat, onClose, onRemove }: FeatDetailModalProps) {
|
||||
const [featDetails, setFeatDetails] = useState<Feat | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [notFound, setNotFound] = useState(false);
|
||||
|
||||
// Try to load feat details from database
|
||||
useEffect(() => {
|
||||
const fetchFeatDetails = async () => {
|
||||
// If we have a featId, try to fetch from database
|
||||
if (feat.featId) {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await api.getFeatById(feat.featId);
|
||||
setFeatDetails(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch feat details:', error);
|
||||
setNotFound(true);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
} else {
|
||||
// Try to find by name
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await api.getFeatByName(feat.name);
|
||||
setFeatDetails(data);
|
||||
} catch {
|
||||
// Feat not in database, that's OK
|
||||
setNotFound(true);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchFeatDetails();
|
||||
}, [feat.featId, feat.name]);
|
||||
|
||||
const getActionsDisplay = (actions?: string) => {
|
||||
if (!actions) return null;
|
||||
switch (actions) {
|
||||
case '1':
|
||||
return <span className="text-sm px-2 py-0.5 rounded bg-primary-500/20 text-primary-400">1 Aktion</span>;
|
||||
case '2':
|
||||
return <span className="text-sm px-2 py-0.5 rounded bg-primary-500/20 text-primary-400">2 Aktionen</span>;
|
||||
case '3':
|
||||
return <span className="text-sm px-2 py-0.5 rounded bg-primary-500/20 text-primary-400">3 Aktionen</span>;
|
||||
case 'free':
|
||||
return <span className="text-sm px-2 py-0.5 rounded bg-green-500/20 text-green-400">Freie Aktion</span>;
|
||||
case 'reaction':
|
||||
return <span className="text-sm px-2 py-0.5 rounded bg-yellow-500/20 text-yellow-400">Reaktion</span>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const displayName = feat.nameGerman || feat.name;
|
||||
const sourceLabel = SOURCE_LABELS[feat.source] || feat.source;
|
||||
const sourceColor = SOURCE_COLORS[feat.source] || 'bg-bg-tertiary text-text-secondary';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative w-full sm:max-w-lg bg-bg-primary rounded-t-2xl sm:rounded-2xl border border-border max-h-[85vh] flex flex-col animate-in slide-in-from-bottom-4 sm:slide-in-from-bottom-0 sm:zoom-in-95 duration-200">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between p-4 border-b border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<Star className="h-5 w-5 text-yellow-400" />
|
||||
<div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h2 className="text-lg font-bold text-text-primary">
|
||||
{displayName}
|
||||
</h2>
|
||||
{featDetails?.actions && getActionsDisplay(featDetails.actions)}
|
||||
</div>
|
||||
{feat.nameGerman && feat.name !== feat.nameGerman && (
|
||||
<p className="text-sm text-text-muted">{feat.name}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={onClose}>
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Basic Info */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className={`px-2 py-1 rounded text-sm font-medium ${sourceColor}`}>
|
||||
{sourceLabel}
|
||||
</span>
|
||||
<span className="px-2 py-1 rounded text-sm bg-bg-tertiary text-text-secondary">
|
||||
Level {feat.level} erhalten
|
||||
</span>
|
||||
{featDetails?.level && (
|
||||
<span className="px-2 py-1 rounded text-sm bg-bg-tertiary text-text-secondary">
|
||||
Benötigt Level {featDetails.level}+
|
||||
</span>
|
||||
)}
|
||||
{featDetails?.rarity && featDetails.rarity !== 'Common' && (
|
||||
<span className={`px-2 py-1 rounded text-sm ${
|
||||
featDetails.rarity === 'Uncommon' ? 'bg-orange-500/20 text-orange-400' :
|
||||
featDetails.rarity === 'Rare' ? 'bg-blue-500/20 text-blue-400' :
|
||||
'bg-purple-500/20 text-purple-400'
|
||||
}`}>
|
||||
{featDetails.rarity}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Prerequisites */}
|
||||
{featDetails?.prerequisites && (
|
||||
<div className="p-3 rounded-lg bg-bg-secondary">
|
||||
<p className="text-xs text-text-secondary mb-1">Voraussetzungen</p>
|
||||
<p className="text-sm text-text-primary">{featDetails.prerequisites}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Traits */}
|
||||
{featDetails?.traits && featDetails.traits.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs text-text-secondary mb-2">Merkmale</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{featDetails.traits.map((trait) => (
|
||||
<span
|
||||
key={trait}
|
||||
className="px-2 py-0.5 rounded text-xs bg-primary-500/20 text-primary-400"
|
||||
>
|
||||
{trait}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary/Description */}
|
||||
{featDetails?.summary && (
|
||||
<div className="p-3 rounded-lg bg-bg-secondary">
|
||||
<p className="text-xs text-text-secondary mb-1">Beschreibung</p>
|
||||
<p className="text-sm text-text-primary leading-relaxed">
|
||||
{featDetails.summaryGerman || featDetails.summary}
|
||||
</p>
|
||||
{featDetails.summaryGerman && featDetails.summary && (
|
||||
<p className="text-xs text-text-muted mt-2 italic">
|
||||
Original: {featDetails.summary}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Full Description */}
|
||||
{featDetails?.description && (
|
||||
<div className="p-3 rounded-lg bg-bg-secondary">
|
||||
<p className="text-xs text-text-secondary mb-1">Vollständige Beschreibung</p>
|
||||
<p className="text-sm text-text-primary leading-relaxed whitespace-pre-wrap">
|
||||
{featDetails.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Not found in database message */}
|
||||
{notFound && !featDetails && (
|
||||
<div className="p-3 rounded-lg bg-bg-secondary text-center">
|
||||
<p className="text-sm text-text-muted">
|
||||
Detaillierte Informationen nicht verfügbar.
|
||||
</p>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
Dieses Talent ist nicht in der Datenbank.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Archives of Nethys Link */}
|
||||
{featDetails?.url && (
|
||||
<a
|
||||
href={`https://2e.aonprd.com${featDetails.url}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-sm text-primary-400 hover:text-primary-300"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Auf Archives of Nethys anzeigen
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer Actions */}
|
||||
<div className="p-4 border-t border-border">
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
onRemove();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Talent entfernen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
314
client/src/features/characters/components/image-crop-modal.tsx
Normal file
@@ -0,0 +1,314 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { X, ZoomIn, ZoomOut } from 'lucide-react';
|
||||
import { Button } from '@/shared/components/ui';
|
||||
|
||||
interface ImageCropModalProps {
|
||||
image: string;
|
||||
onComplete: (croppedImage: string) => void;
|
||||
onCancel: () => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
interface CropData {
|
||||
x: number;
|
||||
y: number;
|
||||
scale: number;
|
||||
}
|
||||
|
||||
export function ImageCropModal({
|
||||
image,
|
||||
onComplete,
|
||||
onCancel,
|
||||
loading = false
|
||||
}: ImageCropModalProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const imageRef = useRef<HTMLImageElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
const [cropData, setCropData] = useState<CropData>({ x: 0, y: 0, scale: 0.5 });
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [lastTouchDistance, setLastTouchDistance] = useState<number | null>(null);
|
||||
const [imageLoaded, setImageLoaded] = useState(false);
|
||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
||||
|
||||
// Load and center image when component mounts
|
||||
useEffect(() => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
setImageLoaded(true);
|
||||
if (containerRef.current) {
|
||||
const containerRect = containerRef.current.getBoundingClientRect();
|
||||
const containerWidth = containerRect.width;
|
||||
const containerHeight = containerRect.height;
|
||||
|
||||
const scaleToFit = Math.min(containerWidth / img.naturalWidth, containerHeight / img.naturalHeight);
|
||||
const initialScale = Math.max(0.3, scaleToFit);
|
||||
|
||||
const cropCenterX = containerWidth / 2;
|
||||
const cropCenterY = containerHeight / 2;
|
||||
const scaledImageWidth = img.naturalWidth * initialScale;
|
||||
const scaledImageHeight = img.naturalHeight * initialScale;
|
||||
|
||||
const centerX = cropCenterX - scaledImageWidth / 2;
|
||||
const centerY = cropCenterY - scaledImageHeight / 2;
|
||||
|
||||
setCropData({ x: centerX, y: centerY, scale: initialScale });
|
||||
}
|
||||
};
|
||||
img.src = image;
|
||||
|
||||
if (imageRef.current) {
|
||||
imageRef.current.src = image;
|
||||
}
|
||||
}, [image]);
|
||||
|
||||
// Update canvas preview whenever crop data changes
|
||||
useEffect(() => {
|
||||
if (imageLoaded && imageRef.current) {
|
||||
updatePreview();
|
||||
}
|
||||
}, [cropData, imageLoaded]);
|
||||
|
||||
const updatePreview = () => {
|
||||
const canvas = canvasRef.current;
|
||||
const img = imageRef.current;
|
||||
const container = containerRef.current;
|
||||
|
||||
if (!canvas || !img || !container) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const size = 160;
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
|
||||
ctx.clearRect(0, 0, size, size);
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const containerWidth = containerRect.width;
|
||||
const containerHeight = containerRect.height;
|
||||
const cropRadius = 80;
|
||||
const cropCenterX = containerWidth / 2;
|
||||
const cropCenterY = containerHeight / 2;
|
||||
|
||||
const imageCenterX = cropData.x + (img.naturalWidth * cropData.scale) / 2;
|
||||
const imageCenterY = cropData.y + (img.naturalHeight * cropData.scale) / 2;
|
||||
|
||||
const offsetX = (cropCenterX - imageCenterX) / cropData.scale;
|
||||
const offsetY = (cropCenterY - imageCenterY) / cropData.scale;
|
||||
|
||||
const sourceX = (img.naturalWidth / 2) + offsetX - (cropRadius / cropData.scale);
|
||||
const sourceY = (img.naturalHeight / 2) + offsetY - (cropRadius / cropData.scale);
|
||||
const sourceSize = (cropRadius * 2) / cropData.scale;
|
||||
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2);
|
||||
ctx.clip();
|
||||
|
||||
ctx.drawImage(img, sourceX, sourceY, sourceSize, sourceSize, 0, 0, size, size);
|
||||
ctx.restore();
|
||||
|
||||
ctx.strokeStyle = '#3b82f6';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.arc(size / 2, size / 2, size / 2 - 1, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
};
|
||||
|
||||
const handleStart = useCallback((clientX: number, clientY: number) => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const containerRect = containerRef.current.getBoundingClientRect();
|
||||
const relativeX = clientX - containerRect.left;
|
||||
const relativeY = clientY - containerRect.top;
|
||||
|
||||
setIsDragging(true);
|
||||
setDragStart({ x: relativeX - cropData.x, y: relativeY - cropData.y });
|
||||
}, [cropData.x, cropData.y]);
|
||||
|
||||
const handleMove = useCallback((clientX: number, clientY: number) => {
|
||||
if (!isDragging || !containerRef.current) return;
|
||||
|
||||
const containerRect = containerRef.current.getBoundingClientRect();
|
||||
const relativeX = clientX - containerRect.left;
|
||||
const relativeY = clientY - containerRect.top;
|
||||
|
||||
setCropData(prev => ({
|
||||
...prev,
|
||||
x: relativeX - dragStart.x,
|
||||
y: relativeY - dragStart.y
|
||||
}));
|
||||
}, [isDragging, dragStart]);
|
||||
|
||||
const handleEnd = useCallback(() => {
|
||||
setIsDragging(false);
|
||||
setLastTouchDistance(null);
|
||||
}, []);
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => handleStart(e.clientX, e.clientY);
|
||||
const handleMouseMove = (e: React.MouseEvent) => handleMove(e.clientX, e.clientY);
|
||||
const handleMouseUp = () => handleEnd();
|
||||
|
||||
const getTouchDistance = (touch1: React.Touch, touch2: React.Touch) => {
|
||||
const dx = touch1.clientX - touch2.clientX;
|
||||
const dy = touch1.clientY - touch2.clientY;
|
||||
return Math.sqrt(dx * dx + dy * dy);
|
||||
};
|
||||
|
||||
const handleTouchStart = (e: React.TouchEvent) => {
|
||||
if (e.touches.length === 1) {
|
||||
handleStart(e.touches[0].clientX, e.touches[0].clientY);
|
||||
} else if (e.touches.length === 2) {
|
||||
setLastTouchDistance(getTouchDistance(e.touches[0], e.touches[1]));
|
||||
setIsDragging(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: React.TouchEvent) => {
|
||||
if (e.touches.length === 1 && isDragging) {
|
||||
handleMove(e.touches[0].clientX, e.touches[0].clientY);
|
||||
} else if (e.touches.length === 2) {
|
||||
const distance = getTouchDistance(e.touches[0], e.touches[1]);
|
||||
if (lastTouchDistance) {
|
||||
const scale = Math.max(0.1, Math.min(3, cropData.scale * (distance / lastTouchDistance)));
|
||||
setCropData(prev => ({ ...prev, scale }));
|
||||
}
|
||||
setLastTouchDistance(distance);
|
||||
}
|
||||
};
|
||||
|
||||
const handleZoomIn = () => setCropData(prev => ({ ...prev, scale: Math.min(3, prev.scale * 1.2) }));
|
||||
const handleZoomOut = () => setCropData(prev => ({ ...prev, scale: Math.max(0.1, prev.scale / 1.2) }));
|
||||
|
||||
const handleCrop = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const img = imageRef.current;
|
||||
const container = containerRef.current;
|
||||
|
||||
if (!ctx || !img || !container) return;
|
||||
|
||||
const outputSize = 300;
|
||||
canvas.width = outputSize;
|
||||
canvas.height = outputSize;
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const containerWidth = containerRect.width;
|
||||
const containerHeight = containerRect.height;
|
||||
const cropRadius = 80;
|
||||
const cropCenterX = containerWidth / 2;
|
||||
const cropCenterY = containerHeight / 2;
|
||||
|
||||
const imageCenterX = cropData.x + (img.naturalWidth * cropData.scale) / 2;
|
||||
const imageCenterY = cropData.y + (img.naturalHeight * cropData.scale) / 2;
|
||||
|
||||
const offsetX = (cropCenterX - imageCenterX) / cropData.scale;
|
||||
const offsetY = (cropCenterY - imageCenterY) / cropData.scale;
|
||||
|
||||
const sourceX = (img.naturalWidth / 2) + offsetX - (cropRadius / cropData.scale);
|
||||
const sourceY = (img.naturalHeight / 2) + offsetY - (cropRadius / cropData.scale);
|
||||
const sourceSize = (cropRadius * 2) / cropData.scale;
|
||||
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.arc(outputSize / 2, outputSize / 2, outputSize / 2, 0, Math.PI * 2);
|
||||
ctx.clip();
|
||||
|
||||
ctx.drawImage(img, sourceX, sourceY, sourceSize, sourceSize, 0, 0, outputSize, outputSize);
|
||||
ctx.restore();
|
||||
|
||||
const croppedImage = canvas.toDataURL('image/jpeg', 0.9);
|
||||
onComplete(croppedImage);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/80" onClick={onCancel} />
|
||||
|
||||
<div className="relative bg-bg-primary border border-border rounded-xl w-full max-w-sm shadow-xl">
|
||||
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||
<h3 className="text-lg font-semibold text-text-primary">Bild zuschneiden</h3>
|
||||
<Button variant="ghost" size="icon" onClick={onCancel}>
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Preview */}
|
||||
<div className="flex justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-text-secondary mb-2">Vorschau</p>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="rounded-full border-2 border-primary-500"
|
||||
width={160}
|
||||
height={160}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Touchpad area */}
|
||||
<div>
|
||||
<p className="text-xs text-text-secondary mb-2 text-center">
|
||||
Ziehen zum Bewegen, Pinch zum Zoomen
|
||||
</p>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative w-full h-32 rounded-lg overflow-hidden cursor-move select-none bg-bg-tertiary"
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleEnd}
|
||||
>
|
||||
{imageLoaded && (
|
||||
<img
|
||||
ref={imageRef}
|
||||
src={image}
|
||||
alt="Crop"
|
||||
className="absolute pointer-events-none opacity-0"
|
||||
style={{
|
||||
transform: `translate(${cropData.x}px, ${cropData.y}px) scale(${cropData.scale * 2})`,
|
||||
transformOrigin: '0 0',
|
||||
}}
|
||||
draggable={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div className="w-40 h-40 rounded-full border-2 border-dashed border-primary-500/50" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Zoom controls */}
|
||||
<div className="flex justify-center items-center gap-3 mt-3">
|
||||
<Button variant="outline" size="icon" onClick={handleZoomOut} disabled={loading}>
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-sm text-text-secondary w-12 text-center">
|
||||
{Math.round(cropData.scale * 100)}%
|
||||
</span>
|
||||
<Button variant="outline" size="icon" onClick={handleZoomIn} disabled={loading}>
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 p-4 border-t border-border">
|
||||
<Button variant="outline" onClick={onCancel} disabled={loading} className="flex-1">
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button onClick={handleCrop} disabled={loading || !imageLoaded} className="flex-1">
|
||||
{loading ? 'Speichere...' : 'Übernehmen'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
411
client/src/features/characters/components/item-detail-modal.tsx
Normal file
@@ -0,0 +1,411 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X, Swords, Shield, Package, Minus, Plus, Trash2, Pencil } from 'lucide-react';
|
||||
import { Button, Input } from '@/shared/components/ui';
|
||||
import { api } from '@/shared/lib/api';
|
||||
import type { CharacterItem, Equipment } from '@/shared/types';
|
||||
|
||||
interface ItemDetailModalProps {
|
||||
item: CharacterItem;
|
||||
isGM: boolean;
|
||||
onClose: () => void;
|
||||
onUpdateQuantity: (quantity: number) => void;
|
||||
onRemove: () => void;
|
||||
onToggleEquipped: (equipped: boolean) => void;
|
||||
onUpdateItem: (data: {
|
||||
alias?: string;
|
||||
customName?: string;
|
||||
customDamage?: string;
|
||||
customDamageType?: string;
|
||||
customTraits?: string[];
|
||||
customRange?: string;
|
||||
customHands?: string;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export function ItemDetailModal({
|
||||
item,
|
||||
isGM,
|
||||
onClose,
|
||||
onUpdateQuantity,
|
||||
onRemove,
|
||||
onToggleEquipped,
|
||||
onUpdateItem,
|
||||
}: ItemDetailModalProps) {
|
||||
const [equipment, setEquipment] = useState<Equipment | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
// Edit state
|
||||
const [alias, setAlias] = useState(item.alias || '');
|
||||
const [customName, setCustomName] = useState(item.customName || '');
|
||||
const [customDamage, setCustomDamage] = useState(item.customDamage || '');
|
||||
const [customDamageType, setCustomDamageType] = useState(item.customDamageType || '');
|
||||
const [customTraits, setCustomTraits] = useState(item.customTraits?.join(', ') || '');
|
||||
const [customRange, setCustomRange] = useState(item.customRange || '');
|
||||
const [customHands, setCustomHands] = useState(item.customHands || '');
|
||||
|
||||
useEffect(() => {
|
||||
const fetchEquipmentDetails = async () => {
|
||||
if (!item.equipmentId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await api.getEquipmentById(item.equipmentId);
|
||||
setEquipment(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch equipment details:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchEquipmentDetails();
|
||||
}, [item.equipmentId]);
|
||||
|
||||
const handleQuantityChange = (delta: number) => {
|
||||
const newQuantity = Math.max(1, item.quantity + delta);
|
||||
onUpdateQuantity(newQuantity);
|
||||
};
|
||||
|
||||
const handleSaveEdit = () => {
|
||||
const updateData: any = { alias: alias || undefined };
|
||||
|
||||
if (isGM) {
|
||||
updateData.customName = customName || undefined;
|
||||
updateData.customDamage = customDamage || undefined;
|
||||
updateData.customDamageType = customDamageType || undefined;
|
||||
updateData.customTraits = customTraits ? customTraits.split(',').map(t => t.trim()).filter(Boolean) : undefined;
|
||||
updateData.customRange = customRange || undefined;
|
||||
updateData.customHands = customHands || undefined;
|
||||
}
|
||||
|
||||
onUpdateItem(updateData);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const getCategoryIcon = () => {
|
||||
const category = equipment?.itemCategory?.toLowerCase() || '';
|
||||
if (category.includes('weapon')) return <Swords className="h-5 w-5 text-red-400" />;
|
||||
if (category.includes('armor') || category.includes('shield')) return <Shield className="h-5 w-5 text-blue-400" />;
|
||||
return <Package className="h-5 w-5 text-text-secondary" />;
|
||||
};
|
||||
|
||||
const formatBulk = (bulk: number | string | undefined) => {
|
||||
if (bulk === undefined || bulk === null) return '—';
|
||||
if (bulk === 0 || bulk === '0') return '—';
|
||||
if (bulk === 'L' || (typeof bulk === 'number' && bulk < 1 && bulk > 0)) return 'L';
|
||||
return `${bulk}`;
|
||||
};
|
||||
|
||||
// Get effective values (custom overrides base equipment)
|
||||
const effectiveDamage = item.customDamage || equipment?.damage;
|
||||
const effectiveDamageType = item.customDamageType || equipment?.damageType;
|
||||
const effectiveTraits = item.customTraits?.length ? item.customTraits : equipment?.traits;
|
||||
const effectiveRange = item.customRange || equipment?.range;
|
||||
const effectiveHands = item.customHands || equipment?.hands;
|
||||
const displayName = item.alias || item.customName || item.nameGerman || item.name;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative w-full sm:max-w-lg bg-bg-primary rounded-t-2xl sm:rounded-2xl border border-border max-h-[85vh] flex flex-col animate-in slide-in-from-bottom-4 sm:slide-in-from-bottom-0 sm:zoom-in-95 duration-200">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between p-4 border-b border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
{getCategoryIcon()}
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-bold text-text-primary">
|
||||
{displayName}
|
||||
</h2>
|
||||
{item.alias && (
|
||||
<span className="px-1.5 py-0.5 text-xs rounded bg-primary-500/20 text-primary-400">
|
||||
Alias
|
||||
</span>
|
||||
)}
|
||||
{formatBulk(item.bulk) !== '—' && (
|
||||
<span className="px-1.5 py-0.5 text-xs rounded bg-bg-tertiary text-text-secondary">
|
||||
{formatBulk(item.bulk)} Bulk
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{(item.alias || item.customName) && (
|
||||
<p className="text-sm text-text-secondary">{item.nameGerman || item.name}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" onClick={() => setIsEditing(!isEditing)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={onClose}>
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin h-6 w-6 border-2 border-primary-500 border-t-transparent rounded-full" />
|
||||
</div>
|
||||
) : isEditing ? (
|
||||
/* Edit Mode */
|
||||
<div className="space-y-4">
|
||||
{/* Player: Alias */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">
|
||||
Alias / Spitzname
|
||||
</label>
|
||||
<Input
|
||||
value={alias}
|
||||
onChange={(e) => setAlias(e.target.value)}
|
||||
placeholder="z.B. 'Opa's Schwert'"
|
||||
/>
|
||||
<p className="text-xs text-text-muted mt-1">Dein persönlicher Name für diesen Gegenstand</p>
|
||||
</div>
|
||||
|
||||
{/* GM-only fields */}
|
||||
{isGM && (
|
||||
<>
|
||||
<div className="pt-2 border-t border-border">
|
||||
<p className="text-xs text-primary-400 font-medium mb-3">GM-Anpassungen</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">
|
||||
Custom Name
|
||||
</label>
|
||||
<Input
|
||||
value={customName}
|
||||
onChange={(e) => setCustomName(e.target.value)}
|
||||
placeholder={item.name}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">
|
||||
Schaden
|
||||
</label>
|
||||
<Input
|
||||
value={customDamage}
|
||||
onChange={(e) => setCustomDamage(e.target.value)}
|
||||
placeholder={equipment?.damage || '1d6'}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">
|
||||
Schadenstyp
|
||||
</label>
|
||||
<Input
|
||||
value={customDamageType}
|
||||
onChange={(e) => setCustomDamageType(e.target.value)}
|
||||
placeholder={equipment?.damageType || 'S'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">
|
||||
Reichweite
|
||||
</label>
|
||||
<Input
|
||||
value={customRange}
|
||||
onChange={(e) => setCustomRange(e.target.value)}
|
||||
placeholder={equipment?.range || ''}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">
|
||||
Hände
|
||||
</label>
|
||||
<Input
|
||||
value={customHands}
|
||||
onChange={(e) => setCustomHands(e.target.value)}
|
||||
placeholder={equipment?.hands || '1'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">
|
||||
Traits (kommagetrennt)
|
||||
</label>
|
||||
<Input
|
||||
value={customTraits}
|
||||
onChange={(e) => setCustomTraits(e.target.value)}
|
||||
placeholder={equipment?.traits?.join(', ') || 'Finesse, Agile'}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button variant="outline" onClick={() => setIsEditing(false)} className="flex-1">
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button onClick={handleSaveEdit} className="flex-1">
|
||||
Speichern
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* View Mode */
|
||||
<>
|
||||
{/* Weapon Stats */}
|
||||
{effectiveDamage && (
|
||||
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/20">
|
||||
<p className="text-xs text-red-400 mb-1">Schaden</p>
|
||||
<p className="text-lg font-bold text-red-400">
|
||||
{effectiveDamage} {effectiveDamageType}
|
||||
{item.customDamage && <span className="text-xs ml-2">(angepasst)</span>}
|
||||
</p>
|
||||
{effectiveRange && (
|
||||
<p className="text-sm text-text-secondary mt-1">
|
||||
Reichweite: {effectiveRange}
|
||||
</p>
|
||||
)}
|
||||
{effectiveHands && (
|
||||
<p className="text-sm text-text-secondary">
|
||||
Hände: {effectiveHands}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Armor Stats */}
|
||||
{equipment?.ac && (
|
||||
<div className="p-3 rounded-lg bg-blue-500/10 border border-blue-500/20">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<p className="text-xs text-blue-400 mb-1">RK-Bonus</p>
|
||||
<p className="text-lg font-bold text-blue-400">+{equipment.ac}</p>
|
||||
</div>
|
||||
{equipment.dexCap !== null && equipment.dexCap !== undefined && (
|
||||
<div>
|
||||
<p className="text-xs text-blue-400 mb-1">GES-Limit</p>
|
||||
<p className="text-lg font-bold text-blue-400">+{equipment.dexCap}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(equipment.checkPenalty || equipment.speedPenalty) && (
|
||||
<div className="mt-2 text-sm text-text-secondary">
|
||||
{equipment.checkPenalty && <span>Prüfungsmalus: {equipment.checkPenalty} </span>}
|
||||
{equipment.speedPenalty && <span>Tempo: -{equipment.speedPenalty} ft</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Traits */}
|
||||
{effectiveTraits && effectiveTraits.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs text-text-secondary mb-2">
|
||||
Merkmale {item.customTraits?.length ? '(angepasst)' : ''}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{effectiveTraits.map((trait) => (
|
||||
<span
|
||||
key={trait}
|
||||
className="px-2 py-0.5 rounded text-xs bg-bg-tertiary text-text-secondary"
|
||||
>
|
||||
{trait}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary/Description */}
|
||||
{(equipment?.summaryGerman || equipment?.summary) && (
|
||||
<div className="p-3 rounded-lg bg-bg-secondary">
|
||||
<p className="text-xs text-text-secondary mb-1">Beschreibung</p>
|
||||
<p className="text-sm text-text-primary">
|
||||
{equipment.summaryGerman || equipment.summary}
|
||||
</p>
|
||||
{equipment.summaryGerman && equipment.summary && (
|
||||
<p className="text-xs text-text-muted mt-2 italic">
|
||||
Original: {equipment.summary}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quantity Control */}
|
||||
{!['Weapons', 'Armor', 'Shields'].includes(equipment?.itemCategory || item.equipment?.itemCategory || '') && (
|
||||
<div className="p-3 rounded-lg bg-bg-secondary">
|
||||
<p className="text-xs text-text-secondary mb-2">Anzahl</p>
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="h-10 w-10"
|
||||
onClick={() => handleQuantityChange(-1)}
|
||||
disabled={item.quantity <= 1}
|
||||
>
|
||||
<Minus className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-2xl font-bold text-text-primary min-w-[60px] text-center">
|
||||
{item.quantity}
|
||||
</span>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="h-10 w-10"
|
||||
onClick={() => handleQuantityChange(1)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
{item.notes && (
|
||||
<div className="p-3 rounded-lg bg-bg-secondary">
|
||||
<p className="text-xs text-text-secondary mb-1">Notizen</p>
|
||||
<p className="text-sm text-text-primary">{item.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer Actions */}
|
||||
{!isEditing && (
|
||||
<div className="p-4 border-t border-border space-y-2">
|
||||
{['Weapons', 'Armor', 'Shields', 'Worn Items'].includes(equipment?.itemCategory || item.equipment?.itemCategory || '') && (
|
||||
<Button
|
||||
className="w-full"
|
||||
variant={item.equipped ? 'outline' : 'default'}
|
||||
onClick={() => onToggleEquipped(!item.equipped)}
|
||||
>
|
||||
{item.equipped ? 'Ablegen' : 'Anlegen'}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
onRemove();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Entfernen
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -95,6 +95,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -117,6 +128,268 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% {
|
||||
filter: drop-shadow(0 0 20px rgba(194, 109, 188, 0.4));
|
||||
}
|
||||
50% {
|
||||
filter: drop-shadow(0 0 40px rgba(194, 109, 188, 0.7));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes gradient-rotate {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes twinkle {
|
||||
0%, 100% {
|
||||
opacity: 0.2;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes drift {
|
||||
0% {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
25% {
|
||||
transform: translate(10px, -10px);
|
||||
}
|
||||
50% {
|
||||
transform: translate(20px, 0);
|
||||
}
|
||||
75% {
|
||||
transform: translate(10px, 10px);
|
||||
}
|
||||
100% {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Auth Page Styles */
|
||||
.auth-background {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
background: radial-gradient(ellipse at bottom, #1a1a2e 0%, #0f0f12 100%);
|
||||
}
|
||||
|
||||
.auth-stars {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.auth-star {
|
||||
position: absolute;
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
animation: twinkle var(--duration, 3s) ease-in-out infinite;
|
||||
animation-delay: var(--delay, 0s);
|
||||
}
|
||||
|
||||
.auth-orb {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(60px);
|
||||
opacity: 0.3;
|
||||
animation: drift 20s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.auth-orb-1 {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
background: var(--color-primary-500);
|
||||
top: -100px;
|
||||
right: -100px;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.auth-orb-2 {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
background: var(--color-secondary-500);
|
||||
bottom: -50px;
|
||||
left: -50px;
|
||||
animation-delay: -10s;
|
||||
}
|
||||
|
||||
.auth-orb-3 {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
background: var(--color-primary-400);
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
animation-delay: -5s;
|
||||
}
|
||||
|
||||
.auth-logo {
|
||||
animation: float 4s ease-in-out infinite, pulse-glow 3s ease-in-out infinite;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
@keyframes logo-burst {
|
||||
0% {
|
||||
filter: drop-shadow(0 0 20px rgba(194, 109, 188, 0.4));
|
||||
transform: scale(1);
|
||||
}
|
||||
30% {
|
||||
filter: drop-shadow(0 0 60px rgba(194, 109, 188, 1)) drop-shadow(0 0 100px rgba(194, 109, 188, 0.8));
|
||||
transform: scale(1.08);
|
||||
}
|
||||
100% {
|
||||
filter: drop-shadow(0 0 20px rgba(194, 109, 188, 0.4));
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.auth-logo-burst {
|
||||
animation: logo-burst 0.6s ease-out forwards, float 4s ease-in-out infinite !important;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
position: relative;
|
||||
background: rgba(26, 26, 31, 0.8);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(194, 109, 188, 0.2);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.auth-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -2px;
|
||||
background: conic-gradient(
|
||||
from 0deg,
|
||||
transparent,
|
||||
var(--color-primary-500),
|
||||
transparent 30%
|
||||
);
|
||||
animation: gradient-rotate 4s linear infinite;
|
||||
z-index: -1;
|
||||
border-radius: inherit;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.auth-card:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.auth-card::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 1px;
|
||||
background: rgba(26, 26, 31, 0.95);
|
||||
border-radius: inherit;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.auth-content {
|
||||
animation: slide-up 0.5s ease-out;
|
||||
}
|
||||
|
||||
.auth-input-glow:focus-within {
|
||||
box-shadow: 0 0 20px rgba(194, 109, 188, 0.2);
|
||||
}
|
||||
|
||||
/* Logo Text Animations */
|
||||
@keyframes letter-reveal {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes text-glow {
|
||||
0%, 100% {
|
||||
text-shadow:
|
||||
0 0 10px rgba(194, 109, 188, 0.3),
|
||||
0 0 20px rgba(194, 109, 188, 0.2);
|
||||
}
|
||||
50% {
|
||||
text-shadow:
|
||||
0 0 20px rgba(194, 109, 188, 0.5),
|
||||
0 0 40px rgba(194, 109, 188, 0.3),
|
||||
0 0 60px rgba(194, 109, 188, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes number-glow {
|
||||
0%, 100% {
|
||||
text-shadow:
|
||||
0 0 10px rgba(194, 109, 188, 0.5),
|
||||
0 0 20px rgba(194, 109, 188, 0.3),
|
||||
0 0 30px rgba(194, 109, 188, 0.2);
|
||||
}
|
||||
50% {
|
||||
text-shadow:
|
||||
0 0 20px rgba(194, 109, 188, 0.8),
|
||||
0 0 40px rgba(194, 109, 188, 0.5),
|
||||
0 0 60px rgba(194, 109, 188, 0.3),
|
||||
0 0 80px rgba(194, 109, 188, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.auth-title-letter {
|
||||
display: inline-block;
|
||||
opacity: 0;
|
||||
color: white;
|
||||
animation: letter-reveal 0.5s ease-out forwards;
|
||||
}
|
||||
|
||||
.auth-title-dimension {
|
||||
animation: text-glow 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.auth-title-47 {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-weight: 700;
|
||||
color: #c26dbc;
|
||||
animation: number-glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Enter Button Animation */
|
||||
@keyframes button-pulse {
|
||||
0%, 100% {
|
||||
box-shadow:
|
||||
0 0 20px rgba(194, 109, 188, 0.4),
|
||||
0 10px 40px rgba(194, 109, 188, 0.2);
|
||||
}
|
||||
50% {
|
||||
box-shadow:
|
||||
0 0 30px rgba(194, 109, 188, 0.6),
|
||||
0 10px 60px rgba(194, 109, 188, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
.auth-enter-button {
|
||||
animation: button-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Base Styles */
|
||||
html {
|
||||
background-color: var(--color-bg-primary);
|
||||
|
||||
74
client/src/shared/components/ui/action-icon.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { cn } from '@/shared/lib/utils';
|
||||
|
||||
type ActionCost = number | 'reaction' | 'free' | 'varies' | null;
|
||||
|
||||
interface ActionIconProps {
|
||||
actions: ActionCost;
|
||||
className?: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'h-4',
|
||||
md: 'h-5',
|
||||
lg: 'h-6',
|
||||
};
|
||||
|
||||
export function ActionIcon({ actions, className, size = 'md' }: ActionIconProps) {
|
||||
const sizeClass = sizeClasses[size];
|
||||
const iconClass = cn('inline-block brightness-0 invert', sizeClass, className);
|
||||
|
||||
if (actions === null || actions === 'varies') {
|
||||
return (
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-text-muted/20 text-text-muted">
|
||||
{actions === 'varies' ? 'Variabel' : '—'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (actions === 'reaction') {
|
||||
return <img src="/icons/action_reaction_black.png" alt="Reaktion" className={iconClass} />;
|
||||
}
|
||||
|
||||
if (actions === 'free') {
|
||||
return <img src="/icons/action_free_black.png" alt="Freie Aktion" className={iconClass} />;
|
||||
}
|
||||
|
||||
if (actions === 1) {
|
||||
return <img src="/icons/action_single_black.png" alt="1 Aktion" className={iconClass} />;
|
||||
}
|
||||
|
||||
if (actions === 2) {
|
||||
return <img src="/icons/action_double_black.png" alt="2 Aktionen" className={iconClass} />;
|
||||
}
|
||||
|
||||
if (actions === 3) {
|
||||
return <img src="/icons/action_triple_black.png" alt="3 Aktionen" className={iconClass} />;
|
||||
}
|
||||
|
||||
// Fallback for any other number
|
||||
return (
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-primary-500/20 text-primary-400">
|
||||
{actions} Akt.
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ActionTypeBadge({ type }: { type: string }) {
|
||||
const typeConfig: Record<string, { label: string; className: string }> = {
|
||||
action: { label: 'Aktion', className: 'bg-primary-500/20 text-primary-400' },
|
||||
reaction: { label: 'Reaktion', className: 'bg-yellow-500/20 text-yellow-400' },
|
||||
free: { label: 'Frei', className: 'bg-green-500/20 text-green-400' },
|
||||
exploration: { label: 'Erkundung', className: 'bg-blue-500/20 text-blue-400' },
|
||||
downtime: { label: 'Auszeit', className: 'bg-purple-500/20 text-purple-400' },
|
||||
varies: { label: 'Variabel', className: 'bg-text-muted/20 text-text-muted' },
|
||||
};
|
||||
|
||||
const config = typeConfig[type] || { label: type, className: 'bg-text-muted/20 text-text-muted' };
|
||||
|
||||
return (
|
||||
<span className={cn('text-xs px-2 py-0.5 rounded', config.className)}>
|
||||
{config.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
145
client/src/shared/hooks/use-character-socket.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
import { api } from '@/shared/lib/api';
|
||||
import type { Character, CharacterItem, CharacterCondition } from '@/shared/types';
|
||||
|
||||
const SOCKET_URL = import.meta.env.VITE_API_URL?.replace('/api', '') || 'http://localhost:3001';
|
||||
|
||||
export type CharacterUpdateType = 'hp' | 'conditions' | 'item' | 'inventory' | 'money' | 'level' | 'equipment_status';
|
||||
|
||||
export interface CharacterUpdate {
|
||||
characterId: string;
|
||||
type: CharacterUpdateType;
|
||||
data: any;
|
||||
}
|
||||
|
||||
interface UseCharacterSocketOptions {
|
||||
characterId: string;
|
||||
onHpUpdate?: (data: { hpCurrent: number; hpTemp: number; hpMax: number }) => void;
|
||||
onConditionsUpdate?: (data: { action: 'add' | 'update' | 'remove'; condition?: CharacterCondition; conditionId?: string }) => void;
|
||||
onInventoryUpdate?: (data: { action: 'add' | 'remove' | 'update'; item?: CharacterItem; itemId?: string }) => void;
|
||||
onEquipmentStatusUpdate?: (data: { action: 'update'; item: CharacterItem }) => void;
|
||||
onMoneyUpdate?: (data: { credits: number }) => void;
|
||||
onLevelUpdate?: (data: { level: number }) => void;
|
||||
onFullUpdate?: (character: Character) => void;
|
||||
}
|
||||
|
||||
export function useCharacterSocket({
|
||||
characterId,
|
||||
onHpUpdate,
|
||||
onConditionsUpdate,
|
||||
onInventoryUpdate,
|
||||
onEquipmentStatusUpdate,
|
||||
onMoneyUpdate,
|
||||
onLevelUpdate,
|
||||
onFullUpdate,
|
||||
}: UseCharacterSocketOptions) {
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
const token = api.getToken();
|
||||
if (!token || !characterId) return;
|
||||
|
||||
// Disconnect existing socket if any
|
||||
if (socketRef.current?.connected) {
|
||||
socketRef.current.disconnect();
|
||||
}
|
||||
|
||||
const socket = io(`${SOCKET_URL}/characters`, {
|
||||
auth: { token },
|
||||
transports: ['websocket', 'polling'],
|
||||
reconnection: true,
|
||||
reconnectionAttempts: 5,
|
||||
reconnectionDelay: 1000,
|
||||
});
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('[WebSocket] Connected to character namespace');
|
||||
// Join the character room
|
||||
socket.emit('join_character', { characterId }, (response: { success: boolean; error?: string }) => {
|
||||
if (response.success) {
|
||||
console.log(`[WebSocket] Joined character room: ${characterId}`);
|
||||
} else {
|
||||
console.error(`[WebSocket] Failed to join character room: ${response.error}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('disconnect', (reason) => {
|
||||
console.log(`[WebSocket] Disconnected: ${reason}`);
|
||||
});
|
||||
|
||||
socket.on('connect_error', (error) => {
|
||||
console.error('[WebSocket] Connection error:', error.message);
|
||||
});
|
||||
|
||||
// Handle character updates
|
||||
socket.on('character_update', (update: CharacterUpdate) => {
|
||||
console.log(`[WebSocket] Received update: ${update.type}`, update.data);
|
||||
|
||||
switch (update.type) {
|
||||
case 'hp':
|
||||
onHpUpdate?.(update.data);
|
||||
break;
|
||||
case 'conditions':
|
||||
onConditionsUpdate?.(update.data);
|
||||
break;
|
||||
case 'inventory':
|
||||
onInventoryUpdate?.(update.data);
|
||||
break;
|
||||
case 'equipment_status':
|
||||
onEquipmentStatusUpdate?.(update.data);
|
||||
break;
|
||||
case 'money':
|
||||
onMoneyUpdate?.(update.data);
|
||||
break;
|
||||
case 'level':
|
||||
onLevelUpdate?.(update.data);
|
||||
break;
|
||||
case 'item':
|
||||
// Item update that's not equipment status (e.g., quantity, notes)
|
||||
onInventoryUpdate?.(update.data);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// Handle full character refresh (e.g., after reconnect)
|
||||
socket.on('character_refresh', (character: Character) => {
|
||||
console.log('[WebSocket] Received full character refresh');
|
||||
onFullUpdate?.(character);
|
||||
});
|
||||
|
||||
socketRef.current = socket;
|
||||
|
||||
return socket;
|
||||
}, [characterId, onHpUpdate, onConditionsUpdate, onInventoryUpdate, onEquipmentStatusUpdate, onMoneyUpdate, onLevelUpdate, onFullUpdate]);
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
if (socketRef.current) {
|
||||
// Leave the character room before disconnecting
|
||||
socketRef.current.emit('leave_character', { characterId });
|
||||
socketRef.current.disconnect();
|
||||
socketRef.current = null;
|
||||
}
|
||||
}, [characterId]);
|
||||
|
||||
useEffect(() => {
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
disconnect();
|
||||
};
|
||||
}, [connect, disconnect]);
|
||||
|
||||
return {
|
||||
socket: socketRef.current,
|
||||
isConnected: socketRef.current?.connected ?? false,
|
||||
reconnect: connect,
|
||||
};
|
||||
}
|
||||
@@ -15,8 +15,8 @@ class ApiClient {
|
||||
},
|
||||
});
|
||||
|
||||
// Load token from localStorage
|
||||
this.token = localStorage.getItem('auth_token');
|
||||
// Load token from localStorage (persistent) or sessionStorage (session-only)
|
||||
this.token = localStorage.getItem('auth_token') || sessionStorage.getItem('auth_token');
|
||||
|
||||
// Request interceptor to add auth header
|
||||
this.client.interceptors.request.use((config) => {
|
||||
@@ -43,14 +43,21 @@ class ApiClient {
|
||||
);
|
||||
}
|
||||
|
||||
setToken(token: string) {
|
||||
setToken(token: string, remember: boolean = true) {
|
||||
this.token = token;
|
||||
localStorage.setItem('auth_token', token);
|
||||
if (remember) {
|
||||
localStorage.setItem('auth_token', token);
|
||||
sessionStorage.removeItem('auth_token');
|
||||
} else {
|
||||
sessionStorage.setItem('auth_token', token);
|
||||
localStorage.removeItem('auth_token');
|
||||
}
|
||||
}
|
||||
|
||||
clearToken() {
|
||||
this.token = null;
|
||||
localStorage.removeItem('auth_token');
|
||||
sessionStorage.removeItem('auth_token');
|
||||
}
|
||||
|
||||
getToken() {
|
||||
@@ -164,6 +171,10 @@ class ApiClient {
|
||||
hpCurrent: number;
|
||||
hpMax: number;
|
||||
hpTemp: number;
|
||||
ancestryId: string;
|
||||
heritageId: string;
|
||||
classId: string;
|
||||
backgroundId: string;
|
||||
}>) {
|
||||
const response = await this.client.put(`/campaigns/${campaignId}/characters/${characterId}`, data);
|
||||
return response.data;
|
||||
@@ -179,6 +190,11 @@ class ApiClient {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async updateCharacterCredits(campaignId: string, characterId: string, credits: number) {
|
||||
const response = await this.client.patch(`/campaigns/${campaignId}/characters/${characterId}/credits`, { credits });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// Conditions
|
||||
async addCharacterCondition(campaignId: string, characterId: string, data: {
|
||||
name: string;
|
||||
@@ -212,10 +228,19 @@ class ApiClient {
|
||||
}
|
||||
|
||||
async updateCharacterItem(campaignId: string, characterId: string, itemId: string, data: {
|
||||
// Player-editable fields
|
||||
quantity?: number;
|
||||
equipped?: boolean;
|
||||
invested?: boolean;
|
||||
notes?: string;
|
||||
alias?: string;
|
||||
// GM-only fields
|
||||
customName?: string;
|
||||
customDamage?: string;
|
||||
customDamageType?: string;
|
||||
customTraits?: string[];
|
||||
customRange?: string;
|
||||
customHands?: string;
|
||||
}) {
|
||||
const response = await this.client.patch(`/campaigns/${campaignId}/characters/${characterId}/items/${itemId}`, data);
|
||||
return response.data;
|
||||
@@ -226,6 +251,23 @@ class ApiClient {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// Character Feats
|
||||
async addCharacterFeat(campaignId: string, characterId: string, data: {
|
||||
featId?: string;
|
||||
name: string;
|
||||
nameGerman?: string;
|
||||
level: number;
|
||||
source: 'CLASS' | 'ANCESTRY' | 'GENERAL' | 'SKILL' | 'BONUS' | 'ARCHETYPE';
|
||||
}) {
|
||||
const response = await this.client.post(`/campaigns/${campaignId}/characters/${characterId}/feats`, data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async removeCharacterFeat(campaignId: string, characterId: string, featId: string) {
|
||||
const response = await this.client.delete(`/campaigns/${campaignId}/characters/${characterId}/feats/${featId}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// Equipment Database (Browse/Search)
|
||||
async searchEquipment(params: {
|
||||
query?: string;
|
||||
@@ -270,6 +312,67 @@ class ApiClient {
|
||||
const response = await this.client.get(`/equipment/by-name/${encodeURIComponent(name)}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// Feats Database (Browse/Search)
|
||||
async searchFeats(params: {
|
||||
query?: string;
|
||||
featType?: string;
|
||||
className?: string;
|
||||
ancestryName?: string;
|
||||
skillName?: string;
|
||||
minLevel?: number;
|
||||
maxLevel?: number;
|
||||
rarity?: string;
|
||||
traits?: string[];
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}) {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params.query) queryParams.set('query', params.query);
|
||||
if (params.featType) queryParams.set('featType', params.featType);
|
||||
if (params.className) queryParams.set('className', params.className);
|
||||
if (params.ancestryName) queryParams.set('ancestryName', params.ancestryName);
|
||||
if (params.skillName) queryParams.set('skillName', params.skillName);
|
||||
if (params.minLevel !== undefined) queryParams.set('minLevel', params.minLevel.toString());
|
||||
if (params.maxLevel !== undefined) queryParams.set('maxLevel', params.maxLevel.toString());
|
||||
if (params.rarity) queryParams.set('rarity', params.rarity);
|
||||
if (params.traits?.length) queryParams.set('traits', params.traits.join(','));
|
||||
if (params.page) queryParams.set('page', params.page.toString());
|
||||
if (params.limit) queryParams.set('limit', params.limit.toString());
|
||||
|
||||
const response = await this.client.get(`/feats?${queryParams.toString()}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getFeatTypes() {
|
||||
const response = await this.client.get('/feats/types');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getFeatClasses() {
|
||||
const response = await this.client.get('/feats/classes');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getFeatAncestries() {
|
||||
const response = await this.client.get('/feats/ancestries');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getFeatTraits() {
|
||||
const response = await this.client.get('/feats/traits');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getFeatById(id: string) {
|
||||
const response = await this.client.get(`/feats/${id}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getFeatByName(name: string) {
|
||||
const response = await this.client.get(`/feats/by-name/${encodeURIComponent(name)}`);
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
export const api = new ApiClient();
|
||||
|
||||
@@ -67,6 +67,7 @@ export interface Character extends CharacterSummary {
|
||||
classId?: string;
|
||||
backgroundId?: string;
|
||||
experiencePoints: number;
|
||||
credits: number; // Ironvale currency (1 Gold = 100, 1 Silver = 10, 1 Copper = 1)
|
||||
pathbuilderData?: unknown;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -126,6 +127,17 @@ export interface CharacterItem {
|
||||
invested: boolean;
|
||||
containerId?: string;
|
||||
notes?: string;
|
||||
// Player-editable alias
|
||||
alias?: string;
|
||||
// GM-editable custom overrides
|
||||
customName?: string;
|
||||
customDamage?: string;
|
||||
customDamageType?: string;
|
||||
customTraits?: string[];
|
||||
customRange?: string;
|
||||
customHands?: string;
|
||||
// Equipment-Details (geladen via Relation)
|
||||
equipment?: Equipment;
|
||||
}
|
||||
|
||||
export interface CharacterCondition {
|
||||
@@ -242,6 +254,9 @@ export interface Equipment {
|
||||
summary?: string;
|
||||
level?: number;
|
||||
price?: number;
|
||||
// Translated fields (from Translation cache)
|
||||
nameGerman?: string;
|
||||
summaryGerman?: string;
|
||||
// Weapon fields
|
||||
hands?: string;
|
||||
damage?: string;
|
||||
@@ -277,6 +292,42 @@ export interface EquipmentSearchResult {
|
||||
categories: string[];
|
||||
}
|
||||
|
||||
// Feat Database Types
|
||||
export interface Feat {
|
||||
id: string;
|
||||
name: string;
|
||||
traits: string[];
|
||||
summary?: string;
|
||||
description?: string;
|
||||
actions?: string; // "1", "2", "3", "free", "reaction", null for passive
|
||||
url?: string;
|
||||
level?: number;
|
||||
sourceBook?: string;
|
||||
// Feat classification
|
||||
featType?: string; // "General", "Skill", "Class", "Ancestry", "Archetype", "Heritage"
|
||||
rarity?: string; // "Common", "Uncommon", "Rare", "Unique"
|
||||
// Prerequisites
|
||||
prerequisites?: string;
|
||||
// For class/archetype feats
|
||||
className?: string;
|
||||
archetypeName?: string;
|
||||
// For ancestry feats
|
||||
ancestryName?: string;
|
||||
// For skill feats
|
||||
skillName?: string;
|
||||
// Cached German translation
|
||||
nameGerman?: string;
|
||||
summaryGerman?: string;
|
||||
}
|
||||
|
||||
export interface FeatSearchResult {
|
||||
items: Feat[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
// API Response Types
|
||||
export interface ApiError {
|
||||
statusCode: number;
|
||||
|
||||