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>
This commit is contained in:
Alexander Zielonka
2026-01-19 15:36:29 +01:00
parent e60a8df4f0
commit 55419d3896
53 changed files with 70462 additions and 475 deletions

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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,

View 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>
);
}

View 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"
>
&larr; 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"
>
&larr; 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>
);
}

View File

@@ -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>
);

View File

@@ -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}
/>
)}
</>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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);

View 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>
);
}

View 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,
};
}

View File

@@ -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();

View File

@@ -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;