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

View File

@@ -48,12 +48,18 @@ dimension47/
│ ├── src/
│ │ ├── app/ # App-Config, Router
│ │ ├── features/ # Feature-Module (auth, campaigns, characters, etc.)
│ │ │ ├── auth/components/
│ │ │ │ ├── login-page.tsx # Login mit Animationen
│ │ │ │ └── register-page.tsx # Registrierung
│ │ │ └── characters/components/
│ │ │ ├── character-sheet-page.tsx # Hauptseite mit Tabs
│ │ │ ├── hp-control.tsx # HP-Management Komponente
│ │ │ ├── add-condition-modal.tsx # Zustand hinzufügen
│ │ │ ── add-item-modal.tsx # Item aus DB hinzufügen
│ │ │ ── add-item-modal.tsx # Item aus DB hinzufügen
│ │ │ └── actions-tab.tsx # Aktionen-Tab
│ │ ├── shared/ # Geteilte Komponenten, Hooks, Types
│ │ │ └── hooks/
│ │ │ └── use-character-socket.ts # WebSocket Hook für Echtzeit-Sync
│ │ └── assets/
│ └── public/ # Statische Dateien, JSON-Datenbanken
@@ -63,7 +69,8 @@ dimension47/
│ │ ├── auth/ # Authentifizierung
│ │ ├── campaigns/ # Kampagnenverwaltung
│ │ ├── characters/# Charakterverwaltung
│ │ └── equipment/ # Equipment-Datenbank (NEU)
│ │ │ └── characters.gateway.ts # WebSocket Gateway
│ │ └── equipment/ # Equipment-Datenbank
│ ├── common/ # Shared Utilities
│ └── prisma/ # Prisma Service
└── prisma/
@@ -80,6 +87,13 @@ dimension47/
- JWT-basierte Authentifizierung
- Login/Register/Logout
- Rollen: ADMIN, GM, PLAYER
- **"Anmeldung speichern"** Funktion (localStorage vs sessionStorage)
- **Animierter Login-Screen**:
- Zweistufiger Flow: Splash → Formular
- Animierter Sternenhintergrund mit schwebenden Orbs
- "DIMENSION 47" Titel mit Buchstaben-Animation und Glow-Effekt
- Logo mit Tap-Glow-Effekt
- Gestaffelte Formular-Animationen (Komponente für Komponente)
### Kampagnen
- CRUD für Kampagnen
@@ -92,9 +106,35 @@ dimension47/
- Zustände (Conditions) mit PF2e-Datenbank
- Fertigkeiten mit deutschen Namen
- Rettungswürfe
- Inventar-System mit vollständiger Equipment-Datenbank (5.482 Items)
- Bulk-Tracking mit Belastungs-Anzeige
- Item-Suche mit Kategoriefilter und Pagination
- **Inventar-System (komplett)**:
- Vollständige Equipment-Datenbank (5.482 Items)
- Item-Suche mit Kategoriefilter und Pagination
- Bulk-Tracking mit Belastungs-Anzeige
- Ausrüstungsstatus (angelegt/abgelegt/investiert)
- Quantity-Management
- Item-Details mit Eigenschaften, Schaden, etc.
- Benutzerdefinierte Notizen pro Item
- **Talente-Tab (komplett)**:
- Alle Charaktertalente aus Pathbuilder-Import
- Kategorisiert nach Quelle (Klasse, Abstammung, Allgemein, Fertigkeit, Bonus, Archetyp)
- Einklappbare Kategorien
- Talentdetails mit Beschreibung und Voraussetzungen
- **Aktionen-Tab** mit PF2e-Aktionsdatenbank (99 Aktionen)
- Kategorien: Allgemein, Kampf, Bewegung, Interaktion, Spezial
- Einklappbare Kategorien (standardmäßig eingeklappt)
- Aktionstyp-Icons (Aktion, Reaktion, Freie Aktion, etc.)
- Suchfunktion
- **WebSocket Real-Time Sync** für:
- HP (aktuell, temporär, max)
- Zustände (hinzufügen, entfernen, aktualisieren)
- Inventar (Items hinzufügen, entfernen, aktualisieren)
- Ausrüstungsstatus (angelegt/abgelegt)
- Geld (Credits)
- Level
### Noch zu implementieren (Character Screen)
- **Alchemie-Tab**: Alchemistische Formeln und Rezepte
- **Level-Up System**: Stufenaufstieg mit Attributs-, Talent- und Fertigkeitenwahl
### Equipment-Datenbank
- **5.482 Items** aus Pathfinder 2e importiert

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;

View File

@@ -15,7 +15,7 @@ NODE_ENV=development
CORS_ORIGINS="http://localhost:3000,http://localhost:5173"
# Claude API (for translations)
CLAUDE_API_KEY=""
ANTHROPIC_API_KEY=""
# File Upload
UPLOAD_DIR="./uploads"

View File

@@ -2,6 +2,7 @@
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"entryFile": "src/main",
"compilerOptions": {
"deleteOutDir": true
}

View File

@@ -15,11 +15,12 @@
"db:studio": "prisma studio",
"db:seed": "tsx prisma/seed.ts",
"db:seed:equipment": "tsx prisma/seed-equipment.ts",
"db:seed:feats": "tsx prisma/seed-feats.ts",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"start:prod": "node dist/src/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

