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