feat: Add GM library for battle maps and NPC templates
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:
Alexander Zielonka
2026-01-30 10:28:38 +01:00
parent d6f2b62bd7
commit 4b3c0d2667
7 changed files with 1100 additions and 3 deletions

View File

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

View File

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

View File

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

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

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

View 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
View 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