2025-05-25 10:22:24 -04:00

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;