50177
server/prisma/data/feats.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,602 @@
[
{
"name": "Buckler",
"trait": "",
"item_category": "Shields",
"item_subcategory": "Base Shields",
"bulk": "L",
"url": "/Shields.aspx?ID=17",
"summary": "This very small shield is a favorite of duelists and quick, lightly armored warriors. It's typically made of steel and strapped to your forearm. You can Raise a Shield with your buckler as long as you have that hand free or are holding a light object that's not a weapon in that hand.",
"ac": "1",
"hp": "6 (3)",
"hardness": "3"
},
{
"name": "Wooden Shield",
"trait": "",
"item_category": "Shields",
"item_subcategory": "Base Shields",
"bulk": "1",
"url": "/Shields.aspx?ID=18",
"summary": "Though they come in a variety of shapes and sizes, the protection offered by wooden shields comes from the stoutness of their materials. While wooden shields are less expensive than steel shields, they break more easily.",
"ac": "2",
"hp": "12 (6)",
"hardness": "3"
},
{
"name": "Caster's Targe",
"trait": "Inscribed",
"item_category": "Shields",
"item_subcategory": "Base Shields",
"bulk": "1",
"url": "/Shields.aspx?ID=5",
"summary": "This small shield is made from wood. It features a special panel of parchment along the inside surface that allows for writing.",
"ac": "1",
"hp": "12 (6)",
"hardness": "3"
},
{
"name": "Hide Shield",
"trait": "Deflecting Bludgeoning",
"item_category": "Shields",
"item_subcategory": "Base Shields",
"bulk": "1",
"url": "/Shields.aspx?ID=11",
"summary": "Hide shields come in a variety of shapes and sizes. Specialized tanning techniques combined with tough hides from creatures such as griffons result in these particularly tough shields. The hardened hide of the shield still has enough flexibility to diminish the impact of battering and pummeling attacks.",
"ac": "2",
"hp": "20 (10)",
"hardness": "4"
},
{
"name": "Steel Shield",
"trait": "",
"item_category": "Shields",
"item_subcategory": "Base Shields",
"bulk": "1",
"url": "/Shields.aspx?ID=19",
"summary": "Like wooden shields, steel shields come in a variety of shapes and sizes. Though more expensive than wooden shields, they are much more durable.",
"ac": "2",
"hp": "20 (10)",
"hardness": "5"
},
{
"name": "Klar",
"trait": "Integrated 1d6 S (Versatile P)",
"item_category": "Shields",
"item_subcategory": "Base Shields",
"bulk": "1",
"url": "/Shields.aspx?ID=12",
"summary": "This traditional Shoanti armament combines a short metal blade with the skull of a large horned lizard, fashioned as a shield. The lightweight shield allows for quick attacks with its integrated blade.",
"ac": "1",
"hp": "10 (5)",
"hardness": "3"
},
{
"name": "Heavy Rondache",
"trait": "",
"item_category": "Shields",
"item_subcategory": "Base Shields",
"bulk": "1",
"url": "/Shields.aspx?ID=10",
"summary": "Similar in size to a buckler, this steel shield is intended to absorb as many blows as possible instead of deflecting attacks. It features multiple layers of metal and is reinforced with additional wood.",
"ac": "1",
"hp": "24 (12)",
"hardness": "5"
},
{
"name": "Meteor Shield",
"trait": "Shield Throw 30 ft.",
"item_category": "Shields",
"item_subcategory": "Base Shields",
"bulk": "1",
"url": "/Shields.aspx?ID=13",
"summary": "Meteor shields are specifically designed with throwing in mind. A meteor shield is made from thin steel and has quick-release straps, allowing for easy, long-distance throws.",
"ac": "2",
"hp": "16 (8)",
"hardness": "4"
},
{
"name": "Gauntlet Buckler",
"trait": "Foldaway",
"item_category": "Shields",
"item_subcategory": "Base Shields",
"bulk": "1",
"url": "/Shields.aspx?ID=8",
"summary": "This buckler-sized shield is segmented, allowing it to collapse into a housing bound to a gauntlet for easy storage. A small catch enables you to expand the shield quickly in battle when you're in need of defense.",
"ac": "1",
"hp": "6 (3)",
"hardness": "3"
},
{
"name": "Harnessed Shield",
"trait": "Harnessed",
"item_category": "Shields",
"item_subcategory": "Base Shields",
"bulk": "2",
"url": "/Shields.aspx?ID=9",
"summary": "This large steel shield features a specialized opening to hold lances and similar weapons. Harnessed shields are a common backup for those who fight with jousting weapons in case they're forced into combat without their mounts. Balancing the weapon within the shield's hold is somewhat awkward, and longer weapons, like lances, need to be held closer to the body than usual for proper support.",
"ac": "2",
"hp": "20 (10)",
"hardness": "5"
},
{
"name": "Razor Disc",
"trait": "Integrated 1d6 S, Shield Throw 20 ft.",
"item_category": "Shields",
"item_subcategory": "Base Shields",
"bulk": "1",
"url": "/Shields.aspx?ID=14",
"summary": "Several small blades line the outside edge of this steel shield. This specialized throwing shield is common among warriors in the Mwangi Expanse, where its blades can cut down foliage as it flies.",
"ac": "1",
"hp": "16 (8)",
"hardness": "4"
},
{
"name": "Salvo Shield",
"trait": "Deflecting Physical Ranged",
"item_category": "Shields",
"item_subcategory": "Base Shields",
"bulk": "1",
"url": "/Shields.aspx?ID=15",
"summary": "This specialized steel shield features an outer layer of angled wooden or steel plates, which help deflect or redirect incoming ranged projectiles but don't offer any additional protection against melee weapons.",
"ac": "2",
"hp": "20 (10)",
"hardness": "4"
},
{
"name": "Swordstealer Shield",
"trait": "Deflecting Slashing",
"item_category": "Shields",
"item_subcategory": "Base Shields",
"bulk": "1",
"url": "/Shields.aspx?ID=16",
"summary": "This specialized steel shield features several wide metal hooks along its surface. These hooks help catch swords and other blades, reducing the impact of their incoming attacks.",
"ac": "2",
"hp": "20 (10)",
"hardness": "4"
},
{
"name": "Dart Shield",
"trait": "Launching Dart",
"item_category": "Shields",
"item_subcategory": "Base Shields",
"bulk": "1",
"url": "/Shields.aspx?ID=6",
"summary": "This wooden shield features a spring-loaded device on its surface that can fire darts with powerful force. A small mechanism within the shield allows you to fire a dart even while actively holding the shield or blocking with it.",
"ac": "1",
"hp": "12 (6)",
"hardness": "3"
},
{
"name": "Tower Shield",
"trait": "",
"item_category": "Shields",
"item_subcategory": "Base Shields",
"bulk": "4",
"url": "/Shields.aspx?ID=20",
"summary": "These massive shields can be used to provide cover to nearly the entire body. Due to their size, they are typically made of wood reinforced with metal.",
"ac": "2",
"hp": "20 (10)",
"hardness": "5"
},
{
"name": "Fortress Shield",
"trait": "Hefty +2",
"item_category": "Shields",
"item_subcategory": "Base Shields",
"bulk": "5",
"url": "/Shields.aspx?ID=7",
"summary": "Also known as portable walls, these thick and heavy shields are slightly larger than tower shields. Like tower shields, they're typically made from wood reinforced with metal, but many are made from larger amounts of metal or even stone.",
"ac": "3",
"hp": "24 (12)",
"hardness": "6"
},
{
"name": "Cold Iron Buckler (Low-Grade)",
"trait": "",
"item_category": "Shields",
"item_subcategory": "Precious Material Shields",
"bulk": "L",
"url": "/Equipment.aspx?ID=2813",
"summary": "The shield has Hardness 3, HP 12, and BT 6.",
"ac": "",
"hp": "",
"hardness": ""
},
{
"name": "Silver Buckler (Low-Grade)",
"trait": "",
"item_category": "Shields",
"item_subcategory": "Precious Material Shields",
"bulk": "L",
"url": "/Equipment.aspx?ID=2817",
"summary": "The shield has Hardness 1, HP 4, and BT 2.",
"ac": "",
"hp": "",
"hardness": ""
},
{
"name": "Cold Iron Shield (Low-Grade)",
"trait": "",
"item_category": "Shields",
"item_subcategory": "Precious Material Shields",
"bulk": "1",
"url": "/Equipment.aspx?ID=2813",
"summary": "The shield has Hardness 5, HP 20, and BT 10.",
"ac": "",
"hp": "",
"hardness": ""
},
{
"name": "Silver Shield (Low-Grade)",
"trait": "",
"item_category": "Shields",
"item_subcategory": "Precious Material Shields",
"bulk": "1",
"url": "/Equipment.aspx?ID=2817",
"summary": "The shield has Hardness 3, HP 12, and BT 6.",
"ac": "",
"hp": "",
"hardness": ""
},
{
"name": "Bivouac Targe",
"trait": "Extradimensional, Magical, Uncommon",
"item_category": "Shields",
"item_subcategory": "Specific Shields",
"bulk": "L",
"url": "/Equipment.aspx?ID=3826",
"summary": "This buckler (Hardness 3, HP 6, BT 3) has the appearance of a common wooden shield . ",
"ac": "",
"hp": "",
"hardness": ""
},
{
"name": "Glamorous Buckler",
"trait": "Illusion, Magical",
"item_category": "Shields",
"item_subcategory": "",
"bulk": "L",
"url": "/Equipment.aspx?ID=3279",
"summary": "A glamorous buckler is lavishly decorated with gilding and inset gemstones that glitter in the light. While you have it raised, the glamorous buckler grants you a +1 item bonus to Deception checks to Feint.",
"ac": "",
"hp": "",
"hardness": ""
},
{
"name": "Mycoweave Shield (Lesser)",
"trait": "Fungus, Poison, Uncommon",
"item_category": "Shields",
"item_subcategory": "",
"bulk": "1",
"url": "/Equipment.aspx?ID=2663",
"summary": "The shield has Hardness 2, HP 12, and BT 6. When the shield breaks, the reaction deals 1d6 persistent poison damage with a DC 16 Fortitude saving throw.",
"ac": "",
"hp": "",
"hardness": ""
},
{
"name": "Fan Buckler",
"trait": "Magical, Uncommon",
"item_category": "Shields",
"item_subcategory": "Specific Shields",
"bulk": "L",
"url": "/Equipment.aspx?ID=3776",
"summary": "When collapsed, a fan buckler appears to be no more than an elegant wooden fan. Any attempts to discern that theres more to the item require a successful Perception check against the Deception DC of the wielder.",
"ac": "",
"hp": "",
"hardness": ""
},
{
"name": "Sapling Shield (Minor)",
"trait": "Magical",
"item_category": "Shields",
"item_subcategory": "Specific Shields",
"bulk": "2",
"url": "/Equipment.aspx?ID=1860",
"summary": "The buckler has Hardness 3, HP 24, and BT 12.",
"ac": "",
"hp": "",
"hardness": ""
},
{
"name": "Siege Shield",
"trait": "Magical, Uncommon",
"item_category": "Shields",
"item_subcategory": "Specific Shields",
"bulk": "4",
"url": "/Equipment.aspx?ID=3830",
"summary": "This massive tower shield (Hardness 5, HP 20, BT 10) is crafted from the toughest steel. Its not ideal for single combat, but it can be used to defend soldiers during a siege. While this shield is raised, you gain resistance to damage from siege weapons equal to half this shields Hardness.",
"ac": "",
"hp": "",
"hardness": ""
},
{
"name": "Pillow Shield",
"trait": "Magical, Transmutation, Uncommon",
"item_category": "Shields",
"item_subcategory": "Specific Shields",
"bulk": "1",
"url": "/Equipment.aspx?ID=1300",
"summary": "The shield's blue enameled face is cool to the touch, and displays the moon's current phase at night. When you lay your head on the reverse side of this steel shield (Hardness 6, HP 36, BT 18), it becomes as pliant and supportive as the best pillows. If you complete a period of rest using the pillow shield, you can choose to transfer your recovery to the shield. Instead of recovering a number of Hit Points after resting, the shield is restored an equal number of Hit Points instead.",
"ac": "",
"hp": "",
"hardness": ""
},
{
"name": "Wovenwood Shield (Minor)",
"trait": "Abjuration, Magical, Uncommon",
"item_category": "Shields",
"item_subcategory": "Specific Shields",
"bulk": "1",
"url": "/Equipment.aspx?ID=1378",
"summary": "This shield has Hardness 5, Hit Points 40, and Broken Threshold 20.",
"ac": "",
"hp": "",
"hardness": ""
},
{
"name": "Sturdy Shield (Minor)",
"trait": "",
"item_category": "Shields",
"item_subcategory": "Specific Shields",
"bulk": "1",
"url": "/Equipment.aspx?ID=2828",
"summary": "The shield has Hardness 8, HP 64, and BT 32.",
"ac": "",
"hp": "",
"hardness": ""
},
{
"name": "Exploding Shield",
"trait": "Magical",
"item_category": "Shields",
"item_subcategory": "",
"bulk": "1",
"url": "/Equipment.aspx?ID=3278",
"summary": "The magic within this wooden shield lashes out at your foes as the shield is destroyed . ",
"ac": "",
"hp": "",
"hardness": ""
},
{
"name": "Tiger Shield",
"trait": "Magical, Uncommon",
"item_category": "Shields",
"item_subcategory": "Specific Shields",
"bulk": "1",
"url": "/Equipment.aspx?ID=3833",
"summary": "This minor reinforcing wooden shield (Hardness 6, HP 56, BT 28) is made with a sturdy but flexible wood found in Tian Xia. Its painted with bold, bright colors in the style of a fiendish tiger head. In combat, the eyes of the tiger seem to follow the opponent.",
"ac": "",
"hp": "",
"hardness": ""
},
{
"name": "Helmsman's Recourse",
"trait": "Magical",
"item_category": "Shields",
"item_subcategory": "Specific Shields",
"bulk": "1",
"url": "/Equipment.aspx?ID=1858",
"summary": "This standard-grade duskwood meteor shield (Hardness 7, HP 28, BT 14) is a wheel from a ship. While wielding the shield, you gain a +1 item bonus to Sailing Lore and to Athletics checks to Swim.",
"ac": "",
"hp": "",
"hardness": ""
},
{
"name": "Testudo Shield",
"trait": "Magical, Uncommon",
"item_category": "Shields",
"item_subcategory": "Specific Shields",
"bulk": "4",
"url": "/Equipment.aspx?ID=3832",
"summary": "This tower shield (Hardness 5, HP 20, BT 10) sports a bright red front with a gold inlay of an eagle. While it looks unassuming, this shield can protect not only yourself but also those behind you.",
"ac": "",
"hp": "",
"hardness": ""
},
{
"name": "Burr Shield",
"trait": "Magical, Necromancy",
"item_category": "Shields",
"item_subcategory": "Specific Shields",
"bulk": "L",
"url": "/Equipment.aspx?ID=1044",
"summary": "This well-crafted wooden shield (Hardness 5, HP 30, BT 15) is covered in numerous seed pods with long spurs. You can Strike with these burrs as though they were +1 striking shield spikes.",
"ac": "",
"hp": "",
"hardness": ""
},
{
"name": "Broadleaf Shield",
"trait": "Magical, Plant, Wood",
"item_category": "Shields",
"item_subcategory": "Specific Shields",
"bulk": "L",
"url": "/Equipment.aspx?ID=2638",
"summary": "The shield has Hardness 4, HP 16, and BT 8. The resistances are 3 (6 when raised).",
"ac": "",
"hp": "",
"hardness": ""
},
{
"name": "Sapling Shield (Lesser)",
"trait": "Magical",
"item_category": "Shields",
"item_subcategory": "Specific Shields",
"bulk": "2",
"url": "/Equipment.aspx?ID=1860",
"summary": "The buckler has Hardness 6, HP 48, and BT 24.",
"ac": "",
"hp": "",
"hardness": ""
},
{
"name": "Lion's Shield",
"trait": "Magical",
"item_category": "Shields",
"item_subcategory": "Specific Shields",
"bulk": "1",
"url": "/Equipment.aspx?ID=2823",
"summary": "This steel shield (Hardness 6, HP 36, BT 18) is forged into the shape of a roaring lion's head. The lion's head functions as +1 striking shield boss that can't be removed from the shield.",
"ac": "",
"hp": "",
"hardness": ""
},
{
"name": "Spellguard Shield",
"trait": "",
"item_category": "Shields",
"item_subcategory": "Specific Shields",
"bulk": "1",
"url": "/Equipment.aspx?ID=2826",
"summary": "This shield bears eldritch glyphs to guard against magic. While you have this steel shield (Hardness 6, HP 24, BT 12) raised, you gain its circumstance bonus to saving throws against spells that target you (as well as to AC), and you can Shield Block spells that target you if you have that action.",
"ac": "",
"hp": "",
"hardness": ""
},
{
"name": "Cold Iron Buckler (Standard-Grade)",
"trait": "",
"item_category": "Shields",
"item_subcategory": "Precious Material Shields",
"bulk": "L",
"url": "/Equipment.aspx?ID=2813",
"summary": "The shield has Hardness 5, HP 20, and BT 10.",
"ac": "",
"hp": "",
"hardness": ""
},
{
"name": "Silver Buckler (Standard-Grade)",
"trait": "",
"item_category": "Shields",
"item_subcategory": "Precious Material Shields",
"bulk": "L",
"url": "/Equipment.aspx?ID=2817",
"summary": "The shield has Hardness 3, HP 12, and BT 6.",
"ac": "",
"hp": "",
"hardness": ""
},
{
"name": "Wovenwood Shield (Lesser)",
"trait": "Abjuration, Magical, Uncommon",
"item_category": "Shields",
"item_subcategory": "Specific Shields",
"bulk": "1",
"url": "/Equipment.aspx?ID=1378",
"summary": "This shield has Hardness 8, Hit Points 64, and Broken Threshold 32.",
"ac": "",
"hp": "",
"hardness": ""
},
{
"name": "Inubrix Buckler (Standard-Grade)",
"trait": "Rare",
"item_category": "Shields",
"item_subcategory": "Precious Material Shields",
"bulk": "L",
"url": "/Equipment.aspx?ID=1417",
"summary": "The shield has Hardness 2, HP 8, and BT 4.",
"ac": "",
"hp": "",
"hardness": ""
},
{
"name": "Cold Iron Shield (Standard-Grade)",
"trait": "",
"item_category": "Shields",
"item_subcategory": "Precious Material Shields",
"bulk": "1",
"url": "/Equipment.aspx?ID=2813",
"summary": "The shield has Hardness 7, HP 28, and BT 14.",
"ac": "",
"hp": "",
"hardness": ""
},
{
"name": "Silver Shield (Standard-Grade)",
"trait": "",
"item_category": "Shields",
"item_subcategory": "Precious Material Shields",
"bulk": "1",
"url": "/Equipment.aspx?ID=2817",
"summary": "The shield has Hardness 5, HP 20, and BT 10.",
"ac": "",
"hp": "",
"hardness": ""
},
{
"name": "Lesser Energized Shield",
"trait": "Magical, Uncommon",
"item_category": "Shields",
"item_subcategory": "Specific Shields",
"bulk": "1",
"url": "/Equipment.aspx?ID=3828",
"summary": "This minor reinforcing steel shield (Hardness 8, HP 64, BT 32) is lined with pale silver that glows when struck. Whenever you use the Shield Block reaction, this shield becomes energized for 1 round.",
"ac": "",
"hp": "",
"hardness": ""
},
{
"name": "Limestone Shield",
"trait": "Earth, Magical",
"item_category": "Shields",
"item_subcategory": "Specific Shields",
"bulk": "4",
"url": "/Equipment.aspx?ID=2592",
"summary": "This tower shield is a slab of limestone, shaved to a portable size and weight. The shield has Hardness 7 and 28 Hit Points. ",
"ac": "",
"hp": "",
"hardness": ""
},
{
"name": "Staff-Storing Shield",
"trait": "Extradimensional, Invested, Magical, Transmutation",
"item_category": "Shields",
"item_subcategory": "Specific Shields",
"bulk": "1",
"url": "/Equipment.aspx?ID=1079",
"summary": "This magically reinforced wooden shield (Hardness 6, HP 36, BT 18) normally has a blank face. It can absorb a staff and transform between a shield and staff. When you prepare a staff, you can hold it up to the shield, at which point the items will merge, and the shield's face becomes an image corresponding to the type of magic, such as a skull for a staff of necromancy.",
"ac": "",
"hp": "",
"hardness": ""
},
{
"name": "Inubrix Shield (Standard-Grade)",
"trait": "Rare",
"item_category": "Shields",
"item_subcategory": "Precious Material Shields",
"bulk": "1",
"url": "/Equipment.aspx?ID=1417",
"summary": "The shield has Hardness 4, HP 16, and BT 8.",
"ac": "",
"hp": "",
"hardness": ""
},
{
"name": "Spined Shield",
"trait": "",
"item_category": "Shields",
"item_subcategory": "Specific Shields",
"bulk": "1",
"url": "/Equipment.aspx?ID=2827",
"summary": "Five jagged spines project from the surface of this steel shield (Hardness 6, HP 24, BT 12). The spines are +1 striking shield spikes. When you use the Shield Block reaction with this shield, the spines take the damage before the shield itself does. When the shield would take damage (after applying Hardness), one spine snaps off per 6 damage, reducing the damage by 6. The shield takes any remaining damage. When there are no spines left, the shield takes damage as normal.",
"ac": "",
"hp": "",
"hardness": ""
},
{
"name": "Sturdy Shield (Lesser)",
"trait": "",
"item_category": "Shields",
"item_subcategory": "Specific Shields",
"bulk": "1",
"url": "/Equipment.aspx?ID=2828",
"summary": "The shield has Hardness 10, HP 80, and BT 40.",
"ac": "",
"hp": "",
"hardness": ""
}
]

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Character" ADD COLUMN "credits" INTEGER NOT NULL DEFAULT 0;

