1091 lines
47 KiB
JavaScript
1091 lines
47 KiB
JavaScript
import React, { useState, useEffect, useCallback } from 'react';
|
|
import { initializeApp } from 'firebase/app';
|
|
import { getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken } from 'firebase/auth';
|
|
import { getFirestore, doc, setDoc, addDoc, getDoc, getDocs, collection, onSnapshot, updateDoc, deleteDoc, query, where, writeBatch } from 'firebase/firestore';
|
|
import { ArrowLeft, PlusCircle, Users, Swords, Shield, Trash2, Eye, Edit3, Save, XCircle, ChevronsUpDown, ChevronDown, ChevronUp, UserCheck, UserX, HeartCrack, HeartPulse } from 'lucide-react';
|
|
|
|
// --- Firebase Configuration ---
|
|
// NOTE: Replace with your actual Firebase config
|
|
const firebaseConfig = typeof __firebase_config !== 'undefined' ? JSON.parse(__firebase_config) : {
|
|
apiKey: "YOUR_API_KEY",
|
|
authDomain: "YOUR_AUTH_DOMAIN",
|
|
projectId: "YOUR_PROJECT_ID",
|
|
storageBucket: "YOUR_STORAGE_BUCKET",
|
|
messagingSenderId: "YOUR_MESSAGING_SENDER_ID",
|
|
appId: "YOUR_APP_ID"
|
|
};
|
|
|
|
// --- Initialize Firebase ---
|
|
const app = initializeApp(firebaseConfig);
|
|
const db = getFirestore(app);
|
|
const auth = getAuth(app);
|
|
|
|
// --- Firestore Paths ---
|
|
const APP_ID = typeof __app_id !== 'undefined' ? __app_id : 'ttrpg-initiative-tracker-default';
|
|
const PUBLIC_DATA_PATH = `artifacts/${APP_ID}/public/data`;
|
|
const CAMPAIGNS_COLLECTION = `${PUBLIC_DATA_PATH}/campaigns`;
|
|
const ACTIVE_DISPLAY_DOC = `${PUBLIC_DATA_PATH}/activeDisplay/status`;
|
|
|
|
// --- Helper Functions ---
|
|
const generateId = () => crypto.randomUUID();
|
|
|
|
// --- Main App Component ---
|
|
function App() {
|
|
const [userId, setUserId] = useState(null);
|
|
const [isAuthReady, setIsAuthReady] = useState(false);
|
|
const [viewMode, setViewMode] = useState('admin'); // 'admin' or 'display'
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [error, setError] = useState(null);
|
|
|
|
// --- Authentication ---
|
|
useEffect(() => {
|
|
const initAuth = async () => {
|
|
try {
|
|
if (typeof __initial_auth_token !== 'undefined' && __initial_auth_token) {
|
|
await signInWithCustomToken(auth, __initial_auth_token);
|
|
} else {
|
|
await signInAnonymously(auth);
|
|
}
|
|
} catch (err) {
|
|
console.error("Authentication error:", err);
|
|
setError("Failed to authenticate. Please try again later.");
|
|
}
|
|
};
|
|
|
|
const unsubscribe = onAuthStateChanged(auth, (user) => {
|
|
if (user) {
|
|
setUserId(user.uid);
|
|
} else {
|
|
setUserId(null);
|
|
}
|
|
setIsAuthReady(true);
|
|
setIsLoading(false);
|
|
});
|
|
|
|
initAuth();
|
|
return () => unsubscribe();
|
|
}, []);
|
|
|
|
if (isLoading || !isAuthReady) {
|
|
return (
|
|
<div className="min-h-screen bg-slate-900 text-white flex flex-col items-center justify-center p-4">
|
|
<div className="animate-spin rounded-full h-16 w-16 border-t-4 border-blue-500 border-solid"></div>
|
|
<p className="mt-4 text-xl">Loading Initiative Tracker...</p>
|
|
{error && <p className="mt-2 text-red-400">{error}</p>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-slate-800 text-slate-100 font-sans">
|
|
<header className="bg-slate-900 p-4 shadow-lg">
|
|
<div className="container mx-auto flex justify-between items-center">
|
|
<h1 className="text-3xl font-bold text-sky-400">TTRPG Initiative Tracker</h1>
|
|
<div className="flex items-center space-x-4">
|
|
{userId && <span className="text-xs text-slate-400">UID: {userId}</span>}
|
|
<button
|
|
onClick={() => setViewMode('admin')}
|
|
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${viewMode === 'admin' ? 'bg-sky-500 text-white' : 'bg-slate-700 hover:bg-slate-600'}`}
|
|
>
|
|
Admin View
|
|
</button>
|
|
<button
|
|
onClick={() => setViewMode('display')}
|
|
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${viewMode === 'display' ? 'bg-teal-500 text-white' : 'bg-slate-700 hover:bg-slate-600'}`}
|
|
>
|
|
Player Display
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<main className="container mx-auto p-4 md:p-8">
|
|
{viewMode === 'admin' && isAuthReady && userId && <AdminView userId={userId} />}
|
|
{viewMode === 'display' && isAuthReady && <DisplayView />}
|
|
{!isAuthReady && <p>Authenticating...</p>}
|
|
</main>
|
|
<footer className="bg-slate-900 p-4 text-center text-sm text-slate-400 mt-8">
|
|
TTRPG Initiative Tracker v0.1.1
|
|
</footer>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// --- Admin View Component ---
|
|
function AdminView({ userId }) {
|
|
const [campaigns, setCampaigns] = useState([]);
|
|
const [selectedCampaignId, setSelectedCampaignId] = useState(null);
|
|
const [showCreateCampaignModal, setShowCreateCampaignModal] = useState(false);
|
|
|
|
// --- Fetch Campaigns ---
|
|
useEffect(() => {
|
|
const campaignsCollectionRef = collection(db, CAMPAIGNS_COLLECTION);
|
|
const q = query(campaignsCollectionRef);
|
|
|
|
const unsubscribe = onSnapshot(q, (snapshot) => {
|
|
const camps = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
|
|
setCampaigns(camps);
|
|
}, (err) => {
|
|
console.error("Error fetching campaigns:", err);
|
|
});
|
|
return () => unsubscribe();
|
|
}, []);
|
|
|
|
const handleCreateCampaign = async (name) => {
|
|
if (!name.trim()) return;
|
|
const newCampaignId = generateId();
|
|
try {
|
|
await setDoc(doc(db, CAMPAIGNS_COLLECTION, newCampaignId), {
|
|
name: name.trim(),
|
|
ownerId: userId,
|
|
createdAt: new Date().toISOString(),
|
|
players: [],
|
|
});
|
|
setShowCreateCampaignModal(false);
|
|
setSelectedCampaignId(newCampaignId);
|
|
} catch (err) {
|
|
console.error("Error creating campaign:", err);
|
|
}
|
|
};
|
|
|
|
const handleDeleteCampaign = async (campaignId) => {
|
|
// TODO: Replace window.confirm with a custom modal
|
|
if (!window.confirm("Are you sure you want to delete this campaign and all its encounters? This action cannot be undone.")) return;
|
|
try {
|
|
const encountersPath = `${CAMPAIGNS_COLLECTION}/${campaignId}/encounters`;
|
|
const encountersSnapshot = await getDocs(collection(db, encountersPath));
|
|
const batch = writeBatch(db);
|
|
encountersSnapshot.docs.forEach(encounterDoc => {
|
|
batch.delete(encounterDoc.ref);
|
|
});
|
|
await batch.commit();
|
|
|
|
await deleteDoc(doc(db, CAMPAIGNS_COLLECTION, campaignId));
|
|
|
|
if (selectedCampaignId === campaignId) {
|
|
setSelectedCampaignId(null);
|
|
}
|
|
const activeDisplayRef = doc(db, ACTIVE_DISPLAY_DOC);
|
|
const activeDisplaySnap = await getDoc(activeDisplayRef);
|
|
if (activeDisplaySnap.exists() && activeDisplaySnap.data().activeCampaignId === campaignId) {
|
|
await updateDoc(activeDisplayRef, { activeCampaignId: null, activeEncounterId: null });
|
|
}
|
|
} catch (err) {
|
|
console.error("Error deleting campaign:", err);
|
|
}
|
|
};
|
|
|
|
const selectedCampaign = campaigns.find(c => c.id === selectedCampaignId);
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<div className="flex justify-between items-center mb-4">
|
|
<h2 className="text-2xl font-semibold text-sky-300">Campaigns</h2>
|
|
<button
|
|
onClick={() => setShowCreateCampaignModal(true)}
|
|
className="bg-green-500 hover:bg-green-600 text-white font-bold py-2 px-4 rounded-lg flex items-center transition-colors"
|
|
>
|
|
<PlusCircle size={20} className="mr-2" /> Create Campaign
|
|
</button>
|
|
</div>
|
|
{campaigns.length === 0 && <p className="text-slate-400">No campaigns yet. Create one to get started!</p>}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{campaigns.map(campaign => (
|
|
<div key={campaign.id} className={`p-4 rounded-lg shadow-md cursor-pointer transition-all duration-200 ease-in-out transform hover:scale-105 ${selectedCampaignId === campaign.id ? 'bg-sky-700 ring-2 ring-sky-400' : 'bg-slate-700 hover:bg-slate-600'}`}>
|
|
<div onClick={() => setSelectedCampaignId(campaign.id)}>
|
|
<h3 className="text-xl font-semibold text-white">{campaign.name}</h3>
|
|
<p className="text-xs text-slate-400">ID: {campaign.id}</p>
|
|
</div>
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); handleDeleteCampaign(campaign.id); }}
|
|
className="mt-2 text-red-400 hover:text-red-300 text-xs flex items-center"
|
|
aria-label="Delete campaign"
|
|
>
|
|
<Trash2 size={14} className="mr-1" /> Delete Campaign
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{showCreateCampaignModal && (
|
|
<Modal onClose={() => setShowCreateCampaignModal(false)} title="Create New Campaign">
|
|
<CreateCampaignForm onCreate={handleCreateCampaign} onCancel={() => setShowCreateCampaignModal(false)} />
|
|
</Modal>
|
|
)}
|
|
|
|
{selectedCampaign && (
|
|
<div className="mt-6 p-6 bg-slate-750 rounded-lg shadow-xl">
|
|
<h2 className="text-2xl font-semibold text-amber-300 mb-4">Managing: {selectedCampaign.name}</h2>
|
|
<PlayerManager campaignId={selectedCampaignId} campaignPlayers={selectedCampaign.players || []} />
|
|
<hr className="my-6 border-slate-600" />
|
|
<EncounterManager campaignId={selectedCampaignId} campaignPlayers={selectedCampaign.players || []} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// --- Create Campaign Form ---
|
|
function CreateCampaignForm({ onCreate, onCancel }) {
|
|
const [name, setName] = useState('');
|
|
return (
|
|
<form onSubmit={(e) => { e.preventDefault(); onCreate(name); }} className="space-y-4">
|
|
<div>
|
|
<label htmlFor="campaignName" className="block text-sm font-medium text-slate-300">Campaign Name</label>
|
|
<input
|
|
type="text"
|
|
id="campaignName"
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
className="mt-1 block w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-md shadow-sm focus:outline-none focus:ring-sky-500 focus:border-sky-500 sm:text-sm text-white"
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="flex justify-end space-x-3">
|
|
<button type="button" onClick={onCancel} className="px-4 py-2 text-sm font-medium text-slate-300 bg-slate-600 hover:bg-slate-500 rounded-md transition-colors">Cancel</button>
|
|
<button type="submit" className="px-4 py-2 text-sm font-medium text-white bg-green-500 hover:bg-green-600 rounded-md transition-colors">Create</button>
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
// --- Player Manager ---
|
|
function PlayerManager({ campaignId, campaignPlayers }) {
|
|
const [playerName, setPlayerName] = useState('');
|
|
const [editingPlayer, setEditingPlayer] = useState(null);
|
|
|
|
const handleAddPlayer = async () => {
|
|
if (!playerName.trim() || !campaignId) return;
|
|
const newPlayer = { id: generateId(), name: playerName.trim() };
|
|
const campaignRef = doc(db, CAMPAIGNS_COLLECTION, campaignId);
|
|
try {
|
|
await updateDoc(campaignRef, {
|
|
players: [...campaignPlayers, newPlayer]
|
|
});
|
|
setPlayerName('');
|
|
} catch (err) {
|
|
console.error("Error adding player:", err);
|
|
}
|
|
};
|
|
|
|
const handleUpdatePlayer = async (playerId, newName) => {
|
|
if (!newName.trim() || !campaignId) return;
|
|
const updatedPlayers = campaignPlayers.map(p => p.id === playerId ? { ...p, name: newName.trim() } : p);
|
|
const campaignRef = doc(db, CAMPAIGNS_COLLECTION, campaignId);
|
|
try {
|
|
await updateDoc(campaignRef, { players: updatedPlayers });
|
|
setEditingPlayer(null);
|
|
} catch (err)
|
|
{
|
|
console.error("Error updating player:", err);
|
|
}
|
|
};
|
|
|
|
const handleDeletePlayer = async (playerId) => {
|
|
// TODO: Replace window.confirm
|
|
if (!window.confirm("Are you sure you want to remove this player from the campaign?")) return;
|
|
const updatedPlayers = campaignPlayers.filter(p => p.id !== playerId);
|
|
const campaignRef = doc(db, CAMPAIGNS_COLLECTION, campaignId);
|
|
try {
|
|
await updateDoc(campaignRef, { players: updatedPlayers });
|
|
} catch (err) {
|
|
console.error("Error deleting player:", err);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="p-4 bg-slate-800 rounded-lg shadow">
|
|
<h3 className="text-xl font-semibold text-sky-300 mb-3 flex items-center"><Users size={24} className="mr-2" /> Campaign Players</h3>
|
|
<form onSubmit={(e) => { e.preventDefault(); handleAddPlayer(); }} className="flex gap-2 mb-4">
|
|
<input
|
|
type="text"
|
|
value={playerName}
|
|
onChange={(e) => setPlayerName(e.target.value)}
|
|
placeholder="New player name"
|
|
className="flex-grow px-3 py-2 bg-slate-700 border border-slate-600 rounded-md shadow-sm focus:outline-none focus:ring-sky-500 focus:border-sky-500 sm:text-sm text-white"
|
|
/>
|
|
<button type="submit" className="bg-sky-500 hover:bg-sky-600 text-white font-semibold py-2 px-4 rounded-md flex items-center transition-colors">
|
|
<PlusCircle size={18} className="mr-1" /> Add Player
|
|
</button>
|
|
</form>
|
|
{campaignPlayers.length === 0 && <p className="text-sm text-slate-400">No players added to this campaign yet.</p>}
|
|
<ul className="space-y-2">
|
|
{campaignPlayers.map(player => (
|
|
<li key={player.id} className="flex justify-between items-center p-3 bg-slate-700 rounded-md">
|
|
{editingPlayer && editingPlayer.id === player.id ? (
|
|
<input
|
|
type="text"
|
|
defaultValue={player.name}
|
|
onBlur={(e) => handleUpdatePlayer(player.id, e.target.value)}
|
|
onKeyDown={(e) => { if (e.key === 'Enter') handleUpdatePlayer(player.id, e.target.value); if (e.key === 'Escape') setEditingPlayer(null); }}
|
|
autoFocus
|
|
className="flex-grow px-2 py-1 bg-slate-600 border border-slate-500 rounded-md text-white"
|
|
/>
|
|
) : (
|
|
<span className="text-slate-100">{player.name}</span>
|
|
)}
|
|
<div className="flex space-x-2">
|
|
<button onClick={() => setEditingPlayer(player)} className="text-yellow-400 hover:text-yellow-300" aria-label="Edit player">
|
|
<Edit3 size={18} />
|
|
</button>
|
|
<button onClick={() => handleDeletePlayer(player.id)} className="text-red-400 hover:text-red-300" aria-label="Delete player">
|
|
<Trash2 size={18} />
|
|
</button>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// --- Encounter Manager ---
|
|
function EncounterManager({ campaignId, campaignPlayers }) {
|
|
const [encounters, setEncounters] = useState([]);
|
|
const [selectedEncounterId, setSelectedEncounterId] = useState(null);
|
|
const [showCreateEncounterModal, setShowCreateEncounterModal] = useState(false);
|
|
const [activeDisplayInfo, setActiveDisplayInfo] = useState(null); // Stores { activeCampaignId, activeEncounterId }
|
|
const encountersPath = `${CAMPAIGNS_COLLECTION}/${campaignId}/encounters`;
|
|
|
|
// --- Fetch Encounters ---
|
|
useEffect(() => {
|
|
if (!campaignId) return;
|
|
const encountersCollectionRef = collection(db, encountersPath);
|
|
const q = query(encountersCollectionRef);
|
|
|
|
const unsubscribe = onSnapshot(q, (snapshot) => {
|
|
const encs = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
|
|
setEncounters(encs);
|
|
}, (err) => {
|
|
console.error(`Error fetching encounters for campaign ${campaignId}:`, err);
|
|
});
|
|
return () => unsubscribe();
|
|
}, [campaignId, encountersPath]);
|
|
|
|
// --- Fetch Active Display Info ---
|
|
useEffect(() => {
|
|
const unsub = onSnapshot(doc(db, ACTIVE_DISPLAY_DOC), (docSnap) => {
|
|
if (docSnap.exists()) {
|
|
setActiveDisplayInfo(docSnap.data());
|
|
} else {
|
|
setActiveDisplayInfo(null);
|
|
}
|
|
}, (err) => {
|
|
console.error("Error fetching active display info:", err);
|
|
setActiveDisplayInfo(null);
|
|
});
|
|
return () => unsub();
|
|
}, []);
|
|
|
|
|
|
const handleCreateEncounter = async (name) => {
|
|
if (!name.trim() || !campaignId) return;
|
|
const newEncounterId = generateId();
|
|
try {
|
|
await setDoc(doc(db, encountersPath, newEncounterId), {
|
|
name: name.trim(),
|
|
createdAt: new Date().toISOString(),
|
|
participants: [],
|
|
round: 0,
|
|
currentTurnParticipantId: null,
|
|
isStarted: false,
|
|
});
|
|
setShowCreateEncounterModal(false);
|
|
setSelectedEncounterId(newEncounterId);
|
|
} catch (err) {
|
|
console.error("Error creating encounter:", err);
|
|
}
|
|
};
|
|
|
|
const handleDeleteEncounter = async (encounterId) => {
|
|
// TODO: Replace window.confirm
|
|
if (!window.confirm("Are you sure you want to delete this encounter? This action cannot be undone.")) return;
|
|
try {
|
|
await deleteDoc(doc(db, encountersPath, encounterId));
|
|
if (selectedEncounterId === encounterId) {
|
|
setSelectedEncounterId(null);
|
|
}
|
|
if (activeDisplayInfo && activeDisplayInfo.activeEncounterId === encounterId) {
|
|
await updateDoc(doc(db, ACTIVE_DISPLAY_DOC), { activeEncounterId: null });
|
|
}
|
|
} catch (err) {
|
|
console.error("Error deleting encounter:", err);
|
|
}
|
|
};
|
|
|
|
const handleSetEncounterAsActiveDisplay = async (encounterId) => {
|
|
try {
|
|
await setDoc(doc(db, ACTIVE_DISPLAY_DOC), {
|
|
activeCampaignId: campaignId,
|
|
activeEncounterId: encounterId,
|
|
}, { merge: true });
|
|
console.log("Encounter set as active for display!"); // Replaced alert with console.log
|
|
} catch (err) {
|
|
console.error("Error setting active display:", err);
|
|
}
|
|
};
|
|
|
|
const selectedEncounter = encounters.find(e => e.id === selectedEncounterId);
|
|
|
|
return (
|
|
<div className="mt-6 p-4 bg-slate-800 rounded-lg shadow">
|
|
<div className="flex justify-between items-center mb-3">
|
|
<h3 className="text-xl font-semibold text-sky-300 flex items-center"><Swords size={24} className="mr-2" /> Encounters</h3>
|
|
<button
|
|
onClick={() => setShowCreateEncounterModal(true)}
|
|
className="bg-orange-500 hover:bg-orange-600 text-white font-bold py-2 px-3 rounded-md flex items-center transition-colors"
|
|
>
|
|
<PlusCircle size={18} className="mr-1" /> Create Encounter
|
|
</button>
|
|
</div>
|
|
{encounters.length === 0 && <p className="text-sm text-slate-400">No encounters in this campaign yet.</p>}
|
|
<div className="space-y-3">
|
|
{encounters.map(encounter => {
|
|
const isActiveOnDisplay = activeDisplayInfo &&
|
|
activeDisplayInfo.activeCampaignId === campaignId &&
|
|
activeDisplayInfo.activeEncounterId === encounter.id;
|
|
return (
|
|
<div
|
|
key={encounter.id}
|
|
className={`p-3 rounded-md shadow transition-all
|
|
${selectedEncounterId === encounter.id ? 'bg-sky-600 ring-2 ring-sky-400' : 'bg-slate-700 hover:bg-slate-650'}
|
|
${isActiveOnDisplay ? 'ring-2 ring-green-500 shadow-md shadow-green-500/30' : ''}`}
|
|
>
|
|
<div className="flex justify-between items-center">
|
|
<div onClick={() => setSelectedEncounterId(encounter.id)} className="cursor-pointer flex-grow">
|
|
<h4 className="font-medium text-white">{encounter.name}</h4>
|
|
<p className="text-xs text-slate-300">Participants: {encounter.participants?.length || 0}</p>
|
|
{isActiveOnDisplay && <span className="text-xs text-green-400 font-semibold block mt-1">LIVE ON DISPLAY</span>}
|
|
</div>
|
|
<div className="flex space-x-2">
|
|
<button
|
|
onClick={() => handleSetEncounterAsActiveDisplay(encounter.id)}
|
|
className={`p-1 rounded transition-colors ${isActiveOnDisplay ? 'bg-green-500 hover:bg-green-600 text-white' : 'bg-slate-600 hover:bg-slate-500 text-teal-400 hover:text-teal-300'}`}
|
|
title="Set as Active Display"
|
|
>
|
|
<Eye size={18} />
|
|
</button>
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); handleDeleteEncounter(encounter.id); }}
|
|
className="text-red-400 hover:text-red-300 p-1 rounded bg-slate-600 hover:bg-slate-500"
|
|
title="Delete Encounter"
|
|
>
|
|
<Trash2 size={18} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{showCreateEncounterModal && (
|
|
<Modal onClose={() => setShowCreateEncounterModal(false)} title="Create New Encounter">
|
|
<CreateEncounterForm onCreate={handleCreateEncounter} onCancel={() => setShowCreateEncounterModal(false)} />
|
|
</Modal>
|
|
)}
|
|
|
|
{selectedEncounter && (
|
|
<div className="mt-6 p-4 bg-slate-750 rounded-lg shadow-inner">
|
|
<h3 className="text-xl font-semibold text-amber-300 mb-3">Managing Encounter: {selectedEncounter.name}</h3>
|
|
<ParticipantManager encounter={selectedEncounter} encounterPath={`${encountersPath}/${selectedEncounterId}`} campaignPlayers={campaignPlayers} />
|
|
<InitiativeControls encounter={selectedEncounter} encounterPath={`${encountersPath}/${selectedEncounterId}`} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// --- Create Encounter Form ---
|
|
function CreateEncounterForm({ onCreate, onCancel }) {
|
|
const [name, setName] = useState('');
|
|
return (
|
|
<form onSubmit={(e) => { e.preventDefault(); onCreate(name); }} className="space-y-4">
|
|
<div>
|
|
<label htmlFor="encounterName" className="block text-sm font-medium text-slate-300">Encounter Name</label>
|
|
<input
|
|
type="text"
|
|
id="encounterName"
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
className="mt-1 block w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-md shadow-sm focus:outline-none focus:ring-sky-500 focus:border-sky-500 sm:text-sm text-white"
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="flex justify-end space-x-3">
|
|
<button type="button" onClick={onCancel} className="px-4 py-2 text-sm font-medium text-slate-300 bg-slate-600 hover:bg-slate-500 rounded-md transition-colors">Cancel</button>
|
|
<button type="submit" className="px-4 py-2 text-sm font-medium text-white bg-orange-500 hover:bg-orange-600 rounded-md transition-colors">Create</button>
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
// --- Participant Manager ---
|
|
function ParticipantManager({ encounter, encounterPath, campaignPlayers }) {
|
|
const [participantName, setParticipantName] = useState('');
|
|
const [participantType, setParticipantType] = useState('monster');
|
|
const [selectedPlayerId, setSelectedPlayerId] = useState('');
|
|
const [initiative, setInitiative] = useState(10);
|
|
const [maxHp, setMaxHp] = useState(10);
|
|
const [editingParticipant, setEditingParticipant] = useState(null);
|
|
const [hpChangeValues, setHpChangeValues] = useState({}); // { [participantId]: "value" }
|
|
|
|
const participants = encounter.participants || [];
|
|
|
|
const handleAddParticipant = async () => {
|
|
if ((participantType === 'monster' && !participantName.trim()) || (participantType === 'player' && !selectedPlayerId)) return;
|
|
|
|
let nameToAdd = participantName.trim();
|
|
if (participantType === 'player') {
|
|
const player = campaignPlayers.find(p => p.id === selectedPlayerId);
|
|
if (!player) {
|
|
console.error("Selected player not found");
|
|
return;
|
|
}
|
|
if (participants.some(p => p.type === 'player' && p.originalPlayerId === selectedPlayerId)) {
|
|
// TODO: Replace alert with better notification
|
|
alert(`${player.name} is already in this encounter.`);
|
|
return;
|
|
}
|
|
nameToAdd = player.name;
|
|
}
|
|
|
|
const newParticipant = {
|
|
id: generateId(),
|
|
name: nameToAdd,
|
|
type: participantType,
|
|
originalPlayerId: participantType === 'player' ? selectedPlayerId : null,
|
|
initiative: parseInt(initiative, 10) || 0,
|
|
maxHp: parseInt(maxHp, 10) || 1,
|
|
currentHp: parseInt(maxHp, 10) || 1,
|
|
conditions: [],
|
|
isActive: true,
|
|
};
|
|
|
|
try {
|
|
await updateDoc(doc(db, encounterPath), {
|
|
participants: [...participants, newParticipant]
|
|
});
|
|
setParticipantName('');
|
|
setInitiative(10);
|
|
setMaxHp(10);
|
|
setSelectedPlayerId('');
|
|
} catch (err) {
|
|
console.error("Error adding participant:", err);
|
|
}
|
|
};
|
|
|
|
const handleUpdateParticipant = async (updatedData) => {
|
|
if (!editingParticipant) return;
|
|
const updatedParticipants = participants.map(p =>
|
|
p.id === editingParticipant.id ? { ...p, ...updatedData } : p
|
|
);
|
|
try {
|
|
await updateDoc(doc(db, encounterPath), { participants: updatedParticipants });
|
|
setEditingParticipant(null);
|
|
} catch (err) {
|
|
console.error("Error updating participant:", err);
|
|
}
|
|
};
|
|
|
|
const handleDeleteParticipant = async (participantId) => {
|
|
// TODO: Replace window.confirm
|
|
if (!window.confirm("Remove this participant from the encounter?")) return;
|
|
const updatedParticipants = participants.filter(p => p.id !== participantId);
|
|
try {
|
|
await updateDoc(doc(db, encounterPath), { participants: updatedParticipants });
|
|
} catch (err) {
|
|
console.error("Error deleting participant:", err);
|
|
}
|
|
};
|
|
|
|
const toggleParticipantActive = async (participantId) => {
|
|
const participant = participants.find(p => p.id === participantId);
|
|
if (!participant) return;
|
|
const updatedParticipants = participants.map(p =>
|
|
p.id === participantId ? { ...p, isActive: !p.isActive } : p
|
|
);
|
|
try {
|
|
await updateDoc(doc(db, encounterPath), { participants: updatedParticipants });
|
|
} catch (err) {
|
|
console.error("Error toggling participant active state:", err);
|
|
}
|
|
};
|
|
|
|
const handleHpInputChange = (participantId, value) => {
|
|
setHpChangeValues(prev => ({ ...prev, [participantId]: value }));
|
|
};
|
|
|
|
const applyHpChange = async (participantId, changeType) => {
|
|
const amountStr = hpChangeValues[participantId];
|
|
if (amountStr === undefined || amountStr.trim() === '') return;
|
|
|
|
const amount = parseInt(amountStr, 10);
|
|
if (isNaN(amount) || amount === 0) {
|
|
setHpChangeValues(prev => ({ ...prev, [participantId]: '' })); // Clear if invalid
|
|
return;
|
|
}
|
|
|
|
const participant = participants.find(p => p.id === participantId);
|
|
if (!participant) return;
|
|
|
|
let newHp = participant.currentHp;
|
|
if (changeType === 'damage') {
|
|
newHp = Math.max(0, participant.currentHp - amount);
|
|
} else if (changeType === 'heal') {
|
|
newHp = Math.min(participant.maxHp, participant.currentHp + amount);
|
|
}
|
|
|
|
const updatedParticipants = participants.map(p =>
|
|
p.id === participantId ? { ...p, currentHp: newHp } : p
|
|
);
|
|
try {
|
|
await updateDoc(doc(db, encounterPath), { participants: updatedParticipants });
|
|
setHpChangeValues(prev => ({ ...prev, [participantId]: '' })); // Clear input after applying
|
|
} catch (err) {
|
|
console.error("Error applying HP change:", err);
|
|
}
|
|
};
|
|
|
|
|
|
const sortedAdminParticipants = [...participants].sort((a, b) => {
|
|
if (b.initiative === a.initiative) {
|
|
return a.name.localeCompare(b.name);
|
|
}
|
|
return b.initiative - a.initiative;
|
|
});
|
|
|
|
|
|
return (
|
|
<div className="p-3 bg-slate-800 rounded-md mt-4">
|
|
<h4 className="text-lg font-medium text-sky-200 mb-3">Participants</h4>
|
|
{/* Add Participant Form */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4 p-3 bg-slate-700 rounded">
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-300">Type</label>
|
|
<select value={participantType} onChange={(e) => setParticipantType(e.target.value)} className="mt-1 w-full p-2 bg-slate-600 border-slate-500 rounded text-white">
|
|
<option value="monster">Monster</option>
|
|
<option value="player">Player Character</option>
|
|
</select>
|
|
</div>
|
|
{participantType === 'monster' && (
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-300">Monster Name</label>
|
|
<input type="text" value={participantName} onChange={(e) => setParticipantName(e.target.value)} placeholder="e.g., Goblin Boss" className="mt-1 w-full p-2 bg-slate-600 border-slate-500 rounded text-white" />
|
|
</div>
|
|
)}
|
|
{participantType === 'player' && (
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-300">Select Player</label>
|
|
<select value={selectedPlayerId} onChange={(e) => setSelectedPlayerId(e.target.value)} className="mt-1 w-full p-2 bg-slate-600 border-slate-500 rounded text-white">
|
|
<option value="">-- Select from Campaign --</option>
|
|
{campaignPlayers.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
|
</select>
|
|
</div>
|
|
)}
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-300">Initiative</label>
|
|
<input type="number" value={initiative} onChange={(e) => setInitiative(e.target.value)} className="mt-1 w-full p-2 bg-slate-600 border-slate-500 rounded text-white" />
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-300">Max HP</label>
|
|
<input type="number" value={maxHp} onChange={(e) => setMaxHp(e.target.value)} className="mt-1 w-full p-2 bg-slate-600 border-slate-500 rounded text-white" />
|
|
</div>
|
|
<div className="md:col-span-2 flex justify-end">
|
|
<button onClick={handleAddParticipant} className="bg-green-500 hover:bg-green-600 text-white font-semibold py-2 px-4 rounded-md flex items-center transition-colors">
|
|
<PlusCircle size={18} className="mr-1" /> Add to Encounter
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Participant List */}
|
|
{participants.length === 0 && <p className="text-sm text-slate-400">No participants added yet.</p>}
|
|
<ul className="space-y-2">
|
|
{sortedAdminParticipants.map(p => (
|
|
<li key={p.id} className={`p-3 rounded-md flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 ${p.type === 'player' ? 'bg-sky-800' : 'bg-red-800'} ${!p.isActive ? 'opacity-50' : ''}`}>
|
|
<div className="flex-1">
|
|
<p className="font-semibold text-lg text-white">{p.name} <span className="text-xs">({p.type})</span></p>
|
|
<p className="text-sm text-slate-200">Init: {p.initiative} | HP: {p.currentHp}/{p.maxHp}</p>
|
|
</div>
|
|
<div className="flex flex-wrap items-center space-x-2 mt-2 sm:mt-0">
|
|
{/* HP Change Controls - only if encounter started and participant active */}
|
|
{encounter.isStarted && p.isActive && (
|
|
<div className="flex items-center space-x-1 bg-slate-700 p-1 rounded-md">
|
|
<input
|
|
type="number"
|
|
placeholder="HP"
|
|
value={hpChangeValues[p.id] || ''}
|
|
onChange={(e) => handleHpInputChange(p.id, e.target.value)}
|
|
className="w-16 p-1 text-sm bg-slate-600 border border-slate-500 rounded-md text-white focus:ring-sky-500 focus:border-sky-500"
|
|
aria-label={`HP change for ${p.name}`}
|
|
/>
|
|
<button
|
|
onClick={() => applyHpChange(p.id, 'damage')}
|
|
className="p-1 bg-red-500 hover:bg-red-600 text-white rounded-md text-xs"
|
|
title="Apply Damage"
|
|
>
|
|
<HeartCrack size={16}/>
|
|
</button>
|
|
<button
|
|
onClick={() => applyHpChange(p.id, 'heal')}
|
|
className="p-1 bg-green-500 hover:bg-green-600 text-white rounded-md text-xs"
|
|
title="Apply Healing"
|
|
>
|
|
<HeartPulse size={16}/>
|
|
</button>
|
|
</div>
|
|
)}
|
|
<button onClick={() => toggleParticipantActive(p.id)} className={`p-1 rounded ${p.isActive ? 'bg-yellow-500 hover:bg-yellow-600' : 'bg-gray-500 hover:bg-gray-600'}`} title={p.isActive ? "Mark Inactive" : "Mark Active"}>
|
|
{p.isActive ? <UserCheck size={18} /> : <UserX size={18} />}
|
|
</button>
|
|
<button onClick={() => setEditingParticipant(p)} className="text-yellow-300 hover:text-yellow-200 p-1 rounded bg-slate-600 hover:bg-slate-500" title="Edit Participant">
|
|
<Edit3 size={18} />
|
|
</button>
|
|
<button onClick={() => handleDeleteParticipant(p.id)} className="text-red-300 hover:text-red-200 p-1 rounded bg-slate-600 hover:bg-slate-500" title="Remove Participant">
|
|
<Trash2 size={18} />
|
|
</button>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
|
|
{editingParticipant && (
|
|
<EditParticipantModal
|
|
participant={editingParticipant}
|
|
onClose={() => setEditingParticipant(null)}
|
|
onSave={handleUpdateParticipant}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// --- Edit Participant Modal ---
|
|
function EditParticipantModal({ participant, onClose, onSave }) {
|
|
const [name, setName] = useState(participant.name);
|
|
const [initiative, setInitiative] = useState(participant.initiative);
|
|
const [currentHp, setCurrentHp] = useState(participant.currentHp);
|
|
const [maxHp, setMaxHp] = useState(participant.maxHp);
|
|
|
|
const handleSubmit = (e) => {
|
|
e.preventDefault();
|
|
onSave({
|
|
name: name.trim(),
|
|
initiative: parseInt(initiative, 10),
|
|
currentHp: parseInt(currentHp, 10),
|
|
maxHp: parseInt(maxHp, 10),
|
|
});
|
|
};
|
|
|
|
return (
|
|
<Modal onClose={onClose} title={`Edit ${participant.name}`}>
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-300">Name</label>
|
|
<input type="text" value={name} onChange={(e) => setName(e.target.value)} className="mt-1 w-full p-2 bg-slate-700 border-slate-600 rounded text-white" />
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-300">Initiative</label>
|
|
<input type="number" value={initiative} onChange={(e) => setInitiative(e.target.value)} className="mt-1 w-full p-2 bg-slate-700 border-slate-600 rounded text-white" />
|
|
</div>
|
|
<div className="flex gap-4">
|
|
<div className="flex-1">
|
|
<label className="block text-sm font-medium text-slate-300">Current HP</label>
|
|
<input type="number" value={currentHp} onChange={(e) => setCurrentHp(e.target.value)} className="mt-1 w-full p-2 bg-slate-700 border-slate-600 rounded text-white" />
|
|
</div>
|
|
<div className="flex-1">
|
|
<label className="block text-sm font-medium text-slate-300">Max HP</label>
|
|
<input type="number" value={maxHp} onChange={(e) => setMaxHp(e.target.value)} className="mt-1 w-full p-2 bg-slate-700 border-slate-600 rounded text-white" />
|
|
</div>
|
|
</div>
|
|
<div className="flex justify-end space-x-3 pt-2">
|
|
<button type="button" onClick={onClose} className="px-4 py-2 text-sm font-medium text-slate-300 bg-slate-600 hover:bg-slate-500 rounded-md transition-colors">Cancel</button>
|
|
<button type="submit" className="px-4 py-2 text-sm font-medium text-white bg-sky-500 hover:bg-sky-600 rounded-md transition-colors">
|
|
<Save size={18} className="mr-1 inline-block" /> Save Changes
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
// --- Initiative Controls ---
|
|
function InitiativeControls({ encounter, encounterPath }) {
|
|
const handleStartEncounter = async () => {
|
|
if (!encounter.participants || encounter.participants.length === 0) {
|
|
// TODO: Replace alert
|
|
alert("Add participants before starting the encounter.");
|
|
return;
|
|
}
|
|
const activeParticipants = encounter.participants.filter(p => p.isActive);
|
|
if (activeParticipants.length === 0) {
|
|
// TODO: Replace alert
|
|
alert("No active participants to start the encounter.");
|
|
return;
|
|
}
|
|
|
|
const sortedParticipants = [...activeParticipants].sort((a, b) => {
|
|
if (b.initiative === a.initiative) {
|
|
return Math.random() - 0.5;
|
|
}
|
|
return b.initiative - a.initiative;
|
|
});
|
|
|
|
try {
|
|
await updateDoc(doc(db, encounterPath), {
|
|
participants: encounter.participants.map(p => {
|
|
const sortedVersion = sortedParticipants.find(sp => sp.id === p.id);
|
|
return sortedVersion ? sortedVersion : p;
|
|
}),
|
|
isStarted: true,
|
|
round: 1,
|
|
currentTurnParticipantId: sortedParticipants[0].id,
|
|
turnOrderIds: sortedParticipants.map(p => p.id)
|
|
});
|
|
} catch (err) {
|
|
console.error("Error starting encounter:", err);
|
|
}
|
|
};
|
|
|
|
const handleNextTurn = async () => {
|
|
if (!encounter.isStarted || !encounter.currentTurnParticipantId || !encounter.turnOrderIds || encounter.turnOrderIds.length === 0) return;
|
|
|
|
const activeParticipantsInOrder = encounter.turnOrderIds
|
|
.map(id => encounter.participants.find(p => p.id === id && p.isActive))
|
|
.filter(Boolean);
|
|
|
|
if (activeParticipantsInOrder.length === 0) {
|
|
// TODO: Replace alert
|
|
alert("No active participants left in the turn order.");
|
|
await updateDoc(doc(db, encounterPath), { isStarted: false, currentTurnParticipantId: null, round: encounter.round });
|
|
return;
|
|
}
|
|
|
|
const currentIndex = activeParticipantsInOrder.findIndex(p => p.id === encounter.currentTurnParticipantId);
|
|
let nextIndex = (currentIndex + 1) % activeParticipantsInOrder.length;
|
|
let nextRound = encounter.round;
|
|
|
|
if (nextIndex === 0 && currentIndex !== -1) {
|
|
nextRound += 1;
|
|
}
|
|
|
|
const nextParticipantId = activeParticipantsInOrder[nextIndex].id;
|
|
|
|
try {
|
|
await updateDoc(doc(db, encounterPath), {
|
|
currentTurnParticipantId: nextParticipantId,
|
|
round: nextRound
|
|
});
|
|
} catch (err) {
|
|
console.error("Error advancing turn:", err);
|
|
}
|
|
};
|
|
|
|
const handleEndEncounter = async () => {
|
|
// TODO: Replace window.confirm
|
|
if (!window.confirm("Are you sure you want to end this encounter? Initiative order will be reset.")) return;
|
|
try {
|
|
await updateDoc(doc(db, encounterPath), {
|
|
isStarted: false,
|
|
currentTurnParticipantId: null,
|
|
round: 0,
|
|
turnOrderIds: []
|
|
});
|
|
} catch (err) {
|
|
console.error("Error ending encounter:", err);
|
|
}
|
|
};
|
|
|
|
|
|
if (!encounter || !encounter.participants) return null;
|
|
|
|
return (
|
|
<div className="mt-6 p-3 bg-slate-800 rounded-md">
|
|
<h4 className="text-lg font-medium text-sky-200 mb-3">Combat Controls</h4>
|
|
<div className="flex flex-wrap gap-3">
|
|
{!encounter.isStarted ? (
|
|
<button onClick={handleStartEncounter} className="bg-green-600 hover:bg-green-700 text-white font-bold py-2 px-4 rounded-lg flex items-center transition-colors" disabled={!encounter.participants || encounter.participants.filter(p=>p.isActive).length === 0}>
|
|
<PlayIcon size={18} className="mr-2" /> Start Encounter
|
|
</button>
|
|
) : (
|
|
<>
|
|
<button onClick={handleNextTurn} className="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded-lg flex items-center transition-colors" disabled={!encounter.currentTurnParticipantId}>
|
|
<SkipForwardIcon size={18} className="mr-2" /> Next Turn
|
|
</button>
|
|
<button onClick={handleEndEncounter} className="bg-red-600 hover:bg-red-700 text-white font-bold py-2 px-4 rounded-lg flex items-center transition-colors">
|
|
<StopCircleIcon size={18} className="mr-2" /> End Encounter
|
|
</button>
|
|
<p className="text-slate-300 self-center">Round: {encounter.round}</p>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// --- Display View Component ---
|
|
function DisplayView() {
|
|
const [activeEncounterData, setActiveEncounterData] = useState(null);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [error, setError] = useState(null);
|
|
|
|
useEffect(() => {
|
|
setIsLoading(true);
|
|
const activeDisplayRef = doc(db, ACTIVE_DISPLAY_DOC);
|
|
|
|
const unsubDisplayConfig = onSnapshot(activeDisplayRef, async (docSnap) => {
|
|
if (docSnap.exists()) {
|
|
const { activeCampaignId, activeEncounterId } = docSnap.data();
|
|
if (activeCampaignId && activeEncounterId) {
|
|
const encounterPath = `${CAMPAIGNS_COLLECTION}/${activeCampaignId}/encounters/${activeEncounterId}`;
|
|
const unsubEncounter = onSnapshot(doc(db, encounterPath), (encDocSnap) => {
|
|
if (encDocSnap.exists()) {
|
|
setActiveEncounterData({ id: encDocSnap.id, ...encDocSnap.data() });
|
|
setError(null);
|
|
} else {
|
|
setActiveEncounterData(null);
|
|
setError("Active encounter not found. The DM might have deleted it or it's no longer set for display.");
|
|
}
|
|
setIsLoading(false);
|
|
}, (err) => {
|
|
console.error("Error fetching active encounter details:", err);
|
|
setError("Error loading encounter data.");
|
|
setIsLoading(false);
|
|
});
|
|
return () => unsubEncounter();
|
|
} else {
|
|
setActiveEncounterData(null);
|
|
setIsLoading(false);
|
|
setError(null);
|
|
}
|
|
} else {
|
|
setActiveEncounterData(null);
|
|
setIsLoading(false);
|
|
setError(null);
|
|
}
|
|
}, (err) => {
|
|
console.error("Error fetching active display config:", err);
|
|
setError("Could not load display configuration.");
|
|
setIsLoading(false);
|
|
});
|
|
|
|
return () => unsubDisplayConfig();
|
|
}, []);
|
|
|
|
if (isLoading) {
|
|
return <div className="text-center py-10 text-2xl text-slate-300">Loading Player Display...</div>;
|
|
}
|
|
if (error) {
|
|
return <div className="text-center py-10 text-2xl text-red-400">{error}</div>;
|
|
}
|
|
if (!activeEncounterData) {
|
|
return <div className="text-center py-10 text-3xl text-slate-400">No active encounter to display. <br/><span className="text-xl">The DM needs to select one from the Admin View.</span></div>;
|
|
}
|
|
|
|
const { name, participants, round, currentTurnParticipantId, isStarted } = activeEncounterData;
|
|
|
|
let displayParticipants = [];
|
|
if (isStarted && activeEncounterData.turnOrderIds && activeEncounterData.turnOrderIds.length > 0) {
|
|
displayParticipants = activeEncounterData.turnOrderIds
|
|
.map(id => participants.find(p => p.id === id))
|
|
.filter(p => p && p.isActive);
|
|
} else if (participants) {
|
|
displayParticipants = [...participants]
|
|
.filter(p => p.isActive)
|
|
.sort((a, b) => {
|
|
if (b.initiative === a.initiative) return a.name.localeCompare(b.name);
|
|
return b.initiative - a.initiative;
|
|
});
|
|
}
|
|
|
|
|
|
return (
|
|
<div className="p-4 md:p-8 bg-slate-900 rounded-xl shadow-2xl">
|
|
<h2 className="text-4xl md:text-5xl font-bold text-center text-amber-400 mb-2">{name}</h2>
|
|
{isStarted && <p className="text-2xl text-center text-sky-300 mb-6">Round: {round}</p>}
|
|
{!isStarted && participants && participants.length > 0 && <p className="text-2xl text-center text-slate-400 mb-6">Encounter Awaiting Start</p>}
|
|
{!isStarted && (!participants || participants.length === 0) && <p className="text-2xl text-center text-slate-500 mb-6">No participants in this encounter yet.</p>}
|
|
|
|
{displayParticipants.length === 0 && isStarted && (
|
|
<p className="text-xl text-center text-slate-400">No active participants in the encounter.</p>
|
|
)}
|
|
|
|
<div className="space-y-4 max-w-3xl mx-auto">
|
|
{displayParticipants.map((p, index) => (
|
|
<div
|
|
key={p.id}
|
|
className={`p-4 md:p-6 rounded-lg shadow-lg transition-all duration-300 ease-in-out transform hover:scale-102
|
|
${p.id === currentTurnParticipantId && isStarted ? 'bg-green-700 ring-4 ring-green-400 scale-105' : (p.type === 'player' ? 'bg-sky-700' : 'bg-red-700')}
|
|
${!p.isActive ? 'opacity-40 grayscale' : ''}
|
|
`}
|
|
>
|
|
<div className="flex justify-between items-center mb-2">
|
|
<h3 className={`text-2xl md:text-3xl font-bold ${p.id === currentTurnParticipantId && isStarted ? 'text-white' : (p.type === 'player' ? 'text-sky-100' : 'text-red-100')}`}>
|
|
{p.name}
|
|
{p.id === currentTurnParticipantId && isStarted && <span className="text-yellow-300 animate-pulse ml-2">(Current Turn)</span>}
|
|
</h3>
|
|
<span className={`text-xl md:text-2xl font-semibold ${p.id === currentTurnParticipantId && isStarted ? 'text-green-200' : 'text-slate-200'}`}>
|
|
Init: {p.initiative}
|
|
</span>
|
|
</div>
|
|
<div className="flex justify-between items-center">
|
|
<div className="w-full bg-slate-600 rounded-full h-6 md:h-8 relative overflow-hidden border-2 border-slate-500">
|
|
<div
|
|
className={`h-full rounded-full transition-all duration-500 ease-out ${p.currentHp <= p.maxHp / 4 ? 'bg-red-500' : (p.currentHp <= p.maxHp / 2 ? 'bg-yellow-500' : 'bg-green-500')}`}
|
|
style={{ width: `${Math.max(0, (p.currentHp / p.maxHp) * 100)}%` }}
|
|
></div>
|
|
<span className="absolute inset-0 flex items-center justify-center text-xs md:text-sm font-medium text-white mix-blend-difference px-2">
|
|
HP: {p.currentHp} / {p.maxHp}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
{p.conditions && p.conditions.length > 0 && (
|
|
<p className="text-sm text-yellow-300 mt-2">Conditions: {p.conditions.join(', ')}</p>
|
|
)}
|
|
{!p.isActive && <p className="text-center text-lg font-semibold text-slate-300 mt-2">(Inactive)</p>}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
|
|
// --- Modal Component ---
|
|
function Modal({ onClose, title, children }) {
|
|
useEffect(() => {
|
|
const handleEsc = (event) => {
|
|
if (event.key === 'Escape') {
|
|
onClose();
|
|
}
|
|
};
|
|
window.addEventListener('keydown', handleEsc);
|
|
return () => window.removeEventListener('keydown', handleEsc);
|
|
}, [onClose]);
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center p-4 z-50 transition-opacity duration-300 ease-in-out">
|
|
<div className="bg-slate-800 p-6 rounded-lg shadow-xl w-full max-w-md transform transition-all duration-300 ease-in-out scale-100 opacity-100">
|
|
<div className="flex justify-between items-center mb-4">
|
|
<h2 className="text-xl font-semibold text-sky-300">{title}</h2>
|
|
<button onClick={onClose} className="text-slate-400 hover:text-slate-200">
|
|
<XCircle size={24} />
|
|
</button>
|
|
</div>
|
|
{children}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// --- Icons ---
|
|
const PlayIcon = ({ size = 24, className = '' }) => <svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><polygon points="5 3 19 12 5 21 5 3"></polygon></svg>;
|
|
const SkipForwardIcon = ({ size = 24, className = '' }) => <svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><polygon points="5 4 15 12 5 20 5 4"></polygon><line x1="19" y1="5" x2="19" y2="19"></line></svg>;
|
|
const StopCircleIcon = ({size=24, className=''}) => <svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><circle cx="12" cy="12" r="10"></circle><rect x="9" y="9" width="6" height="6"></rect></svg>;
|
|
|
|
|
|
export default App;
|
|
|