feat: Add GM library for battle maps and NPC templates
All checks were successful
Deploy Dimension47 / deploy (push) Successful in 35s
All checks were successful
Deploy Dimension47 / deploy (push) Successful in 35s
- Add library page with tabs for maps and combatants - Create map upload modal with grid configuration - Create NPC/monster template modal with abilities - Add library link to campaign page (GM only) - Add battle feature TODO documentation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ import { LoginPage, RegisterPage, useAuthStore } from '@/features/auth';
|
|||||||
import { CampaignsPage, CampaignDetailPage } from '@/features/campaigns';
|
import { CampaignsPage, CampaignDetailPage } from '@/features/campaigns';
|
||||||
import { CharacterSheetPage } from '@/features/characters';
|
import { CharacterSheetPage } from '@/features/characters';
|
||||||
import { BattlePage } from '@/features/battle';
|
import { BattlePage } from '@/features/battle';
|
||||||
|
import { LibraryPage } from '@/features/library';
|
||||||
import { ProtectedRoute } from '@/shared/components/protected-route';
|
import { ProtectedRoute } from '@/shared/components/protected-route';
|
||||||
import { Layout } from '@/shared/components/layout';
|
import { Layout } from '@/shared/components/layout';
|
||||||
|
|
||||||
@@ -48,7 +49,7 @@ function AppContent() {
|
|||||||
<Route path="/campaigns/:id" element={<CampaignDetailPage />} />
|
<Route path="/campaigns/:id" element={<CampaignDetailPage />} />
|
||||||
<Route path="/campaigns/:id/characters/:characterId" element={<CharacterSheetPage />} />
|
<Route path="/campaigns/:id/characters/:characterId" element={<CharacterSheetPage />} />
|
||||||
<Route path="/campaigns/:id/battle" element={<BattlePage />} />
|
<Route path="/campaigns/:id/battle" element={<BattlePage />} />
|
||||||
<Route path="/library" element={<div>Library (TODO)</div>} />
|
<Route path="/campaigns/:id/library" element={<LibraryPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import {
|
|||||||
Crown,
|
Crown,
|
||||||
Shield,
|
Shield,
|
||||||
Heart,
|
Heart,
|
||||||
FileJson
|
FileJson,
|
||||||
|
Library
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -268,7 +269,7 @@ export function CampaignDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Actions */}
|
{/* Quick Actions */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
<Card className="p-4 hover:border-border-hover cursor-pointer transition-colors active:scale-[0.98]" onClick={() => navigate(`/campaigns/${id}/battle`)}>
|
<Card className="p-4 hover:border-border-hover cursor-pointer transition-colors active:scale-[0.98]" onClick={() => navigate(`/campaigns/${id}/battle`)}>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="h-10 w-10 rounded-lg bg-red-500/20 flex items-center justify-center flex-shrink-0">
|
<div className="h-10 w-10 rounded-lg bg-red-500/20 flex items-center justify-center flex-shrink-0">
|
||||||
@@ -280,6 +281,19 @@ export function CampaignDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
{canManage && (
|
||||||
|
<Card className="p-4 hover:border-border-hover cursor-pointer transition-colors active:scale-[0.98]" onClick={() => navigate(`/campaigns/${id}/library`)}>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-10 w-10 rounded-lg bg-purple-500/20 flex items-center justify-center flex-shrink-0">
|
||||||
|
<Library className="h-5 w-5 text-purple-500" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-medium text-text-primary">Bibliothek</p>
|
||||||
|
<p className="text-sm text-text-secondary">Karten & NPCs</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
<Card className="p-4 hover:border-border-hover cursor-pointer transition-colors opacity-50">
|
<Card className="p-4 hover:border-border-hover cursor-pointer transition-colors opacity-50">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="h-10 w-10 rounded-lg bg-blue-500/20 flex items-center justify-center flex-shrink-0">
|
<div className="h-10 w-10 rounded-lg bg-blue-500/20 flex items-center justify-center flex-shrink-0">
|
||||||
|
|||||||
@@ -0,0 +1,390 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { X, Plus, Trash2 } from 'lucide-react';
|
||||||
|
import { cn } from '@/shared/lib/utils';
|
||||||
|
|
||||||
|
interface CombatantAbility {
|
||||||
|
name: string;
|
||||||
|
actionCost: number;
|
||||||
|
actionType: 'ACTION' | 'REACTION' | 'FREE';
|
||||||
|
description: string;
|
||||||
|
damage?: string;
|
||||||
|
traits?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateCombatantModalProps {
|
||||||
|
onClose: () => void;
|
||||||
|
onCreate: (data: {
|
||||||
|
name: string;
|
||||||
|
type: 'NPC' | 'MONSTER';
|
||||||
|
level: number;
|
||||||
|
hpMax: number;
|
||||||
|
ac: number;
|
||||||
|
fortitude: number;
|
||||||
|
reflex: number;
|
||||||
|
will: number;
|
||||||
|
perception: number;
|
||||||
|
speed: number;
|
||||||
|
description?: string;
|
||||||
|
abilities: CombatantAbility[];
|
||||||
|
}) => void;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateCombatantModal({ onClose, onCreate, isLoading }: CreateCombatantModalProps) {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [type, setType] = useState<'NPC' | 'MONSTER'>('NPC');
|
||||||
|
const [level, setLevel] = useState(1);
|
||||||
|
const [hpMax, setHpMax] = useState(20);
|
||||||
|
const [ac, setAc] = useState(15);
|
||||||
|
const [fortitude, setFortitude] = useState(5);
|
||||||
|
const [reflex, setReflex] = useState(5);
|
||||||
|
const [will, setWill] = useState(5);
|
||||||
|
const [perception, setPerception] = useState(5);
|
||||||
|
const [speed, setSpeed] = useState(25);
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [abilities, setAbilities] = useState<CombatantAbility[]>([]);
|
||||||
|
const [showAddAbility, setShowAddAbility] = useState(false);
|
||||||
|
|
||||||
|
// Ability form state
|
||||||
|
const [abilityName, setAbilityName] = useState('');
|
||||||
|
const [abilityActionCost, setAbilityActionCost] = useState(1);
|
||||||
|
const [abilityActionType, setAbilityActionType] = useState<'ACTION' | 'REACTION' | 'FREE'>('ACTION');
|
||||||
|
const [abilityDescription, setAbilityDescription] = useState('');
|
||||||
|
const [abilityDamage, setAbilityDamage] = useState('');
|
||||||
|
|
||||||
|
const handleAddAbility = () => {
|
||||||
|
if (!abilityName.trim() || !abilityDescription.trim()) return;
|
||||||
|
|
||||||
|
setAbilities([
|
||||||
|
...abilities,
|
||||||
|
{
|
||||||
|
name: abilityName.trim(),
|
||||||
|
actionCost: abilityActionCost,
|
||||||
|
actionType: abilityActionType,
|
||||||
|
description: abilityDescription.trim(),
|
||||||
|
damage: abilityDamage.trim() || undefined,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Reset ability form
|
||||||
|
setAbilityName('');
|
||||||
|
setAbilityActionCost(1);
|
||||||
|
setAbilityActionType('ACTION');
|
||||||
|
setAbilityDescription('');
|
||||||
|
setAbilityDamage('');
|
||||||
|
setShowAddAbility(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveAbility = (index: number) => {
|
||||||
|
setAbilities(abilities.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!name.trim()) return;
|
||||||
|
|
||||||
|
onCreate({
|
||||||
|
name: name.trim(),
|
||||||
|
type,
|
||||||
|
level,
|
||||||
|
hpMax,
|
||||||
|
ac,
|
||||||
|
fortitude,
|
||||||
|
reflex,
|
||||||
|
will,
|
||||||
|
perception,
|
||||||
|
speed,
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
abilities,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70">
|
||||||
|
<div className="bg-bg-secondary border border-border-default rounded-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-border-default sticky top-0 bg-bg-secondary z-10">
|
||||||
|
<h2 className="text-lg font-semibold text-text-primary">NPC/Monster erstellen</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1 rounded-lg hover:bg-white/10 text-text-secondary transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<form onSubmit={handleSubmit} className="p-4 space-y-4">
|
||||||
|
{/* Basic Info */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="col-span-2 sm:col-span-1">
|
||||||
|
<label className="block text-sm font-medium text-text-secondary mb-2">Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="z.B. Goblin Krieger"
|
||||||
|
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded-lg text-text-primary placeholder:text-text-secondary focus:outline-none focus:border-primary"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text-secondary mb-2">Typ</label>
|
||||||
|
<select
|
||||||
|
value={type}
|
||||||
|
onChange={(e) => setType(e.target.value as 'NPC' | 'MONSTER')}
|
||||||
|
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded-lg text-text-primary focus:outline-none focus:border-primary"
|
||||||
|
>
|
||||||
|
<option value="NPC">NPC</option>
|
||||||
|
<option value="MONSTER">Monster</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Row 1 */}
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text-secondary mb-2">Level</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={level}
|
||||||
|
onChange={(e) => setLevel(parseInt(e.target.value) || 0)}
|
||||||
|
min={-1}
|
||||||
|
max={30}
|
||||||
|
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded-lg text-text-primary focus:outline-none focus:border-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text-secondary mb-2">HP</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={hpMax}
|
||||||
|
onChange={(e) => setHpMax(parseInt(e.target.value) || 1)}
|
||||||
|
min={1}
|
||||||
|
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded-lg text-text-primary focus:outline-none focus:border-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text-secondary mb-2">RK</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={ac}
|
||||||
|
onChange={(e) => setAc(parseInt(e.target.value) || 0)}
|
||||||
|
min={0}
|
||||||
|
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded-lg text-text-primary focus:outline-none focus:border-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Saves */}
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text-secondary mb-2">Zähigkeit</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={fortitude}
|
||||||
|
onChange={(e) => setFortitude(parseInt(e.target.value) || 0)}
|
||||||
|
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded-lg text-text-primary focus:outline-none focus:border-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text-secondary mb-2">Reflex</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={reflex}
|
||||||
|
onChange={(e) => setReflex(parseInt(e.target.value) || 0)}
|
||||||
|
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded-lg text-text-primary focus:outline-none focus:border-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text-secondary mb-2">Wille</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={will}
|
||||||
|
onChange={(e) => setWill(parseInt(e.target.value) || 0)}
|
||||||
|
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded-lg text-text-primary focus:outline-none focus:border-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Perception & Speed */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text-secondary mb-2">Wahrnehmung</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={perception}
|
||||||
|
onChange={(e) => setPerception(parseInt(e.target.value) || 0)}
|
||||||
|
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded-lg text-text-primary focus:outline-none focus:border-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text-secondary mb-2">Geschwindigkeit (ft)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={speed}
|
||||||
|
onChange={(e) => setSpeed(parseInt(e.target.value) || 0)}
|
||||||
|
min={0}
|
||||||
|
step={5}
|
||||||
|
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded-lg text-text-primary focus:outline-none focus:border-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text-secondary mb-2">Beschreibung (optional)</label>
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Notizen, Taktiken, besondere Merkmale..."
|
||||||
|
rows={2}
|
||||||
|
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded-lg text-text-primary placeholder:text-text-secondary focus:outline-none focus:border-primary resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Abilities */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<label className="text-sm font-medium text-text-secondary">Fähigkeiten</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowAddAbility(!showAddAbility)}
|
||||||
|
className="text-sm text-primary hover:text-primary/80 flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Hinzufügen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add Ability Form */}
|
||||||
|
{showAddAbility && (
|
||||||
|
<div className="p-3 bg-bg-tertiary rounded-lg space-y-3 mb-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={abilityName}
|
||||||
|
onChange={(e) => setAbilityName(e.target.value)}
|
||||||
|
placeholder="Name (z.B. Schwerthieb)"
|
||||||
|
className="px-3 py-2 bg-bg-secondary border border-border-default rounded-lg text-text-primary text-sm focus:outline-none focus:border-primary"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<select
|
||||||
|
value={abilityActionCost}
|
||||||
|
onChange={(e) => setAbilityActionCost(parseInt(e.target.value))}
|
||||||
|
className="flex-1 px-2 py-2 bg-bg-secondary border border-border-default rounded-lg text-text-primary text-sm focus:outline-none focus:border-primary"
|
||||||
|
>
|
||||||
|
<option value={0}>0</option>
|
||||||
|
<option value={1}>1</option>
|
||||||
|
<option value={2}>2</option>
|
||||||
|
<option value={3}>3</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={abilityActionType}
|
||||||
|
onChange={(e) => setAbilityActionType(e.target.value as 'ACTION' | 'REACTION' | 'FREE')}
|
||||||
|
className="flex-1 px-2 py-2 bg-bg-secondary border border-border-default rounded-lg text-text-primary text-sm focus:outline-none focus:border-primary"
|
||||||
|
>
|
||||||
|
<option value="ACTION">Aktion</option>
|
||||||
|
<option value="REACTION">Reaktion</option>
|
||||||
|
<option value="FREE">Frei</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={abilityDamage}
|
||||||
|
onChange={(e) => setAbilityDamage(e.target.value)}
|
||||||
|
placeholder="Schaden (z.B. 1d8+4 Hieb)"
|
||||||
|
className="w-full px-3 py-2 bg-bg-secondary border border-border-default rounded-lg text-text-primary text-sm focus:outline-none focus:border-primary"
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
value={abilityDescription}
|
||||||
|
onChange={(e) => setAbilityDescription(e.target.value)}
|
||||||
|
placeholder="Beschreibung..."
|
||||||
|
rows={2}
|
||||||
|
className="w-full px-3 py-2 bg-bg-secondary border border-border-default rounded-lg text-text-primary text-sm focus:outline-none focus:border-primary resize-none"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowAddAbility(false)}
|
||||||
|
className="flex-1 px-3 py-1.5 bg-bg-secondary text-text-secondary rounded-lg text-sm hover:bg-white/10"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAddAbility}
|
||||||
|
disabled={!abilityName.trim() || !abilityDescription.trim()}
|
||||||
|
className="flex-1 px-3 py-1.5 bg-primary text-white rounded-lg text-sm hover:bg-primary/80 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Hinzufügen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Ability List */}
|
||||||
|
{abilities.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{abilities.map((ability, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-start justify-between gap-2 p-2 bg-bg-tertiary rounded-lg"
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-text-primary text-sm">{ability.name}</span>
|
||||||
|
<span className="text-xs px-1.5 py-0.5 bg-primary/20 text-primary rounded">
|
||||||
|
{ability.actionCost > 0 ? `${ability.actionCost} Aktion${ability.actionCost > 1 ? 'en' : ''}` : ability.actionType === 'REACTION' ? 'Reaktion' : 'Frei'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{ability.damage && (
|
||||||
|
<p className="text-xs text-red-400 mt-0.5">{ability.damage}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-text-secondary mt-0.5 line-clamp-2">{ability.description}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRemoveAbility(index)}
|
||||||
|
className="p-1 text-text-secondary hover:text-red-400"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-text-secondary text-center py-4 bg-bg-tertiary rounded-lg">
|
||||||
|
Keine Fähigkeiten hinzugefügt
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex-1 px-4 py-2 bg-bg-tertiary text-text-primary rounded-lg hover:bg-white/10 transition-colors"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!name.trim() || isLoading}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 px-4 py-2 rounded-lg font-medium transition-colors',
|
||||||
|
name.trim() && !isLoading
|
||||||
|
? 'bg-primary text-white hover:bg-primary/80'
|
||||||
|
: 'bg-primary/50 text-white/50 cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Erstellen...' : 'Erstellen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
324
client/src/features/library/components/library-page.tsx
Normal file
324
client/src/features/library/components/library-page.tsx
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { ArrowLeft, Map, Users, Plus, Trash2, Image, Swords } from 'lucide-react';
|
||||||
|
import { api } from '@/shared/lib/api';
|
||||||
|
import { useAuthStore } from '@/features/auth';
|
||||||
|
import { UploadMapModal } from './upload-map-modal';
|
||||||
|
import { CreateCombatantModal } from './create-combatant-modal';
|
||||||
|
import type { BattleMap, Combatant } from '@/shared/types';
|
||||||
|
import { cn } from '@/shared/lib/utils';
|
||||||
|
|
||||||
|
type Tab = 'maps' | 'combatants';
|
||||||
|
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL?.replace('/api', '') || 'http://localhost:5000';
|
||||||
|
|
||||||
|
export function LibraryPage() {
|
||||||
|
const { id: campaignId } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { user } = useAuthStore();
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<Tab>('maps');
|
||||||
|
const [showUploadMap, setShowUploadMap] = useState(false);
|
||||||
|
const [showCreateCombatant, setShowCreateCombatant] = useState(false);
|
||||||
|
|
||||||
|
// Fetch campaign to check GM status
|
||||||
|
const { data: campaign } = useQuery({
|
||||||
|
queryKey: ['campaign', campaignId],
|
||||||
|
queryFn: () => api.getCampaign(campaignId!),
|
||||||
|
enabled: !!campaignId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isGM = campaign?.gmId === user?.id;
|
||||||
|
|
||||||
|
// Redirect non-GMs
|
||||||
|
if (campaign && !isGM) {
|
||||||
|
navigate(`/campaigns/${campaignId}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch battle maps
|
||||||
|
const { data: maps = [], isLoading: mapsLoading } = useQuery({
|
||||||
|
queryKey: ['battleMaps', campaignId],
|
||||||
|
queryFn: () => api.getBattleMaps(campaignId!),
|
||||||
|
enabled: !!campaignId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch combatants
|
||||||
|
const { data: combatants = [], isLoading: combatantsLoading } = useQuery({
|
||||||
|
queryKey: ['combatants', campaignId],
|
||||||
|
queryFn: () => api.getCombatants(campaignId!),
|
||||||
|
enabled: !!campaignId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Upload map mutation
|
||||||
|
const uploadMapMutation = useMutation({
|
||||||
|
mutationFn: (data: Parameters<typeof api.uploadBattleMap>[1]) =>
|
||||||
|
api.uploadBattleMap(campaignId!, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['battleMaps', campaignId] });
|
||||||
|
setShowUploadMap(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete map mutation
|
||||||
|
const deleteMapMutation = useMutation({
|
||||||
|
mutationFn: (mapId: string) => api.deleteBattleMap(campaignId!, mapId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['battleMaps', campaignId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create combatant mutation
|
||||||
|
const createCombatantMutation = useMutation({
|
||||||
|
mutationFn: (data: Parameters<typeof api.createCombatant>[1]) =>
|
||||||
|
api.createCombatant(campaignId!, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['combatants', campaignId] });
|
||||||
|
setShowCreateCombatant(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete combatant mutation
|
||||||
|
const deleteCombatantMutation = useMutation({
|
||||||
|
mutationFn: (combatantId: string) => api.deleteCombatant(campaignId!, combatantId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['combatants', campaignId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-bg-primary">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="sticky top-0 z-10 bg-bg-secondary border-b border-border-default">
|
||||||
|
<div className="max-w-6xl mx-auto px-4 py-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/campaigns/${campaignId}`)}
|
||||||
|
className="p-2 rounded-lg hover:bg-white/10 text-text-secondary transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-semibold text-text-primary">Bibliothek</h1>
|
||||||
|
<p className="text-sm text-text-secondary">{campaign?.name}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="max-w-6xl mx-auto px-4">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('maps')}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 transition-colors',
|
||||||
|
activeTab === 'maps'
|
||||||
|
? 'border-primary text-primary'
|
||||||
|
: 'border-transparent text-text-secondary hover:text-text-primary'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Map className="w-4 h-4" />
|
||||||
|
Karten ({maps.length})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('combatants')}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 transition-colors',
|
||||||
|
activeTab === 'combatants'
|
||||||
|
? 'border-primary text-primary'
|
||||||
|
: 'border-transparent text-text-secondary hover:text-text-primary'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Users className="w-4 h-4" />
|
||||||
|
NPCs ({combatants.length})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<main className="max-w-6xl mx-auto px-4 py-6">
|
||||||
|
{activeTab === 'maps' ? (
|
||||||
|
<div>
|
||||||
|
{/* Add Map Button */}
|
||||||
|
<div className="flex justify-end mb-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowUploadMap(true)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/80 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Karte hochladen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Maps Grid */}
|
||||||
|
{mapsLoading ? (
|
||||||
|
<div className="text-center py-12 text-text-secondary">Laden...</div>
|
||||||
|
) : maps.length === 0 ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<Image className="w-12 h-12 text-text-secondary mx-auto mb-3" />
|
||||||
|
<p className="text-text-secondary">Noch keine Karten hochgeladen</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowUploadMap(true)}
|
||||||
|
className="mt-4 text-primary hover:text-primary/80"
|
||||||
|
>
|
||||||
|
Erste Karte hochladen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{maps.map((map: BattleMap) => (
|
||||||
|
<div
|
||||||
|
key={map.id}
|
||||||
|
className="bg-bg-secondary border border-border-default rounded-lg overflow-hidden group"
|
||||||
|
>
|
||||||
|
{/* Map Preview */}
|
||||||
|
<div className="aspect-video bg-black/30 relative">
|
||||||
|
<img
|
||||||
|
src={`${API_URL}${map.imageUrl}`}
|
||||||
|
alt={map.name}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
{/* Overlay with actions */}
|
||||||
|
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm('Karte wirklich löschen?')) {
|
||||||
|
deleteMapMutation.mutate(map.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="p-2 bg-red-500/20 text-red-400 rounded-lg hover:bg-red-500/30"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Map Info */}
|
||||||
|
<div className="p-3">
|
||||||
|
<h3 className="font-medium text-text-primary truncate">{map.name}</h3>
|
||||||
|
<p className="text-sm text-text-secondary">
|
||||||
|
{map.gridSizeX} x {map.gridSizeY} Grid
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
{/* Add Combatant Button */}
|
||||||
|
<div className="flex justify-end mb-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateCombatant(true)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/80 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
NPC erstellen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Combatants List */}
|
||||||
|
{combatantsLoading ? (
|
||||||
|
<div className="text-center py-12 text-text-secondary">Laden...</div>
|
||||||
|
) : combatants.length === 0 ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<Swords className="w-12 h-12 text-text-secondary mx-auto mb-3" />
|
||||||
|
<p className="text-text-secondary">Noch keine NPCs erstellt</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateCombatant(true)}
|
||||||
|
className="mt-4 text-primary hover:text-primary/80"
|
||||||
|
>
|
||||||
|
Ersten NPC erstellen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{combatants.map((combatant: Combatant) => (
|
||||||
|
<div
|
||||||
|
key={combatant.id}
|
||||||
|
className="bg-bg-secondary border border-border-default rounded-lg p-4 flex items-center justify-between gap-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4 min-w-0 flex-1">
|
||||||
|
{/* Avatar */}
|
||||||
|
<div className={cn(
|
||||||
|
'w-12 h-12 rounded-lg flex items-center justify-center flex-shrink-0',
|
||||||
|
combatant.type === 'MONSTER' ? 'bg-red-500/20' : 'bg-blue-500/20'
|
||||||
|
)}>
|
||||||
|
<Swords className={cn(
|
||||||
|
'w-6 h-6',
|
||||||
|
combatant.type === 'MONSTER' ? 'text-red-400' : 'text-blue-400'
|
||||||
|
)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="font-medium text-text-primary truncate">{combatant.name}</h3>
|
||||||
|
<span className={cn(
|
||||||
|
'text-xs px-1.5 py-0.5 rounded',
|
||||||
|
combatant.type === 'MONSTER'
|
||||||
|
? 'bg-red-500/20 text-red-400'
|
||||||
|
: 'bg-blue-500/20 text-blue-400'
|
||||||
|
)}>
|
||||||
|
{combatant.type === 'MONSTER' ? 'Monster' : 'NPC'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-sm text-text-secondary mt-1">
|
||||||
|
<span>Lvl {combatant.level}</span>
|
||||||
|
<span>HP {combatant.hpMax}</span>
|
||||||
|
<span>RK {combatant.ac}</span>
|
||||||
|
<span className="hidden sm:inline">
|
||||||
|
Rett: +{combatant.fortitude}/+{combatant.reflex}/+{combatant.will}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{combatant.abilities && combatant.abilities.length > 0 && (
|
||||||
|
<p className="text-xs text-text-secondary mt-1">
|
||||||
|
{combatant.abilities.length} Fähigkeit{combatant.abilities.length > 1 ? 'en' : ''}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm('NPC wirklich löschen?')) {
|
||||||
|
deleteCombatantMutation.mutate(combatant.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="p-2 text-text-secondary hover:text-red-400 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Modals */}
|
||||||
|
{showUploadMap && (
|
||||||
|
<UploadMapModal
|
||||||
|
onClose={() => setShowUploadMap(false)}
|
||||||
|
onUpload={(data) => uploadMapMutation.mutate(data)}
|
||||||
|
isLoading={uploadMapMutation.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showCreateCombatant && (
|
||||||
|
<CreateCombatantModal
|
||||||
|
onClose={() => setShowCreateCombatant(false)}
|
||||||
|
onCreate={(data) => createCombatantMutation.mutate(data)}
|
||||||
|
isLoading={createCombatantMutation.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
231
client/src/features/library/components/upload-map-modal.tsx
Normal file
231
client/src/features/library/components/upload-map-modal.tsx
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
import { useState, useRef } from 'react';
|
||||||
|
import { X, Upload, Image } from 'lucide-react';
|
||||||
|
import { cn } from '@/shared/lib/utils';
|
||||||
|
|
||||||
|
interface UploadMapModalProps {
|
||||||
|
onClose: () => void;
|
||||||
|
onUpload: (data: {
|
||||||
|
name: string;
|
||||||
|
gridSizeX: number;
|
||||||
|
gridSizeY: number;
|
||||||
|
gridOffsetX: number;
|
||||||
|
gridOffsetY: number;
|
||||||
|
image: File;
|
||||||
|
}) => void;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UploadMapModal({ onClose, onUpload, isLoading }: UploadMapModalProps) {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [gridSizeX, setGridSizeX] = useState(20);
|
||||||
|
const [gridSizeY, setGridSizeY] = useState(20);
|
||||||
|
const [gridOffsetX, setGridOffsetX] = useState(0);
|
||||||
|
const [gridOffsetY, setGridOffsetY] = useState(0);
|
||||||
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
|
const [preview, setPreview] = useState<string | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
setSelectedFile(file);
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onloadend = () => {
|
||||||
|
setPreview(reader.result as string);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
|
||||||
|
// Auto-fill name from filename if empty
|
||||||
|
if (!name) {
|
||||||
|
setName(file.name.replace(/\.[^/.]+$/, ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!selectedFile || !name.trim()) return;
|
||||||
|
|
||||||
|
onUpload({
|
||||||
|
name: name.trim(),
|
||||||
|
gridSizeX,
|
||||||
|
gridSizeY,
|
||||||
|
gridOffsetX,
|
||||||
|
gridOffsetY,
|
||||||
|
image: selectedFile,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70">
|
||||||
|
<div className="bg-bg-secondary border border-border-default rounded-xl w-full max-w-lg max-h-[90vh] overflow-y-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-border-default">
|
||||||
|
<h2 className="text-lg font-semibold text-text-primary">Karte hochladen</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1 rounded-lg hover:bg-white/10 text-text-secondary transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<form onSubmit={handleSubmit} className="p-4 space-y-4">
|
||||||
|
{/* File Upload */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text-secondary mb-2">
|
||||||
|
Kartenbild
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp"
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
{preview ? (
|
||||||
|
<div className="relative">
|
||||||
|
<img
|
||||||
|
src={preview}
|
||||||
|
alt="Preview"
|
||||||
|
className="w-full h-48 object-contain bg-black/30 rounded-lg"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedFile(null);
|
||||||
|
setPreview(null);
|
||||||
|
}}
|
||||||
|
className="absolute top-2 right-2 p-1 rounded-full bg-black/50 hover:bg-black/70 text-white"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="w-full h-48 border-2 border-dashed border-border-default rounded-lg flex flex-col items-center justify-center gap-2 hover:border-primary hover:bg-primary/5 transition-colors"
|
||||||
|
>
|
||||||
|
<Image className="w-8 h-8 text-text-secondary" />
|
||||||
|
<span className="text-sm text-text-secondary">Klicken zum Auswählen</span>
|
||||||
|
<span className="text-xs text-text-secondary">PNG, JPG, WebP (max. 10MB)</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Name */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text-secondary mb-2">
|
||||||
|
Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="z.B. Taverne im Wald"
|
||||||
|
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded-lg text-text-primary placeholder:text-text-secondary focus:outline-none focus:border-primary"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grid Size */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text-secondary mb-2">
|
||||||
|
Grid Spalten (X)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={gridSizeX}
|
||||||
|
onChange={(e) => setGridSizeX(parseInt(e.target.value) || 1)}
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded-lg text-text-primary focus:outline-none focus:border-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text-secondary mb-2">
|
||||||
|
Grid Reihen (Y)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={gridSizeY}
|
||||||
|
onChange={(e) => setGridSizeY(parseInt(e.target.value) || 1)}
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded-lg text-text-primary focus:outline-none focus:border-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grid Offset */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text-secondary mb-2">
|
||||||
|
Offset X (px)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={gridOffsetX}
|
||||||
|
onChange={(e) => setGridOffsetX(parseInt(e.target.value) || 0)}
|
||||||
|
min={0}
|
||||||
|
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded-lg text-text-primary focus:outline-none focus:border-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text-secondary mb-2">
|
||||||
|
Offset Y (px)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={gridOffsetY}
|
||||||
|
onChange={(e) => setGridOffsetY(parseInt(e.target.value) || 0)}
|
||||||
|
min={0}
|
||||||
|
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded-lg text-text-primary focus:outline-none focus:border-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-text-secondary">
|
||||||
|
Offset ist nützlich für Karten mit vorgezeichnetem Grid, um das digitale Grid auszurichten.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex-1 px-4 py-2 bg-bg-tertiary text-text-primary rounded-lg hover:bg-white/10 transition-colors"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!selectedFile || !name.trim() || isLoading}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 px-4 py-2 rounded-lg font-medium transition-colors flex items-center justify-center gap-2',
|
||||||
|
selectedFile && name.trim() && !isLoading
|
||||||
|
? 'bg-primary text-white hover:bg-primary/80'
|
||||||
|
: 'bg-primary/50 text-white/50 cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||||
|
Hochladen...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Upload className="w-4 h-4" />
|
||||||
|
Hochladen
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
3
client/src/features/library/index.ts
Normal file
3
client/src/features/library/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { LibraryPage } from './components/library-page';
|
||||||
|
export { UploadMapModal } from './components/upload-map-modal';
|
||||||
|
export { CreateCombatantModal } from './components/create-combatant-modal';
|
||||||
134
docs/BATTLE_TODO.md
Normal file
134
docs/BATTLE_TODO.md
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
# Battle Screen - TODO
|
||||||
|
|
||||||
|
## Phase 2: Grid System
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- [ ] `grid-overlay.tsx` - Verbessertes Grid-Rendering mit Snap-Preview
|
||||||
|
- [ ] `grid-config-modal.tsx` - Grid konfigurieren (nur GM)
|
||||||
|
- Grid-Größe anpassen (Spalten x Reihen)
|
||||||
|
- Grid-Offset für vorgezeichnete Maps einstellen
|
||||||
|
- Live-Preview beim Anpassen
|
||||||
|
- Zellenfarbe und Transparenz
|
||||||
|
- [ ] Snap-to-Grid beim Token-Platzieren
|
||||||
|
- [ ] Grid Ein-/Ausblenden Toggle (bereits vorhanden, verbessern)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: Token Management
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- [ ] `add-pc-modal.tsx` - PCs aus Kampagne hinzufügen (mit Filter/Suche)
|
||||||
|
- [ ] `add-npc-modal.tsx` - NPCs aus Templates hinzufügen
|
||||||
|
- [ ] `create-combatant-modal.tsx` - Neues NPC-Template erstellen
|
||||||
|
- Name, Level, HP, AC
|
||||||
|
- Rettungswürfe (Fort, Ref, Will)
|
||||||
|
- Wahrnehmung, Geschwindigkeit
|
||||||
|
- Fähigkeiten mit Aktionskosten
|
||||||
|
- Avatar-Upload
|
||||||
|
- [ ] `token-detail-panel.tsx` - Seitenpanel für Token-Details
|
||||||
|
- HP-Management (Schaden/Heilung)
|
||||||
|
- Zustand hinzufügen/entfernen
|
||||||
|
- Notizen
|
||||||
|
- Statistiken anzeigen (für NPCs)
|
||||||
|
- [ ] `initiative-tracker.tsx` - Initiativreihenfolge
|
||||||
|
- Sortierte Liste aller Tokens
|
||||||
|
- Aktueller Zug markiert
|
||||||
|
- Nächster/Vorheriger Zug
|
||||||
|
- Verzögern-Funktion
|
||||||
|
|
||||||
|
### Backend WebSocket Events
|
||||||
|
- [ ] `token_conditions_changed` - Zustände aktualisiert
|
||||||
|
- [ ] `turn_started` - Zug beginnt (für aktiven Token)
|
||||||
|
- [ ] `delay_turn` - Zug verzögern
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: Player View
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- [ ] `battle-player-view.tsx` - Route: `/campaigns/:id/battle/view`
|
||||||
|
- Vollbild-Ansicht für Spieler (TV-Modus)
|
||||||
|
- Read-only (keine Token-Manipulation)
|
||||||
|
- Sync via gleicher WebSocket-Room
|
||||||
|
- [ ] `battle-tv-window.tsx` - Pop-out Window Logik
|
||||||
|
- "TV-Modus öffnen" Button
|
||||||
|
- Separates Browser-Fenster
|
||||||
|
- Automatische Größenanpassung
|
||||||
|
|
||||||
|
### Berechtigungen
|
||||||
|
| Feature | GM View | Player View |
|
||||||
|
|---------|---------|-------------|
|
||||||
|
| Tokens bewegen | Ja | Nein |
|
||||||
|
| PC HP sehen | Ja (Zahlen) | Ja (Zahlen) |
|
||||||
|
| NPC HP sehen | Ja (Zahlen) | Nein (nur Blut-Effekt bei <20%) |
|
||||||
|
| NPC-Stats sehen | Ja | Nein |
|
||||||
|
| Initiative bearbeiten | Ja | Nein |
|
||||||
|
| Grid konfigurieren | Ja | Nein |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: Polish & Erweiterte Features
|
||||||
|
|
||||||
|
### Drag & Drop
|
||||||
|
- [ ] Touch-Support für mobile Geräte
|
||||||
|
- [ ] Drag-Preview mit Zielzelle hervorheben
|
||||||
|
- [ ] Multi-Select (Shift+Klick)
|
||||||
|
- [ ] Mehrere Tokens gleichzeitig bewegen
|
||||||
|
|
||||||
|
### Visuelle Verbesserungen
|
||||||
|
- [ ] Condition-Badges auf Tokens
|
||||||
|
- Icon für häufige Zustände (Frightened, Slowed, etc.)
|
||||||
|
- Anzahl bei gestuften Zuständen
|
||||||
|
- [ ] Blut-Effekt bei niedrigen HP (<20%)
|
||||||
|
- [ ] Token-Avatar anstatt Initialen (wenn vorhanden)
|
||||||
|
- [ ] Verschiedene Token-Formen (rund, eckig)
|
||||||
|
- [ ] Größen-Indikator für Large/Huge/Gargantuan
|
||||||
|
|
||||||
|
### Kampf-Tools
|
||||||
|
- [ ] `combat-log.tsx` - Letzte Aktionen anzeigen
|
||||||
|
- Token bewegt
|
||||||
|
- Schaden erhalten
|
||||||
|
- Zustand hinzugefügt
|
||||||
|
- Initiative geändert
|
||||||
|
- [ ] `measure-tool.tsx` - Entfernung messen
|
||||||
|
- Klick & Drag für Linie
|
||||||
|
- Entfernung in Fuß anzeigen
|
||||||
|
- Diagonal-Bewegung nach PF2e-Regeln
|
||||||
|
- [ ] `area-tool.tsx` - Flächeneffekte
|
||||||
|
- Kegel, Linie, Emanation, Burst
|
||||||
|
- Größe einstellen
|
||||||
|
- Betroffene Felder hervorheben
|
||||||
|
|
||||||
|
### Map-Features
|
||||||
|
- [ ] Fog of War (nur GM sieht alles)
|
||||||
|
- [ ] Mehrere Ebenen (Untergrund, Obergeschoss)
|
||||||
|
- [ ] Zoom & Pan
|
||||||
|
- [ ] Map-Marker / Notizen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technische Verbesserungen
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
- [ ] Canvas-basiertes Rendering statt DOM für große Maps
|
||||||
|
- [ ] Token-Position nur bei Release senden (nicht während Drag)
|
||||||
|
- [ ] Lazy Loading für Token-Avatare
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- [ ] Combat Log in Datenbank speichern
|
||||||
|
- [ ] Battle Session Snapshots (Undo/Redo)
|
||||||
|
- [ ] Automatisches Aufräumen inaktiver Sessions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prioritäten
|
||||||
|
|
||||||
|
1. **Hoch** - Grid Config Modal (Phase 2)
|
||||||
|
2. **Hoch** - Initiative Tracker (Phase 3)
|
||||||
|
3. **Hoch** - Player View / TV-Modus (Phase 4)
|
||||||
|
4. **Mittel** - Token Detail Panel mit HP-Management
|
||||||
|
5. **Mittel** - Condition Badges
|
||||||
|
6. **Mittel** - Combatant Template erstellen
|
||||||
|
7. **Niedrig** - Measure Tool
|
||||||
|
8. **Niedrig** - Combat Log
|
||||||
|
9. **Niedrig** - Fog of War
|
||||||
Reference in New Issue
Block a user