View File

@@ -0,0 +1,8 @@
-- AlterTable
ALTER TABLE "CharacterItem" ADD COLUMN "alias" TEXT,
ADD COLUMN "customDamage" TEXT,
ADD COLUMN "customDamageType" TEXT,
ADD COLUMN "customHands" TEXT,
ADD COLUMN "customName" TEXT,
ADD COLUMN "customRange" TEXT,
ADD COLUMN "customTraits" TEXT[];

View File

@@ -0,0 +1,23 @@
-- AlterTable
ALTER TABLE "Feat" ADD COLUMN "ancestryName" TEXT,
ADD COLUMN "archetypeName" TEXT,
ADD COLUMN "className" TEXT,
ADD COLUMN "description" TEXT,
ADD COLUMN "featType" TEXT,
ADD COLUMN "nameGerman" TEXT,
ADD COLUMN "prerequisites" TEXT,
ADD COLUMN "rarity" TEXT,
ADD COLUMN "skillName" TEXT,
ADD COLUMN "summaryGerman" TEXT;
-- CreateIndex
CREATE INDEX "Feat_featType_idx" ON "Feat"("featType");
-- CreateIndex
CREATE INDEX "Feat_className_idx" ON "Feat"("className");
-- CreateIndex
CREATE INDEX "Feat_ancestryName_idx" ON "Feat"("ancestryName");
-- CreateIndex
CREATE INDEX "Feat_level_idx" ON "Feat"("level");

View File

@@ -184,6 +184,9 @@ model Character {
// Experience
experiencePoints Int @default(0)
// Currency (Ironvale uses Credits instead of Gold/Silver/Copper)
credits Int @default(0)
// Pathbuilder Import Data (JSON blob for original import)
pathbuilderData Json?
@@ -266,6 +269,17 @@ model CharacterItem {
containerId String? // For containers
notes String?
// Player-editable: Alias/Nickname for the item
alias String?
// GM-editable: Custom overrides for item properties
customName String? // Override display name
customDamage String? // Override damage dice (e.g. "2d6")
customDamageType String? // Override damage type (e.g. "fire")
customTraits String[] // Override/add traits
customRange String? // Override range
customHands String? // Override hands requirement
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
equipment Equipment? @relation(fields: [equipmentId], references: [id])
}
@@ -466,16 +480,43 @@ model NoteShare {
// ==========================================
model Feat {
id String @id @default(uuid())
name String @unique
traits String[]
summary String?
actions String?
url String?
level Int?
sourceBook String?
id String @id @default(uuid())
name String @unique
traits String[]
summary String?
description String? // Full feat description/benefit text
actions String? // "1", "2", "3", "free", "reaction", null for passive
url String?
level Int? // Minimum level requirement
sourceBook String?
// Feat classification
featType String? // "General", "Skill", "Class", "Ancestry", "Archetype", "Heritage"
rarity String? // "Common", "Uncommon", "Rare", "Unique"
// Prerequisites
prerequisites String? // Text description of prerequisites
// For class/archetype feats
className String? // "Fighter", "Wizard", etc.
archetypeName String? // "Sentinel", "Medic", etc.
// For ancestry feats
ancestryName String? // "Human", "Elf", etc.
// For skill feats
skillName String? // "Acrobatics", "Athletics", etc.
// Cached German translation
nameGerman String?
summaryGerman String?
characterFeats CharacterFeat[]
@@index([featType])
@@index([className])
@@index([ancestryName])
@@index([level])
}
model Equipment {

View File

@@ -33,6 +33,19 @@ interface ArmorJson {
dex_cap?: string;
}
interface ShieldJson {
name: string;
trait: string;
item_category: string;
item_subcategory: string;
bulk: string;
url: string;
summary: string;
ac?: string;
hp?: string; // Format: "6 (3)" - HP und Broken Threshold
hardness?: string;
}
interface EquipmentJson {
name: string;
trait: string;
@@ -66,6 +79,18 @@ function parseNumber(str: string | undefined): number | null {
return isNaN(num) ? null : num;
}
function parseShieldHp(hpStr: string | undefined): { hp: number | null; bt: number | null } {
if (!hpStr || hpStr.trim() === '') return { hp: null, bt: null };
// Format: "6 (3)" oder "12 (6)"
const match = hpStr.match(/^(\d+)\s*\((\d+)\)$/);
if (match) {
return { hp: parseInt(match[1], 10), bt: parseInt(match[2], 10) };
}
// Fallback: nur HP
const num = parseInt(hpStr, 10);
return { hp: isNaN(num) ? null : num, bt: null };
}
async function seedWeapons() {
const dataPath = path.join(__dirname, 'data', 'weapons.json');
const data: WeaponJson[] = JSON.parse(fs.readFileSync(dataPath, 'utf-8'));
@@ -184,6 +209,63 @@ async function seedArmor() {
console.log(` ✅ Created: ${created}, Updated: ${updated}, Errors: ${errors}`);
}
async function seedShields() {
const dataPath = path.join(__dirname, 'data', 'shields.json');
const data: ShieldJson[] = JSON.parse(fs.readFileSync(dataPath, 'utf-8'));
console.log(`🛡️ Importing ${data.length} shields...`);
let created = 0;
let updated = 0;
let errors = 0;
for (const item of data) {
try {
const { hp, bt } = parseShieldHp(item.hp);
const existing = await prisma.equipment.findUnique({ where: { name: item.name } });
if (existing) {
await prisma.equipment.update({
where: { name: item.name },
data: {
ac: parseNumber(item.ac) ?? existing.ac,
shieldHp: hp ?? existing.shieldHp,
shieldBt: bt ?? existing.shieldBt,
shieldHardness: parseNumber(item.hardness) ?? existing.shieldHardness,
traits: existing.traits.length > 0 ? existing.traits : parseTraits(item.trait),
summary: existing.summary || item.summary || null,
},
});
updated++;
} else {
await prisma.equipment.create({
data: {
name: item.name,
traits: parseTraits(item.trait),
itemCategory: item.item_category || 'Shields',
itemSubcategory: item.item_subcategory || null,
bulk: item.bulk || null,
url: item.url || null,
summary: item.summary || null,
ac: parseNumber(item.ac),
shieldHp: hp,
shieldBt: bt,
shieldHardness: parseNumber(item.hardness),
},
});
created++;
}
} catch (error: any) {
if (errors < 3) {
console.log(` ⚠️ Error for "${item.name}": ${error.message?.slice(0, 100)}`);
}
errors++;
}
}
console.log(` ✅ Created: ${created}, Updated: ${updated}, Errors: ${errors}`);
}
async function seedEquipment() {
const dataPath = path.join(__dirname, 'data', 'equipment.json');
const data: EquipmentJson[] = JSON.parse(fs.readFileSync(dataPath, 'utf-8'));
@@ -233,10 +315,11 @@ async function main() {
const startTime = Date.now();
// WICHTIG: Equipment zuerst, dann Waffen/Rüstung um spezifische Felder zu ergänzen
// WICHTIG: Equipment zuerst, dann Waffen/Rüstung/Schilde um spezifische Felder zu ergänzen
await seedEquipment();
await seedWeapons(); // Ergänzt damage, hands, weapon_category etc.
await seedArmor(); // Ergänzt ac, dex_cap etc.
await seedShields(); // Ergänzt shieldHp, shieldHardness, shieldBt etc.
const totalCount = await prisma.equipment.count();
const duration = ((Date.now() - startTime) / 1000).toFixed(1);

263
server/prisma/seed-feats.ts Normal file
View File

@@ -0,0 +1,263 @@
import 'dotenv/config';
import * as fs from 'fs';
import * as path from 'path';
import { PrismaClient } from '../src/generated/prisma/client.js';
import { PrismaPg } from '@prisma/adapter-pg';
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
const prisma = new PrismaClient({ adapter });
interface RawFeat {
name: string;
trait: string;
summary: string;
actions: string;
damage: string;
trigger: string;
url: string;
}
interface FeatLevelData {
name: string;
level: string;
prerequisite: string;
}
// Known classes in Pathfinder 2e
const CLASSES = [
'Alchemist', 'Barbarian', 'Bard', 'Champion', 'Cleric', 'Druid',
'Fighter', 'Gunslinger', 'Inventor', 'Investigator', 'Kineticist',
'Magus', 'Monk', 'Oracle', 'Psychic', 'Ranger', 'Rogue', 'Sorcerer',
'Summoner', 'Swashbuckler', 'Thaumaturge', 'Witch', 'Wizard',
// Archetype dedication markers
'Archetype',
];
// Known ancestries in Pathfinder 2e
const ANCESTRIES = [
'Android', 'Anadi', 'Aasimar', 'Aphorite', 'Automaton', 'Azarketi',
'Beastkin', 'Catfolk', 'Changeling', 'Conrasu', 'Dhampir', 'Duskwalker',
'Dwarf', 'Elf', 'Fetchling', 'Fleshwarp', 'Ganzi', 'Ghoran', 'Gnoll',
'Gnome', 'Goblin', 'Goloma', 'Grippli', 'Halfling', 'Hobgoblin', 'Human',
'Ifrit', 'Kashrishi', 'Kitsune', 'Kobold', 'Leshy', 'Lizardfolk', 'Nagaji',
'Orc', 'Oread', 'Poppet', 'Ratfolk', 'Reflection', 'Shisk', 'Shoony',
'Skeleton', 'Sprite', 'Strix', 'Suli', 'Sylph', 'Tanuki', 'Tengu',
'Tiefling', 'Undine', 'Vanara', 'Vishkanya', 'Wayang',
// Heritage markers
'Half-Elf', 'Half-Orc', 'Versatile Heritage',
];
// Rarity levels
const RARITIES = ['Common', 'Uncommon', 'Rare', 'Unique'];
// Feat type markers
const GENERAL_MARKERS = ['General'];
const SKILL_MARKERS = ['Skill'];
// Clean up prerequisites text
function cleanPrerequisites(prereq: string): string | null {
if (!prereq || prereq.trim() === '') return null;
let cleaned = prereq.trim();
// Remove leading semicolons or commas
cleaned = cleaned.replace(/^[;,\s]+/, '');
// Normalize some common patterns
cleaned = cleaned.replace(/\s+/g, ' '); // normalize whitespace
return cleaned || null;
}
function parseFeat(raw: RawFeat, levelData?: FeatLevelData) {
const traits = raw.trait.split(',').map(t => t.trim()).filter(Boolean);
let featType: string | null = null;
let className: string | null = null;
let ancestryName: string | null = null;
let archetypeName: string | null = null;
let skillName: string | null = null;
let rarity: string = 'Common';
const actualTraits: string[] = [];
for (const trait of traits) {
// Check for rarity
if (RARITIES.includes(trait)) {
rarity = trait;
continue;
}
// Check for class
if (CLASSES.includes(trait)) {
if (trait === 'Archetype') {
featType = 'Archetype';
} else {
className = trait;
featType = 'Class';
}
continue;
}
// Check for ancestry
if (ANCESTRIES.includes(trait)) {
ancestryName = trait;
featType = 'Ancestry';
continue;
}
// Check for general/skill markers
if (GENERAL_MARKERS.includes(trait)) {
if (!featType) featType = 'General';
continue;
}
if (SKILL_MARKERS.includes(trait)) {
featType = 'Skill';
continue;
}
// Check for lineage/heritage
if (trait === 'Lineage' || trait === 'Heritage') {
featType = 'Heritage';
continue;
}
// Everything else is an actual trait
actualTraits.push(trait);
}
// Parse actions
let actions: string | null = null;
if (raw.actions) {
const actionsLower = raw.actions.toLowerCase();
if (actionsLower.includes('free')) {
actions = 'free';
} else if (actionsLower.includes('reaction')) {
actions = 'reaction';
} else if (actionsLower.includes('1') || actionsLower === 'a') {
actions = '1';
} else if (actionsLower.includes('2') || actionsLower === 'aa') {
actions = '2';
} else if (actionsLower.includes('3') || actionsLower === 'aaa') {
actions = '3';
}
}
// Get level and prerequisites from levelData if available
const level = levelData ? parseInt(levelData.level, 10) : null;
const prerequisites = levelData ? cleanPrerequisites(levelData.prerequisite) : null;
return {
name: raw.name,
traits: actualTraits,
summary: raw.summary || null,
description: raw.trigger ? `Trigger: ${raw.trigger}` : null,
actions,
url: raw.url || null,
featType,
rarity,
className,
ancestryName,
archetypeName,
skillName,
level,
prerequisites,
};
}
async function seedFeats() {
console.log('Starting feats seed...');
// Read feats JSON
const featsPath = path.join(__dirname, 'data', 'feats.json');
const rawFeats: RawFeat[] = JSON.parse(fs.readFileSync(featsPath, 'utf-8'));
console.log(`Found ${rawFeats.length} feats to import`);
// Read feat levels JSON
const levelsPath = path.join(__dirname, 'data', 'featlevels.json');
let levelDataMap = new Map<string, FeatLevelData>();
if (fs.existsSync(levelsPath)) {
const levelData: FeatLevelData[] = JSON.parse(fs.readFileSync(levelsPath, 'utf-8'));
console.log(`Found ${levelData.length} feat level entries`);
// Create lookup map by name (case-insensitive)
levelData.forEach(ld => {
levelDataMap.set(ld.name.toLowerCase(), ld);
});
} else {
console.log('Warning: featlevels.json not found, skipping level data');
}
// Clear existing feats
console.log('Clearing existing feats...');
await prisma.feat.deleteMany();
// Parse and prepare feats with level data
let matchedCount = 0;
const feats = rawFeats.map(raw => {
const levelData = levelDataMap.get(raw.name.toLowerCase());
if (levelData) matchedCount++;
return parseFeat(raw, levelData);
});
console.log(`Matched ${matchedCount}/${rawFeats.length} feats with level data`);
// Insert in batches to avoid memory issues
const BATCH_SIZE = 500;
let inserted = 0;
for (let i = 0; i < feats.length; i += BATCH_SIZE) {
const batch = feats.slice(i, i + BATCH_SIZE);
// Use createMany with skipDuplicates for efficiency
try {
await prisma.feat.createMany({
data: batch,
skipDuplicates: true,
});
inserted += batch.length;
console.log(`Inserted ${inserted}/${feats.length} feats...`);
} catch (error) {
// If batch fails, try one by one to identify problematic entries
console.log(`Batch failed, inserting one by one...`);
for (const feat of batch) {
try {
await prisma.feat.create({ data: feat });
inserted++;
} catch (e) {
console.warn(`Failed to insert feat: ${feat.name}`, e);
}
}
}
}
// Count results
const totalFeats = await prisma.feat.count();
const classFeatCount = await prisma.feat.count({ where: { featType: 'Class' } });
const ancestryFeatCount = await prisma.feat.count({ where: { featType: 'Ancestry' } });
const generalFeatCount = await prisma.feat.count({ where: { featType: 'General' } });
const skillFeatCount = await prisma.feat.count({ where: { featType: 'Skill' } });
const archetypeFeatCount = await prisma.feat.count({ where: { featType: 'Archetype' } });
const withLevel = await prisma.feat.count({ where: { level: { not: null } } });
const withPrereqs = await prisma.feat.count({ where: { prerequisites: { not: null } } });
console.log('\n=== Feats Import Complete ===');
console.log(`Total feats: ${totalFeats}`);
console.log(`Class feats: ${classFeatCount}`);
console.log(`Ancestry feats: ${ancestryFeatCount}`);
console.log(`General feats: ${generalFeatCount}`);
console.log(`Skill feats: ${skillFeatCount}`);
console.log(`Archetype feats: ${archetypeFeatCount}`);
console.log(`Feats with level: ${withLevel}`);
console.log(`Feats with prerequisites: ${withPrereqs}`);
}
seedFeats()
.catch((e) => {
console.error('Error seeding feats:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@@ -12,6 +12,7 @@ import { CampaignsModule } from './modules/campaigns/campaigns.module';
import { CharactersModule } from './modules/characters/characters.module';
import { TranslationsModule } from './modules/translations/translations.module';
import { EquipmentModule } from './modules/equipment/equipment.module';
import { FeatsModule } from './modules/feats/feats.module';
// Guards
import { JwtAuthGuard } from './modules/auth/guards/jwt-auth.guard';
@@ -35,6 +36,7 @@ import { RolesGuard } from './modules/auth/guards/roles.guard';
CharactersModule,
TranslationsModule,
EquipmentModule,
FeatsModule,
],
providers: [
// Global JWT Auth Guard

View File

@@ -24,6 +24,7 @@ import {
CreateFeatDto,
CreateSpellDto,
CreateItemDto,
UpdateItemDto,
CreateConditionDto,
CreateResourceDto,
PathbuilderImportDto,
@@ -118,6 +119,17 @@ export class CharactersController {
return this.charactersService.updateHp(id, body.hpCurrent, body.hpTemp, userId);
}
// Credits Management
@Patch(':id/credits')
@ApiOperation({ summary: 'Update character credits' })
async updateCredits(
@Param('id') id: string,
@Body() body: { credits: number },
@CurrentUser('id') userId: string,
) {
return this.charactersService.updateCredits(id, body.credits, userId);
}
// Abilities
@Put(':id/abilities')
@ApiOperation({ summary: 'Set character abilities' })
@@ -216,11 +228,11 @@ export class CharactersController {
}
@Patch(':id/items/:itemId')
@ApiOperation({ summary: 'Update item' })
@ApiOperation({ summary: 'Update item (players can edit alias, GMs can edit custom properties)' })
async updateItem(
@Param('id') id: string,
@Param('itemId') itemId: string,
@Body() body: Partial<CreateItemDto>,
@Body() body: UpdateItemDto,
@CurrentUser('id') userId: string,
) {
return this.charactersService.updateItem(id, itemId, body, userId);

View File

@@ -0,0 +1,171 @@
import {
WebSocketGateway,
WebSocketServer,
SubscribeMessage,
OnGatewayConnection,
OnGatewayDisconnect,
ConnectedSocket,
MessageBody,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { Injectable, Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../../prisma/prisma.service';
interface AuthenticatedSocket extends Socket {
userId?: string;
username?: string;
}
export interface CharacterUpdatePayload {
characterId: string;
type: 'hp' | 'conditions' | 'item' | 'inventory' | 'money' | 'level' | 'equipment_status';
data: any;
}
@Injectable()
@WebSocketGateway({
cors: {
origin: ['http://localhost:5173', 'http://localhost:3000'],
credentials: true,
},
namespace: '/characters',
})
export class CharactersGateway implements OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer()
server: Server;
private logger = new Logger('CharactersGateway');
private connectedClients = new Map<string, Set<string>>(); // characterId -> Set<socketId>
constructor(
private jwtService: JwtService,
private configService: ConfigService,
private prisma: PrismaService,
) {}
async handleConnection(client: AuthenticatedSocket) {
try {
const token = client.handshake.auth.token || client.handshake.headers.authorization?.split(' ')[1];
if (!token) {
this.logger.warn(`Client ${client.id} disconnected: No token provided`);
client.disconnect();
return;
}
const secret = this.configService.get<string>('JWT_SECRET');
const payload = this.jwtService.verify(token, { secret });
// Verify user exists
const user = await this.prisma.user.findUnique({
where: { id: payload.sub },
select: { id: true, username: true },
});
if (!user) {
this.logger.warn(`Client ${client.id} disconnected: User not found`);
client.disconnect();
return;
}
client.userId = user.id;
client.username = user.username;
this.logger.log(`Client connected: ${client.id} (User: ${user.username})`);
} catch (error) {
this.logger.warn(`Client ${client.id} disconnected: Invalid token`);
client.disconnect();
}
}
handleDisconnect(client: AuthenticatedSocket) {
// Remove client from all character rooms
this.connectedClients.forEach((clients, characterId) => {
clients.delete(client.id);
if (clients.size === 0) {
this.connectedClients.delete(characterId);
}
});
this.logger.log(`Client disconnected: ${client.id}`);
}
@SubscribeMessage('join_character')
async handleJoinCharacter(
@ConnectedSocket() client: AuthenticatedSocket,
@MessageBody() data: { characterId: string },
) {
if (!client.userId) {
return { success: false, error: 'Not authenticated' };
}
try {
// Verify user has access to this character
const character = await this.prisma.character.findUnique({
where: { id: data.characterId },
include: { campaign: { include: { members: true } } },
});
if (!character) {
return { success: false, error: 'Character not found' };
}
const isGM = character.campaign.gmId === client.userId;
const isOwner = character.ownerId === client.userId;
const isMember = character.campaign.members.some(m => m.userId === client.userId);
if (!isGM && !isOwner && !isMember) {
return { success: false, error: 'No access to this character' };
}
// Join the room
const room = `character:${data.characterId}`;
client.join(room);
// Track connected clients
if (!this.connectedClients.has(data.characterId)) {
this.connectedClients.set(data.characterId, new Set());
}
this.connectedClients.get(data.characterId)?.add(client.id);
this.logger.log(`Client ${client.id} joined character room: ${data.characterId}`);
return { success: true };
} catch (error) {
this.logger.error(`Error joining character room: ${error}`);
return { success: false, error: 'Failed to join character room' };
}
}
@SubscribeMessage('leave_character')
handleLeaveCharacter(
@ConnectedSocket() client: AuthenticatedSocket,
@MessageBody() data: { characterId: string },
) {
const room = `character:${data.characterId}`;
client.leave(room);
this.connectedClients.get(data.characterId)?.delete(client.id);
if (this.connectedClients.get(data.characterId)?.size === 0) {
this.connectedClients.delete(data.characterId);
}
this.logger.log(`Client ${client.id} left character room: ${data.characterId}`);
return { success: true };
}
// Broadcast character update to all clients in the room
broadcastCharacterUpdate(characterId: string, update: CharacterUpdatePayload) {
const room = `character:${characterId}`;
this.server.to(room).emit('character_update', update);
this.logger.debug(`Broadcast to ${room}: ${update.type}`);
}
// Get number of connected clients for a character
getConnectedClientsCount(characterId: string): number {
return this.connectedClients.get(characterId)?.size || 0;
}
}

View File

@@ -1,13 +1,25 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { CharactersController } from './characters.controller';
import { CharactersService } from './characters.service';
import { CharactersGateway } from './characters.gateway';
import { PathbuilderImportService } from './pathbuilder-import.service';
import { TranslationsModule } from '../translations/translations.module';
@Module({
imports: [TranslationsModule],
imports: [
TranslationsModule,
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({
secret: configService.get<string>('JWT_SECRET'),
}),
inject: [ConfigService],
}),
],
controllers: [CharactersController],
providers: [CharactersService, PathbuilderImportService],
exports: [CharactersService, PathbuilderImportService],
providers: [CharactersService, CharactersGateway, PathbuilderImportService],
exports: [CharactersService, CharactersGateway, PathbuilderImportService],
})
export class CharactersModule {}

View File

@@ -2,8 +2,13 @@ import {
Injectable,
NotFoundException,
ForbiddenException,
Inject,
forwardRef,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { TranslationsService } from '../translations/translations.service';
import { CharactersGateway } from './characters.gateway';
import { TranslationType } from '../../generated/prisma/client.js';
import {
CreateCharacterDto,
UpdateCharacterDto,
@@ -12,13 +17,19 @@ import {
CreateFeatDto,
CreateSpellDto,
CreateItemDto,
UpdateItemDto,
CreateConditionDto,
CreateResourceDto,
} from './dto';
@Injectable()
export class CharactersService {
constructor(private prisma: PrismaService) {}
constructor(
private prisma: PrismaService,
private translationsService: TranslationsService,
@Inject(forwardRef(() => CharactersGateway))
private charactersGateway: CharactersGateway,
) {}
// Check if user has access to campaign
private async checkCampaignAccess(campaignId: string, userId: string) {
@@ -114,7 +125,12 @@ export class CharactersService {
skills: { orderBy: { skillName: 'asc' } },
feats: { orderBy: [{ level: 'asc' }, { name: 'asc' }] },
spells: { orderBy: [{ spellLevel: 'asc' }, { name: 'asc' }] },
items: { orderBy: { name: 'asc' } },
items: {
orderBy: { name: 'asc' },
include: {
equipment: true, // Lade Equipment-Details für Kategorie und Stats
},
},
conditions: true,
resources: true,
},
@@ -156,13 +172,45 @@ export class CharactersService {
await this.checkCharacterAccess(id, userId, true);
}
return this.prisma.character.update({
const result = await this.prisma.character.update({
where: { id },
data: {
hpCurrent: Math.max(0, hpCurrent),
...(hpTemp !== undefined && { hpTemp: Math.max(0, hpTemp) }),
},
});
// Broadcast HP update
this.charactersGateway.broadcastCharacterUpdate(id, {
characterId: id,
type: 'hp',
data: { hpCurrent: result.hpCurrent, hpTemp: result.hpTemp, hpMax: result.hpMax },
});
return result;
}
// Credits Management
async updateCredits(id: string, credits: number, userId?: string) {
if (userId) {
await this.checkCharacterAccess(id, userId, true);
}
const result = await this.prisma.character.update({
where: { id },
data: {
credits: Math.max(0, credits),
},
});
// Broadcast money update
this.charactersGateway.broadcastCharacterUpdate(id, {
characterId: id,
type: 'money',
data: { credits: result.credits },
});
return result;
}
// Abilities
@@ -202,8 +250,54 @@ export class CharactersService {
async addFeat(characterId: string, dto: CreateFeatDto, userId: string) {
await this.checkCharacterAccess(characterId, userId, true);
let nameGerman = dto.nameGerman;
// If featId is provided, translate and update the Feat record
if (dto.featId) {
const feat = await this.prisma.feat.findUnique({
where: { id: dto.featId },
});
if (feat) {
// Check if feat needs translation
if (!feat.nameGerman || !feat.summaryGerman) {
const translation = await this.translationsService.getTranslation(
TranslationType.FEAT,
feat.name,
feat.summary || undefined,
);
// Update the Feat record with translations
await this.prisma.feat.update({
where: { id: dto.featId },
data: {
nameGerman: feat.nameGerman || translation.germanName,
summaryGerman: feat.summaryGerman || translation.germanDescription,
},
});
nameGerman = nameGerman || translation.germanName;
} else {
nameGerman = nameGerman || feat.nameGerman;
}
}
}
// Fallback: translate just the name if still missing
if (!nameGerman) {
const translation = await this.translationsService.getTranslation(
TranslationType.FEAT,
dto.name,
);
nameGerman = translation.germanName;
}
return this.prisma.characterFeat.create({
data: { ...dto, characterId },
data: {
...dto,
nameGerman,
characterId,
},
});
}
@@ -243,24 +337,125 @@ export class CharactersService {
async addItem(characterId: string, dto: CreateItemDto, userId: string) {
await this.checkCharacterAccess(characterId, userId, true);
return this.prisma.characterItem.create({
data: { ...dto, characterId, bulk: dto.bulk as any },
// Hole Equipment-Details für die Summary (falls equipmentId vorhanden)
let summary: string | undefined;
if (dto.equipmentId) {
const equipment = await this.prisma.equipment.findUnique({
where: { id: dto.equipmentId },
select: { summary: true },
});
summary = equipment?.summary || undefined;
}
// Übersetze den Item-Namen (und Summary), falls noch nicht vorhanden
let nameGerman = dto.nameGerman;
if (!nameGerman && dto.name) {
const translation = await this.translationsService.getTranslation(
TranslationType.EQUIPMENT,
dto.name,
summary, // Summary wird mit übersetzt
);
nameGerman = translation.germanName;
}
const result = await this.prisma.characterItem.create({
data: { ...dto, characterId, bulk: dto.bulk as any, nameGerman },
include: { equipment: true },
});
// Broadcast inventory update
this.charactersGateway.broadcastCharacterUpdate(characterId, {
characterId,
type: 'inventory',
data: { action: 'add', item: result },
});
return result;
}
async updateItem(characterId: string, itemId: string, data: Partial<CreateItemDto>, userId: string) {
await this.checkCharacterAccess(characterId, userId, true);
return this.prisma.characterItem.update({
where: { id: itemId },
data: { ...data, bulk: data.bulk as any },
async updateItem(characterId: string, itemId: string, data: UpdateItemDto, userId: string) {
const character = await this.prisma.character.findUnique({
where: { id: characterId },
include: { campaign: true },
});
if (!character) {
throw new NotFoundException('Character not found');
}
const isGM = character.campaign.gmId === userId;
const isOwner = character.ownerId === userId;
if (!isOwner && !isGM) {
throw new ForbiddenException('Only the owner or GM can modify this character');
}
// Separate player-allowed fields from GM-only fields
const playerFields = {
quantity: data.quantity,
equipped: data.equipped,
invested: data.invested,
notes: data.notes,
alias: data.alias,
};
const gmFields = {
customName: data.customName,
customDamage: data.customDamage,
customDamageType: data.customDamageType,
customTraits: data.customTraits,
customRange: data.customRange,
customHands: data.customHands,
};
// Build update data based on role
const updateData: any = {};
// Players can always update player fields
Object.entries(playerFields).forEach(([key, value]) => {
if (value !== undefined) {
updateData[key] = value;
}
});
// Only GM can update GM fields
if (isGM) {
Object.entries(gmFields).forEach(([key, value]) => {
if (value !== undefined) {
updateData[key] = value;
}
});
}
const result = await this.prisma.characterItem.update({
where: { id: itemId },
data: updateData,
include: { equipment: true },
});
// Broadcast item update (could be equipment status change or other update)
const updateType = data.equipped !== undefined ? 'equipment_status' : 'item';
this.charactersGateway.broadcastCharacterUpdate(characterId, {
characterId,
type: updateType,
data: { action: 'update', item: result },
});
return result;
}
async removeItem(characterId: string, itemId: string, userId: string) {
await this.checkCharacterAccess(characterId, userId, true);
await this.prisma.characterItem.delete({ where: { id: itemId } });
// Broadcast inventory update
this.charactersGateway.broadcastCharacterUpdate(characterId, {
characterId,
type: 'inventory',
data: { action: 'remove', itemId },
});
return { message: 'Item removed' };
}
@@ -268,24 +463,60 @@ export class CharactersService {
async addCondition(characterId: string, dto: CreateConditionDto, userId: string) {
await this.checkCharacterAccess(characterId, userId, true);
return this.prisma.characterCondition.create({
data: { ...dto, characterId },
// Übersetze den Condition-Namen, falls noch nicht vorhanden
let nameGerman = dto.nameGerman;
if (!nameGerman && dto.name) {
const translation = await this.translationsService.getTranslation(
TranslationType.CONDITION,
dto.name,
);
nameGerman = translation.germanName;
}
const result = await this.prisma.characterCondition.create({
data: { ...dto, characterId, nameGerman },
});
// Broadcast conditions update
this.charactersGateway.broadcastCharacterUpdate(characterId, {
characterId,
type: 'conditions',
data: { action: 'add', condition: result },
});
return result;
}
async updateCondition(characterId: string, conditionId: string, value: number, userId: string) {
await this.checkCharacterAccess(characterId, userId, true);
return this.prisma.characterCondition.update({
const result = await this.prisma.characterCondition.update({
where: { id: conditionId },
data: { value },
});
// Broadcast conditions update
this.charactersGateway.broadcastCharacterUpdate(characterId, {
characterId,
type: 'conditions',
data: { action: 'update', condition: result },
});
return result;
}
async removeCondition(characterId: string, conditionId: string, userId: string) {
await this.checkCharacterAccess(characterId, userId, true);
await this.prisma.characterCondition.delete({ where: { id: conditionId } });
// Broadcast conditions update
this.charactersGateway.broadcastCharacterUpdate(characterId, {
characterId,
type: 'conditions',
data: { action: 'remove', conditionId },
});
return { message: 'Condition removed' };
}

View File

@@ -193,6 +193,65 @@ export class CreateItemDto {
notes?: string;
}
export class UpdateItemDto {
// Player-editable fields
@ApiPropertyOptional()
@IsOptional()
@IsInt()
@Min(1)
quantity?: number;
@ApiPropertyOptional()
@IsOptional()
equipped?: boolean;
@ApiPropertyOptional()
@IsOptional()
invested?: boolean;
@ApiPropertyOptional()
@IsOptional()
@IsString()
notes?: string;
@ApiPropertyOptional({ description: 'Player-editable alias/nickname for the item' })
@IsOptional()
@IsString()
alias?: string;
// GM-only editable fields (will be validated in service)
@ApiPropertyOptional({ description: 'GM-only: Custom display name override' })
@IsOptional()
@IsString()
customName?: string;
@ApiPropertyOptional({ description: 'GM-only: Custom damage dice override (e.g. "2d6")' })
@IsOptional()
@IsString()
customDamage?: string;
@ApiPropertyOptional({ description: 'GM-only: Custom damage type override' })
@IsOptional()
@IsString()
customDamageType?: string;
@ApiPropertyOptional({ description: 'GM-only: Custom traits override', type: [String] })
@IsOptional()
@IsArray()
@IsString({ each: true })
customTraits?: string[];
@ApiPropertyOptional({ description: 'GM-only: Custom range override' })
@IsOptional()
@IsString()
customRange?: string;
@ApiPropertyOptional({ description: 'GM-only: Custom hands requirement override' })
@IsOptional()
@IsString()
customHands?: string;
}
export class CreateConditionDto {
@ApiProperty()
@IsString()

View File

@@ -89,7 +89,7 @@ Antworte NUR mit einem JSON-Array in diesem Format:
Gib confidence zwischen 0.0 und 1.0 an basierend auf der Übersetzungsqualität.`;
const response = await this.client.messages.create({
model: 'claude-3-5-sonnet-20241022',
model: 'claude-haiku-4-5-20251001',
max_tokens: 4000,
messages: [{ role: 'user', content: prompt }],
});

View File

@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service.js';
import { TranslationType } from '../../generated/prisma/client.js';
export interface EquipmentSearchParams {
query?: string;
@@ -108,9 +109,28 @@ export class EquipmentService {
}
async getById(id: string) {
return this.prisma.equipment.findUnique({
const equipment = await this.prisma.equipment.findUnique({
where: { id },
});
if (!equipment) return null;
// Hole die Übersetzung aus dem Cache (falls vorhanden)
const translation = await this.prisma.translation.findUnique({
where: {
type_englishName: {
type: TranslationType.EQUIPMENT,
englishName: equipment.name,
},
},
});
// Erweitere das Equipment um übersetzte Felder
return {
...equipment,
nameGerman: translation?.germanName || null,
summaryGerman: translation?.germanDescription || null,
};
}
async getByName(name: string) {

View File

@@ -0,0 +1,116 @@
import {
Controller,
Get,
Param,
Query,
NotFoundException,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiQuery,
} from '@nestjs/swagger';
import { FeatsService } from './feats.service';
@ApiTags('Feats')
@ApiBearerAuth()
@Controller('feats')
export class FeatsController {
constructor(private readonly featsService: FeatsService) {}
@Get()
@ApiOperation({ summary: 'Search and filter feats' })
@ApiResponse({ status: 200, description: 'List of feats with pagination' })
@ApiQuery({ name: 'query', required: false, description: 'Search term' })
@ApiQuery({ name: 'featType', required: false, description: 'Feat type (General, Skill, Class, Ancestry, Archetype)' })
@ApiQuery({ name: 'className', required: false, description: 'Class name for class feats' })
@ApiQuery({ name: 'ancestryName', required: false, description: 'Ancestry name for ancestry feats' })
@ApiQuery({ name: 'skillName', required: false, description: 'Skill name for skill feats' })
@ApiQuery({ name: 'minLevel', required: false, type: Number, description: 'Minimum feat level' })
@ApiQuery({ name: 'maxLevel', required: false, type: Number, description: 'Maximum feat level' })
@ApiQuery({ name: 'rarity', required: false, description: 'Rarity (Common, Uncommon, Rare, Unique)' })
@ApiQuery({ name: 'traits', required: false, description: 'Comma-separated traits' })
@ApiQuery({ name: 'page', required: false, type: Number, description: 'Page number (default: 1)' })
@ApiQuery({ name: 'limit', required: false, type: Number, description: 'Items per page (default: 20)' })
async search(
@Query('query') query?: string,
@Query('featType') featType?: string,
@Query('className') className?: string,
@Query('ancestryName') ancestryName?: string,
@Query('skillName') skillName?: string,
@Query('minLevel') minLevel?: string,
@Query('maxLevel') maxLevel?: string,
@Query('rarity') rarity?: string,
@Query('traits') traits?: string,
@Query('page') page?: string,
@Query('limit') limit?: string,
) {
return this.featsService.search({
query,
featType,
className,
ancestryName,
skillName,
minLevel: minLevel ? parseInt(minLevel, 10) : undefined,
maxLevel: maxLevel ? parseInt(maxLevel, 10) : undefined,
rarity,
traits: traits ? traits.split(',').map((t) => t.trim()) : undefined,
page: page ? parseInt(page, 10) : 1,
limit: limit ? parseInt(limit, 10) : 20,
});
}
@Get('types')
@ApiOperation({ summary: 'Get all feat types' })
@ApiResponse({ status: 200, description: 'List of feat types' })
async getFeatTypes() {
return this.featsService.getFeatTypes();
}
@Get('classes')
@ApiOperation({ summary: 'Get all classes that have class feats' })
@ApiResponse({ status: 200, description: 'List of class names' })
async getClasses() {
return this.featsService.getClasses();
}
@Get('ancestries')
@ApiOperation({ summary: 'Get all ancestries that have ancestry feats' })
@ApiResponse({ status: 200, description: 'List of ancestry names' })
async getAncestries() {
return this.featsService.getAncestries();
}
@Get('traits')
@ApiOperation({ summary: 'Get all unique traits from feats' })
@ApiResponse({ status: 200, description: 'List of traits' })
async getTraits() {
return this.featsService.getTraits();
}
@Get(':id')
@ApiOperation({ summary: 'Get feat by ID' })
@ApiResponse({ status: 200, description: 'Feat details' })
@ApiResponse({ status: 404, description: 'Feat not found' })
async findById(@Param('id') id: string) {
const feat = await this.featsService.findById(id);
if (!feat) {
throw new NotFoundException('Feat not found');
}
return feat;
}
@Get('by-name/:name')
@ApiOperation({ summary: 'Get feat by name' })
@ApiResponse({ status: 200, description: 'Feat details' })
@ApiResponse({ status: 404, description: 'Feat not found' })
async findByName(@Param('name') name: string) {
const feat = await this.featsService.findByName(decodeURIComponent(name));
if (!feat) {
throw new NotFoundException('Feat not found');
}
return feat;
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { FeatsController } from './feats.controller';
import { FeatsService } from './feats.service';
import { PrismaModule } from '../../prisma/prisma.module';
import { TranslationsModule } from '../translations/translations.module';
@Module({
imports: [PrismaModule, TranslationsModule],
controllers: [FeatsController],
providers: [FeatsService],
exports: [FeatsService],
})
export class FeatsModule {}

View File

@@ -0,0 +1,211 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { TranslationsService } from '../translations/translations.service';
import { TranslationType } from '../../generated/prisma/client.js';
export interface FeatSearchParams {
query?: string;
featType?: string;
className?: string;
ancestryName?: string;
skillName?: string;
minLevel?: number;
maxLevel?: number;
rarity?: string;
traits?: string[];
page?: number;
limit?: number;
}
@Injectable()
export class FeatsService {
constructor(
private prisma: PrismaService,
private translationsService: TranslationsService,
) {}
async search(params: FeatSearchParams) {
const {
query,
featType,
className,
ancestryName,
skillName,
minLevel,
maxLevel,
rarity,
traits,
page = 1,
limit = 20,
} = params;
const where: any = {};
// Text search on name
if (query) {
where.OR = [
{ name: { contains: query, mode: 'insensitive' } },
{ nameGerman: { contains: query, mode: 'insensitive' } },
{ summary: { contains: query, mode: 'insensitive' } },
];
}
// Feat type filter
if (featType) {
where.featType = featType;
}
// Class filter
if (className) {
where.className = className;
}
// Ancestry filter
if (ancestryName) {
where.ancestryName = ancestryName;
}
// Skill filter
if (skillName) {
where.skillName = skillName;
}
// Level range filter
if (minLevel !== undefined) {
where.level = { ...where.level, gte: minLevel };
}
if (maxLevel !== undefined) {
where.level = { ...where.level, lte: maxLevel };
}
// Rarity filter
if (rarity) {
where.rarity = rarity;
}
// Traits filter (must have all specified traits)
if (traits && traits.length > 0) {
where.traits = { hasEvery: traits };
}
const skip = (page - 1) * limit;
const [items, total] = await Promise.all([
this.prisma.feat.findMany({
where,
skip,
take: limit,
orderBy: [{ level: 'asc' }, { name: 'asc' }],
}),
this.prisma.feat.count({ where }),
]);
return {
items,
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
async findById(id: string) {
return this.prisma.feat.findUnique({
where: { id },
});
}
async findByName(name: string) {
// Try exact match first
let feat = await this.prisma.feat.findUnique({
where: { name },
});
// If not found, try case-insensitive search
if (!feat) {
feat = await this.prisma.feat.findFirst({
where: {
OR: [
{ name: { equals: name, mode: 'insensitive' } },
{ nameGerman: { equals: name, mode: 'insensitive' } },
],
},
});
}
return feat;
}
async getFeatTypes() {
const result = await this.prisma.feat.groupBy({
by: ['featType'],
where: { featType: { not: null } },
orderBy: { featType: 'asc' },
});
return result.map((r) => r.featType).filter(Boolean);
}
async getClasses() {
const result = await this.prisma.feat.groupBy({
by: ['className'],
where: { className: { not: null } },
orderBy: { className: 'asc' },
});
return result.map((r) => r.className).filter(Boolean);
}
async getAncestries() {
const result = await this.prisma.feat.groupBy({
by: ['ancestryName'],
where: { ancestryName: { not: null } },
orderBy: { ancestryName: 'asc' },
});
return result.map((r) => r.ancestryName).filter(Boolean);
}
async getTraits() {
// Get all unique traits across all feats
const feats = await this.prisma.feat.findMany({
select: { traits: true },
where: { traits: { isEmpty: false } },
});
const traitsSet = new Set<string>();
feats.forEach((f) => f.traits.forEach((t) => traitsSet.add(t)));
return Array.from(traitsSet).sort();
}
// Get translation for a feat (with caching)
async getTranslatedFeat(feat: { name: string; summary?: string | null }) {
const translation = await this.translationsService.getTranslation(
TranslationType.FEAT,
feat.name,
feat.summary || undefined,
);
return {
nameGerman: translation.germanName,
summaryGerman: translation.germanDescription,
};
}
// Update feat with German translation
async updateFeatTranslation(id: string) {
const feat = await this.prisma.feat.findUnique({ where: { id } });
if (!feat) return null;
const translation = await this.getTranslatedFeat(feat);
return this.prisma.feat.update({
where: { id },
data: {
nameGerman: translation.nameGerman,
summaryGerman: translation.summaryGerman,
},
});
}
}

View File

@@ -0,0 +1,3 @@
export * from './feats.module';
export * from './feats.service';
export * from './feats.controller';

View File

@@ -25,7 +25,7 @@ export class TranslationsService {
where: { type_englishName: { type, englishName } },
});
if (cached && !this.isIncomplete(cached.germanDescription)) {
if (cached && this.isValidTranslation(cached, englishDescription)) {
this.logger.debug(`Cache hit for ${type}: ${englishName}`);
return {
englishName: cached.englishName,
@@ -76,7 +76,7 @@ export class TranslationsService {
for (const item of items) {
const cachedItem = cachedMap.get(item.englishName);
if (cachedItem && !this.isIncomplete(cachedItem.germanDescription)) {
if (cachedItem && this.isValidTranslation(cachedItem, item.englishDescription)) {
result.set(item.englishName, {
englishName: cachedItem.englishName,
germanName: cachedItem.germanName,
@@ -150,10 +150,35 @@ export class TranslationsService {
}
/**
* Check if a translation is incomplete (truncated)
* Check if a cached translation is valid and complete
*/
private isIncomplete(description?: string | null): boolean {
if (!description) return false;
return description.trim().endsWith('…') || description.trim().endsWith('...');
private isValidTranslation(
cached: { englishName: string; germanName: string; germanDescription?: string | null; quality: string },
requestedDescription?: string,
): boolean {
// LOW quality means the translation failed or API was unavailable
if (cached.quality === 'LOW') {
return false;
}
// If germanName equals englishName, it wasn't actually translated
if (cached.germanName === cached.englishName) {
return false;
}
// Check if description is incomplete (truncated)
if (cached.germanDescription) {
const desc = cached.germanDescription.trim();
if (desc.endsWith('…') || desc.endsWith('...')) {
return false;
}
}
// If a description was requested but not cached, re-translate
if (requestedDescription && !cached.germanDescription) {
return false;
}
return true;
}
}