import React, { useState, useEffect, useRef, useMemo } from 'react';
import { initializeApp } from 'firebase/app';
import { getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken } from 'firebase/auth';
import { getFirestore, doc, setDoc, getDoc, getDocs, collection, onSnapshot, updateDoc, deleteDoc, query, writeBatch } from 'firebase/firestore';
import { PlusCircle, Users, Swords, Trash2, Eye, Edit3, Save, XCircle, ChevronsUpDown, UserCheck, UserX, HeartCrack, HeartPulse, Zap, EyeOff, ExternalLink, AlertTriangle, Play as PlayIcon, Pause as PauseIcon, SkipForward as SkipForwardIcon, StopCircle as StopCircleIcon, Users2, Dices } from 'lucide-react';
// --- Firebase Configuration ---
const firebaseConfig = {
apiKey: process.env.REACT_APP_FIREBASE_API_KEY,
authDomain: process.env.REACT_APP_FIREBASE_AUTH_DOMAIN,
projectId: process.env.REACT_APP_FIREBASE_PROJECT_ID,
storageBucket: process.env.REACT_APP_FIREBASE_STORAGE_BUCKET,
messagingSenderId: process.env.REACT_APP_FIREBASE_MESSAGING_SENDER_ID,
appId: process.env.REACT_APP_FIREBASE_APP_ID
};
let app;
let db;
let auth;
const requiredFirebaseConfigKeys = ['apiKey', 'authDomain', 'projectId', 'appId'];
const missingKeys = requiredFirebaseConfigKeys.filter(key => !firebaseConfig[key]);
if (missingKeys.length > 0) {
console.error(`CRITICAL: Missing Firebase config values from environment variables: ${missingKeys.join(', ')}`);
console.error("Firebase cannot be initialized. Please ensure all REACT_APP_FIREBASE_... variables are set in your .env.local file and accessible during the build.");
} else {
try {
app = initializeApp(firebaseConfig);
db = getFirestore(app);
auth = getAuth(app);
} catch (error) {
console.error("Error initializing Firebase:", error);
}
}
// --- Firestore Paths ---
const APP_ID = process.env.REACT_APP_TRACKER_APP_ID || 'ttrpg-initiative-tracker-default';
const PUBLIC_DATA_PATH = `artifacts/${APP_ID}/public/data`;
// --- Firestore Path Helpers ---
const getCampaignsCollectionPath = () => `${PUBLIC_DATA_PATH}/campaigns`;
const getCampaignDocPath = (campaignId) => `${PUBLIC_DATA_PATH}/campaigns/${campaignId}`;
const getEncountersCollectionPath = (campaignId) => `${PUBLIC_DATA_PATH}/campaigns/${campaignId}/encounters`;
const getEncounterDocPath = (campaignId, encounterId) => `${PUBLIC_DATA_PATH}/campaigns/${campaignId}/encounters/${encounterId}`;
const getActiveDisplayDocPath = () => `${PUBLIC_DATA_PATH}/activeDisplay/status`;
// --- Helper Functions ---
const generateId = () => crypto.randomUUID();
const rollD20 = () => Math.floor(Math.random() * 20) + 1;
// --- Custom Hooks for Firestore ---
function useFirestoreDocument(docPath) {
const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
if (!db || !docPath) {
setData(null);
setIsLoading(false);
setError(docPath ? "Firestore not available." : "Document path not provided.");
return;
}
setIsLoading(true);
setError(null);
const docRef = doc(db, docPath);
const unsubscribe = onSnapshot(docRef, (docSnap) => {
if (docSnap.exists()) {
setData({ id: docSnap.id, ...docSnap.data() });
} else {
setData(null);
}
setIsLoading(false);
}, (err) => {
console.error(`Error fetching document ${docPath}:`, err);
setError(err.message || "Failed to fetch document.");
setIsLoading(false);
setData(null);
});
return () => unsubscribe();
}, [docPath]);
return { data, isLoading, error };
}
function useFirestoreCollection(collectionPath, queryConstraints = []) {
const [data, setData] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const queryString = useMemo(() => JSON.stringify(queryConstraints), [queryConstraints]);
useEffect(() => {
if (!db || !collectionPath) {
setData([]);
setIsLoading(false);
setError(collectionPath ? "Firestore not available." : "Collection path not provided.");
return;
}
setIsLoading(true);
setError(null);
const q = query(collection(db, collectionPath), ...queryConstraints);
const unsubscribe = onSnapshot(q, (snapshot) => {
const items = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
setData(items);
setIsLoading(false);
}, (err) => {
console.error(`Error fetching collection ${collectionPath}:`, err);
setError(err.message || "Failed to fetch collection.");
setIsLoading(false);
setData([]);
});
return () => unsubscribe();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [collectionPath, queryString]);
return { data, isLoading, error };
}
// --- Main App Component ---
function App() {
const [userId, setUserId] = useState(null);
const [isAuthReady, setIsAuthReady] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const [isPlayerViewOnlyMode, setIsPlayerViewOnlyMode] = useState(false);
useEffect(() => {
const queryParams = new URLSearchParams(window.location.search);
if (queryParams.get('playerView') === 'true') {
setIsPlayerViewOnlyMode(true);
}
if (!auth) {
setError("Firebase Auth not initialized. Check your Firebase configuration.");
setIsLoading(false);
setIsAuthReady(false);
return;
}
const initAuth = async () => {
try {
const token = window.__initial_auth_token;
if (token) {
await signInWithCustomToken(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) => {
setUserId(user ? user.uid : null);
setIsAuthReady(true);
setIsLoading(false);
});
initAuth();
return () => unsubscribe();
}, []);
if (!db || !auth) {
return (
Configuration Error
Firebase is not properly configured or initialized.
Please check your `.env.local` file and ensure all `REACT_APP_FIREBASE_...` variables are correctly set.
Also, check the browser console for more specific error messages.
{error &&
{error}
}
);
}
if (isLoading || !isAuthReady) {
return (
Loading Initiative Tracker...
{error &&
{error}
}
);
}
const openPlayerWindow = () => {
const playerViewUrl = window.location.origin + window.location.pathname + '?playerView=true';
window.open(playerViewUrl, '_blank', 'noopener,noreferrer,width=1024,height=768');
};
if (isPlayerViewOnlyMode) {
return (
{isAuthReady &&
}
{!isAuthReady && !error &&
Authenticating for Player Display...
}
);
}
return (
TTRPG Initiative Tracker
{isAuthReady && userId && }
{!isAuthReady && !error && Authenticating...
}
);
}
// --- Confirmation Modal Component ---
function ConfirmationModal({ isOpen, onClose, onConfirm, title, message }) {
if (!isOpen) return null;
return (
{title || "Confirm Action"}
{message || "Are you sure you want to proceed?"}
);
}
// --- Admin View Component ---
function AdminView({ userId }) {
const { data: campaignsData, isLoading: isLoadingCampaigns } = useFirestoreCollection(getCampaignsCollectionPath());
const { data: initialActiveInfoData } = useFirestoreDocument(getActiveDisplayDocPath());
const [campaigns, setCampaigns] = useState([]);
const [selectedCampaignId, setSelectedCampaignId] = useState(null);
const [showCreateCampaignModal, setShowCreateCampaignModal] = useState(false);
const [showDeleteCampaignConfirm, setShowDeleteCampaignConfirm] = useState(false);
const [itemToDelete, setItemToDelete] = useState(null);
useEffect(() => {
if (campaignsData) {
setCampaigns(campaignsData.map(c => ({ ...c, characters: c.players || [] })));
}
}, [campaignsData]);
useEffect(() => {
if (initialActiveInfoData && initialActiveInfoData.activeCampaignId && campaigns.length > 0 && !selectedCampaignId) {
const campaignExists = campaigns.some(c => c.id === initialActiveInfoData.activeCampaignId);
if (campaignExists) {
setSelectedCampaignId(initialActiveInfoData.activeCampaignId);
}
}
}, [initialActiveInfoData, campaigns, selectedCampaignId]);
const handleCreateCampaign = async (name, backgroundUrl) => {
if (!db || !name.trim()) return;
const newCampaignId = generateId();
try {
await setDoc(doc(db, getCampaignDocPath(newCampaignId)), {
name: name.trim(),
playerDisplayBackgroundUrl: backgroundUrl.trim() || '',
ownerId: userId,
createdAt: new Date().toISOString(),
players: [],
});
setShowCreateCampaignModal(false);
setSelectedCampaignId(newCampaignId);
} catch (err) { console.error("Error creating campaign:", err); }
};
const requestDeleteCampaign = (campaignId, campaignName) => {
setItemToDelete({ id: campaignId, name: campaignName, type: 'campaign' });
setShowDeleteCampaignConfirm(true);
};
const confirmDeleteCampaign = async () => {
if (!db || !itemToDelete || itemToDelete.type !== 'campaign') return;
const campaignId = itemToDelete.id;
try {
const encountersPath = getEncountersCollectionPath(campaignId);
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, getCampaignDocPath(campaignId)));
if (selectedCampaignId === campaignId) setSelectedCampaignId(null);
const activeDisplayRef = doc(db, getActiveDisplayDocPath());
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); }
setShowDeleteCampaignConfirm(false);
setItemToDelete(null);
};
const selectedCampaign = campaigns.find(c => c.id === selectedCampaignId);
if (isLoadingCampaigns) {
return Loading campaigns...
;
}
return (
<>
Campaigns
{campaigns.length === 0 &&
No campaigns yet.
}
{campaigns.map(campaign => {
const cardStyle = campaign.playerDisplayBackgroundUrl ? { backgroundImage: `url(${campaign.playerDisplayBackgroundUrl})`} : {};
const cardClasses = `h-36 flex flex-col justify-between rounded-lg shadow-md cursor-pointer transition-all relative overflow-hidden bg-cover bg-center ${selectedCampaignId === campaign.id ? 'ring-4 ring-sky-400' : ''} ${!campaign.playerDisplayBackgroundUrl ? 'bg-slate-700 hover:bg-slate-600' : 'hover:shadow-xl'}`;
return (
setSelectedCampaignId(campaign.id)} className={cardClasses} style={cardStyle}>
{campaign.name}
);
})}
{showCreateCampaignModal &&
setShowCreateCampaignModal(false)} title="Create New Campaign"> setShowCreateCampaignModal(false)} />}
{selectedCampaign && (
Managing: {selectedCampaign.name}
)}
setShowDeleteCampaignConfirm(false)} onConfirm={confirmDeleteCampaign} title="Delete Campaign?" message={`Are you sure you want to delete the campaign "${itemToDelete?.name}" and all its encounters? This action cannot be undone.`}/>
>
);
}
// --- CreateCampaignForm, CharacterManager, EncounterManager, CreateEncounterForm, ParticipantManager, EditParticipantModal, InitiativeControls, DisplayView, Modal, Icons ---
// The rest of the components are identical to the previous version (v0.1.28) and are included below for completeness.
// Only ParticipantManager and DisplayView have minor changes for monster color.
function CreateCampaignForm({ onCreate, onCancel }) {
const [name, setName] = useState('');
const [backgroundUrl, setBackgroundUrl] = useState('');
const handleSubmit = (e) => { e.preventDefault(); onCreate(name, backgroundUrl); };
return (
);
}
function CharacterManager({ campaignId, campaignCharacters }) {
const [characterName, setCharacterName] = useState('');
const [defaultMaxHp, setDefaultMaxHp] = useState(10);
const [defaultInitMod, setDefaultInitMod] = useState(0);
const [editingCharacter, setEditingCharacter] = useState(null);
const [showDeleteCharConfirm, setShowDeleteCharConfirm] = useState(false);
const [itemToDelete, setItemToDelete] = useState(null);
const handleAddCharacter = async () => {
if (!db ||!characterName.trim() || !campaignId) return;
const hp = parseInt(defaultMaxHp, 10);
const initMod = parseInt(defaultInitMod, 10);
if (isNaN(hp) || hp <= 0) { alert("Please enter a valid positive number for Default Max HP."); return; }
if (isNaN(initMod)) { alert("Please enter a valid number for Default Initiative Modifier."); return; }
const newCharacter = { id: generateId(), name: characterName.trim(), defaultMaxHp: hp, defaultInitMod: initMod };
try { await updateDoc(doc(db, getCampaignDocPath(campaignId)), { players: [...campaignCharacters, newCharacter] }); setCharacterName(''); setDefaultMaxHp(10); setDefaultInitMod(0); } catch (err) { console.error("Error adding character:", err); }
};
const handleUpdateCharacter = async (characterId, newName, newDefaultMaxHp, newDefaultInitMod) => {
if (!db ||!newName.trim() || !campaignId) return;
const hp = parseInt(newDefaultMaxHp, 10);
const initMod = parseInt(newDefaultInitMod, 10);
if (isNaN(hp) || hp <= 0) { alert("Please enter a valid positive number for Default Max HP."); setEditingCharacter(null); return; }
if (isNaN(initMod)) { alert("Please enter a valid number for Default Initiative Modifier."); setEditingCharacter(null); return; }
const updatedCharacters = campaignCharacters.map(c => c.id === characterId ? { ...c, name: newName.trim(), defaultMaxHp: hp, defaultInitMod: initMod } : c);
try { await updateDoc(doc(db, getCampaignDocPath(campaignId)), { players: updatedCharacters }); setEditingCharacter(null); } catch (err) { console.error("Error updating character:", err); }
};
const requestDeleteCharacter = (characterId, charName) => { setItemToDelete({ id: characterId, name: charName, type: 'character' }); setShowDeleteCharConfirm(true); };
const confirmDeleteCharacter = async () => {
if (!db || !itemToDelete || itemToDelete.type !== 'character') return;
const characterId = itemToDelete.id;
const updatedCharacters = campaignCharacters.filter(c => c.id !== characterId);
try { await updateDoc(doc(db, getCampaignDocPath(campaignId)), { players: updatedCharacters }); } catch (err) { console.error("Error deleting character:", err); }
setShowDeleteCharConfirm(false); setItemToDelete(null);
};
return (
<>
Campaign Characters
{campaignCharacters.length === 0 &&
No characters added.
}
{campaignCharacters.map(character => (
-
{editingCharacter && editingCharacter.id === character.id ? (
) : (
<>
{character.name} (HP: {character.defaultMaxHp || 'N/A'}, Init Mod: {character.defaultInitMod !== undefined ? (character.defaultInitMod >= 0 ? `+${character.defaultInitMod}` : character.defaultInitMod) : 'N/A'})
>
)}
))}
setShowDeleteCharConfirm(false)} onConfirm={confirmDeleteCharacter} title="Delete Character?" message={`Are you sure you want to remove the character "${itemToDelete?.name}" from this campaign?`}/>
>
);
}
function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharacters }) {
const {data: encountersData, isLoading: isLoadingEncounters } = useFirestoreCollection(campaignId ? getEncountersCollectionPath(campaignId) : null);
const {data: activeDisplayInfoFromHook } = useFirestoreDocument(getActiveDisplayDocPath());
const [encounters, setEncounters] = useState([]);
const [selectedEncounterId, setSelectedEncounterId] = useState(null);
const [showCreateEncounterModal, setShowCreateEncounterModal] = useState(false);
const [showDeleteEncounterConfirm, setShowDeleteEncounterConfirm] = useState(false);
const [itemToDelete, setItemToDelete] = useState(null);
const selectedEncounterIdRef = useRef(selectedEncounterId);
useEffect(() => { if(encountersData) setEncounters(encountersData); }, [encountersData]);
useEffect(() => { selectedEncounterIdRef.current = selectedEncounterId; }, [selectedEncounterId]);
useEffect(() => {
if (!campaignId) { setSelectedEncounterId(null); return; }
if (encounters && encounters.length > 0) {
const currentSelection = selectedEncounterIdRef.current;
if (currentSelection === null || !encounters.some(e => e.id === currentSelection)) {
if (initialActiveEncounterId && encounters.some(e => e.id === initialActiveEncounterId)) { setSelectedEncounterId(initialActiveEncounterId); }
else if (activeDisplayInfoFromHook && activeDisplayInfoFromHook.activeCampaignId === campaignId && encounters.some(e => e.id === activeDisplayInfoFromHook.activeEncounterId)) { setSelectedEncounterId(activeDisplayInfoFromHook.activeEncounterId); }
}
} else if (encounters && encounters.length === 0) { setSelectedEncounterId(null); }
}, [campaignId, initialActiveEncounterId, activeDisplayInfoFromHook, encounters]);
const handleCreateEncounter = async (name) => {
if (!db ||!name.trim() || !campaignId) return;
const newEncounterId = generateId();
try { await setDoc(doc(db, getEncountersCollectionPath(campaignId), newEncounterId), { name: name.trim(), createdAt: new Date().toISOString(), participants: [], round: 0, currentTurnParticipantId: null, isStarted: false, isPaused: false }); setShowCreateEncounterModal(false); setSelectedEncounterId(newEncounterId); } catch (err) { console.error("Error creating encounter:", err); }
};
const requestDeleteEncounter = (encounterId, encounterName) => { setItemToDelete({ id: encounterId, name: encounterName, type: 'encounter' }); setShowDeleteEncounterConfirm(true); };
const confirmDeleteEncounter = async () => {
if (!db || !itemToDelete || itemToDelete.type !== 'encounter') return;
const encounterId = itemToDelete.id;
try { await deleteDoc(doc(db, getEncounterDocPath(campaignId, encounterId))); if (selectedEncounterId === encounterId) setSelectedEncounterId(null); if (activeDisplayInfoFromHook && activeDisplayInfoFromHook.activeEncounterId === encounterId) { await updateDoc(doc(db, getActiveDisplayDocPath()), { activeCampaignId: null, activeEncounterId: null }); } } catch (err) { console.error("Error deleting encounter:", err); }
setShowDeleteEncounterConfirm(false); setItemToDelete(null);
};
const handleTogglePlayerDisplayForEncounter = async (encounterId) => {
if (!db) return;
try {
const currentActiveCampaign = activeDisplayInfoFromHook?.activeCampaignId;
const currentActiveEncounter = activeDisplayInfoFromHook?.activeEncounterId;
if (currentActiveCampaign === campaignId && currentActiveEncounter === encounterId) { await setDoc(doc(db, getActiveDisplayDocPath()), { activeCampaignId: null, activeEncounterId: null, }, { merge: true }); console.log("Player Display for this encounter turned OFF."); }
else { await setDoc(doc(db, getActiveDisplayDocPath()), { activeCampaignId: campaignId, activeEncounterId: encounterId, }, { merge: true }); console.log("Encounter set as active for Player Display!"); }
} catch (err) { console.error("Error toggling Player Display for encounter:", err); }
};
const selectedEncounter = encounters?.find(e => e.id === selectedEncounterId);
if (isLoadingEncounters && campaignId) { return Loading encounters...
; }
return (
<>
Encounters
{(!encounters || encounters.length === 0) &&
No encounters yet.
}
{encounters?.map(encounter => {
const isLiveOnPlayerDisplay = activeDisplayInfoFromHook && activeDisplayInfoFromHook.activeCampaignId === campaignId && activeDisplayInfoFromHook.activeEncounterId === encounter.id;
return (
setSelectedEncounterId(encounter.id)} className="cursor-pointer flex-grow">
{encounter.name}
Participants: {encounter.participants?.length || 0}
{isLiveOnPlayerDisplay &&
LIVE ON PLAYER DISPLAY}
);
})}
{showCreateEncounterModal &&
setShowCreateEncounterModal(false)} title="Create New Encounter"> setShowCreateEncounterModal(false)} />}
{selectedEncounter && (
Managing Encounter: {selectedEncounter.name}
)}
setShowDeleteEncounterConfirm(false)} onConfirm={confirmDeleteEncounter} title="Delete Encounter?" message={`Are you sure you want to delete the encounter "${itemToDelete?.name}"? This action cannot be undone.`}/>
>
);
}
function CreateEncounterForm({ onCreate, onCancel }) {
const [name, setName] = useState('');
return (
);
}
function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
const [participantName, setParticipantName] = useState('');
const [participantType, setParticipantType] = useState('monster');
const [selectedCharacterId, setSelectedCharacterId] = useState('');
const [monsterInitMod, setMonsterInitMod] = useState(2);
const [maxHp, setMaxHp] = useState(10);
const [isNpc, setIsNpc] = useState(false);
const [editingParticipant, setEditingParticipant] = useState(null);
const [hpChangeValues, setHpChangeValues] = useState({});
const [draggedItemId, setDraggedItemId] = useState(null);
const [showDeleteParticipantConfirm, setShowDeleteParticipantConfirm] = useState(false);
const [itemToDelete, setItemToDelete] = useState(null);
const [lastRollDetails, setLastRollDetails] = useState(null);
const participants = encounter.participants || [];
const MONSTER_DEFAULT_INIT_MOD = 2;
useEffect(() => {
if (participantType === 'character' && selectedCharacterId) {
const selectedChar = campaignCharacters.find(c => c.id === selectedCharacterId);
if (selectedChar && selectedChar.defaultMaxHp) { setMaxHp(selectedChar.defaultMaxHp); } else { setMaxHp(10); }
setIsNpc(false);
} else if (participantType === 'monster') {
setMaxHp(10);
setMonsterInitMod(MONSTER_DEFAULT_INIT_MOD);
}
}, [selectedCharacterId, participantType, campaignCharacters]);
const handleAddParticipant = async () => {
if (!db || (participantType === 'monster' && !participantName.trim()) || (participantType === 'character' && !selectedCharacterId)) return;
let nameToAdd = participantName.trim();
const initiativeRoll = rollD20();
let modifier = 0;
let finalInitiative;
let currentMaxHp = parseInt(maxHp, 10) || 10;
let participantIsNpc = false;
if (participantType === 'character') {
const character = campaignCharacters.find(c => c.id === selectedCharacterId);
if (!character) { console.error("Selected character not found"); return; }
if (participants.some(p => p.type === 'character' && p.originalCharacterId === selectedCharacterId)) { alert(`${character.name} is already in this encounter.`); return; }
nameToAdd = character.name;
currentMaxHp = character.defaultMaxHp || currentMaxHp;
modifier = character.defaultInitMod || 0;
finalInitiative = initiativeRoll + modifier;
} else {
modifier = parseInt(monsterInitMod, 10) || 0;
finalInitiative = initiativeRoll + modifier;
participantIsNpc = isNpc;
}
const newParticipant = {
id: generateId(), name: nameToAdd, type: participantType,
originalCharacterId: participantType === 'character' ? selectedCharacterId : null,
initiative: finalInitiative, maxHp: currentMaxHp, currentHp: currentMaxHp,
isNpc: participantType === 'monster' ? participantIsNpc : false,
conditions: [], isActive: true,
};
try {
await updateDoc(doc(db, encounterPath), { participants: [...participants, newParticipant] });
setLastRollDetails({ name: nameToAdd, roll: initiativeRoll, mod: modifier, total: finalInitiative, type: participantIsNpc ? 'NPC' : participantType });
setTimeout(() => setLastRollDetails(null), 5000);
setParticipantName(''); setMaxHp(10); setSelectedCharacterId(''); setMonsterInitMod(MONSTER_DEFAULT_INIT_MOD); setIsNpc(false);
} catch (err) { console.error("Error adding participant:", err); }
};
const handleAddAllCampaignCharacters = async () => {
if (!db || !campaignCharacters || campaignCharacters.length === 0) return;
const existingParticipantOriginalIds = participants.filter(p => p.type === 'character' && p.originalCharacterId).map(p => p.originalCharacterId);
let consoleRollLog = "Adding all campaign characters:\n";
const newParticipants = campaignCharacters
.filter(char => !existingParticipantOriginalIds.includes(char.id))
.map(char => {
const initiativeRoll = rollD20();
const modifier = char.defaultInitMod || 0;
const finalInitiative = initiativeRoll + modifier;
consoleRollLog += `${char.name}: Rolled D20 (${initiativeRoll}) + ${modifier} (mod) = ${finalInitiative} init\n`;
return { id: generateId(), name: char.name, type: 'character', originalCharacterId: char.id, initiative: finalInitiative, maxHp: char.defaultMaxHp || 10, currentHp: char.defaultMaxHp || 10, conditions: [], isActive: true, isNpc: false };
});
if (newParticipants.length === 0) { alert("All campaign characters are already in this encounter."); return; }
console.log(consoleRollLog);
try { await updateDoc(doc(db, encounterPath), { participants: [...participants, ...newParticipants] }); console.log(`Added ${newParticipants.length} characters to the encounter.`); }
catch (err) { console.error("Error adding all campaign characters:", err); }
};
const handleUpdateParticipant = async (updatedData) => {
if (!db || !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 requestDeleteParticipant = (participantId, participantName) => { setItemToDelete({ id: participantId, name: participantName, type: 'participant' }); setShowDeleteParticipantConfirm(true); };
const confirmDeleteParticipant = async () => {
if (!db || !itemToDelete || itemToDelete.type !== 'participant') return;
const participantId = itemToDelete.id;
const updatedParticipants = participants.filter(p => p.id !== participantId);
try { await updateDoc(doc(db, encounterPath), { participants: updatedParticipants }); } catch (err) { console.error("Error deleting participant:", err); }
setShowDeleteParticipantConfirm(false); setItemToDelete(null);
};
const toggleParticipantActive = async (participantId) => {
if (!db) return;
const pToToggle = participants.find(p => p.id === participantId);
if (!pToToggle) return;
const updatedPs = participants.map(p => p.id === participantId ? { ...p, isActive: !p.isActive } : p);
try { await updateDoc(doc(db, encounterPath), { participants: updatedPs }); } catch (err) { console.error("Error toggling active state:", err); }
};
const handleHpInputChange = (participantId, value) => setHpChangeValues(prev => ({ ...prev, [participantId]: value }));
const applyHpChange = async (participantId, changeType) => {
if (!db) return;
const amountStr = hpChangeValues[participantId];
if (amountStr === undefined || amountStr.trim() === '') return;
const amount = parseInt(amountStr, 10);
if (isNaN(amount) || amount === 0) { setHpChangeValues(prev => ({ ...prev, [participantId]: '' })); return; }
const pToChange = participants.find(p => p.id === participantId);
if (!pToChange) return;
let newHp = pToChange.currentHp;
if (changeType === 'damage') newHp = Math.max(0, pToChange.currentHp - amount);
else if (changeType === 'heal') newHp = Math.min(pToChange.maxHp, pToChange.currentHp + amount);
const updatedPs = participants.map(p => p.id === participantId ? { ...p, currentHp: newHp } : p);
try { await updateDoc(doc(db, encounterPath), { participants: updatedPs }); setHpChangeValues(prev => ({ ...prev, [participantId]: '' })); } catch (err) { console.error("Error applying HP change:", err); }
};
const handleDragStart = (e, id) => { setDraggedItemId(id); e.dataTransfer.effectAllowed = 'move'; };
const handleDragOver = (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; };
const handleDrop = async (e, targetId) => {
e.preventDefault();
if (!db || draggedItemId === null || draggedItemId === targetId) { setDraggedItemId(null); return; }
const currentParticipants = [...participants];
const draggedItemIndex = currentParticipants.findIndex(p => p.id === draggedItemId);
const targetItemIndex = currentParticipants.findIndex(p => p.id === targetId);
if (draggedItemIndex === -1 || targetItemIndex === -1) { console.error("Dragged or target item not found."); setDraggedItemId(null); return; }
const draggedItem = currentParticipants[draggedItemIndex];
const targetItem = currentParticipants[targetItemIndex];
if (draggedItem.initiative !== targetItem.initiative) { console.log("Drag-drop only for same initiative."); setDraggedItemId(null); return; }
const [removedItem] = currentParticipants.splice(draggedItemIndex, 1);
currentParticipants.splice(targetItemIndex, 0, removedItem);
try { await updateDoc(doc(db, encounterPath), { participants: currentParticipants }); console.log("Participants reordered."); } catch (err) { console.error("Error D&D update:", err); }
setDraggedItemId(null);
};
const handleDragEnd = () => { setDraggedItemId(null); };
const sortedAdminParticipants = [...participants].sort((a, b) => {
if (a.initiative === b.initiative) { const indexA = participants.findIndex(p => p.id === a.id); const indexB = participants.findIndex(p => p.id === b.id); return indexA - indexB; }
return b.initiative - a.initiative;
});
const initiativeGroups = participants.reduce((acc, p) => { acc[p.initiative] = (acc[p.initiative] || 0) + 1; return acc; }, {});
const tiedInitiatives = Object.keys(initiativeGroups).filter(init => initiativeGroups[init] > 1).map(Number);
return (
<>
Add Participants
{lastRollDetails && (
{lastRollDetails.name} ({lastRollDetails.type === 'character' ? 'Character' : lastRollDetails.type}): Rolled D20 ({lastRollDetails.roll}) {lastRollDetails.mod >= 0 ? '+' : ''} {lastRollDetails.mod} (mod) = {lastRollDetails.total} Initiative
)}
{participants.length === 0 &&
No participants.
}
{sortedAdminParticipants.map((p) => {
const isCurrentTurn = encounter.isStarted && p.id === encounter.currentTurnParticipantId;
const isDraggable = (!encounter.isStarted || encounter.isPaused) && tiedInitiatives.includes(Number(p.initiative));
const participantDisplayType = p.type === 'monster' ? (p.isNpc ? 'NPC' : 'Monster') : 'Character';
let bgColor = p.type === 'character' ? 'bg-sky-800' : (p.isNpc ? 'bg-slate-600' : 'bg-[#8e351c]'); // Custom hex for monster
if (isCurrentTurn && !encounter.isPaused) bgColor = 'bg-green-600';
return (
- handleDragStart(e, p.id) : undefined} onDragOver={isDraggable ? handleDragOver : undefined} onDrop={isDraggable ? (e) => handleDrop(e, p.id) : undefined} onDragEnd={isDraggable ? handleDragEnd : undefined}
className={`p-3 rounded-md flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 transition-all ${bgColor} ${isCurrentTurn && !encounter.isPaused ? 'ring-2 ring-green-300 shadow-lg' : ''} ${!p.isActive ? 'opacity-50' : ''} ${isDraggable ? 'cursor-grab' : ''} ${draggedItemId === p.id ? 'opacity-50 ring-2 ring-yellow-400' : ''}`}>
{isDraggable &&
}
{p.name} ({participantDisplayType}){isCurrentTurn && !encounter.isPaused && CURRENT}
Init: {p.initiative} | HP: {p.currentHp}/{p.maxHp}
{encounter.isStarted && p.isActive && (
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}`}/>
)}
);
})}
{editingParticipant &&
setEditingParticipant(null)} onSave={handleUpdateParticipant} />}
setShowDeleteParticipantConfirm(false)} onConfirm={confirmDeleteParticipant} title="Delete Participant?" message={`Are you sure you want to remove "${itemToDelete?.name}" from this encounter?`}/>
>
);
}
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 [isNpc, setIsNpc] = useState(participant.type === 'monster' ? (participant.isNpc || false) : false);
const handleSubmit = (e) => {
e.preventDefault();
onSave({
name: name.trim(),
initiative: parseInt(initiative, 10),
currentHp: parseInt(currentHp, 10),
maxHp: parseInt(maxHp, 10),
isNpc: participant.type === 'monster' ? isNpc : false,
});
};
return (
);
}
function InitiativeControls({ campaignId, encounter, encounterPath }) {
const [showEndEncounterConfirm, setShowEndEncounterConfirm] = useState(false);
const handleStartEncounter = async () => {
if (!db ||!encounter.participants || encounter.participants.length === 0) { alert("Add participants first."); return; }
const activePs = encounter.participants.filter(p => p.isActive);
if (activePs.length === 0) { alert("No active participants."); return; }
const sortedPs = [...activePs].sort((a, b) => {
if (a.initiative === b.initiative) { const indexA = encounter.participants.findIndex(p => p.id === a.id); const indexB = encounter.participants.findIndex(p => p.id === b.id); return indexA - indexB; }
return b.initiative - a.initiative;
});
try {
await updateDoc(doc(db, encounterPath), { isStarted: true, isPaused: false, round: 1, currentTurnParticipantId: sortedPs[0].id, turnOrderIds: sortedPs.map(p => p.id) });
await setDoc(doc(db, getActiveDisplayDocPath()), { activeCampaignId: campaignId, activeEncounterId: encounter.id }, { merge: true });
console.log("Encounter started and set as active display.");
} catch (err) { console.error("Error starting encounter:", err); }
};
const handleTogglePause = async () => {
if (!db || !encounter || !encounter.isStarted) return;
const newPausedState = !encounter.isPaused;
let newTurnOrderIds = encounter.turnOrderIds;
if (!newPausedState && encounter.isPaused) {
const activeParticipants = encounter.participants.filter(p => p.isActive);
const sortedActiveParticipants = [...activeParticipants].sort((a, b) => {
if (a.initiative === b.initiative) { const indexA = encounter.participants.findIndex(p => p.id === a.id); const indexB = encounter.participants.findIndex(p => p.id === b.id); return indexA - indexB; }
return b.initiative - a.initiative;
});
newTurnOrderIds = sortedActiveParticipants.map(p => p.id);
}
try { await updateDoc(doc(db, encounterPath), { isPaused: newPausedState, turnOrderIds: newTurnOrderIds }); console.log(`Encounter ${newPausedState ? 'paused' : 'resumed'}.`); } catch (err) { console.error("Error toggling pause state:", err); }
};
const handleNextTurn = async () => {
if (!db ||!encounter.isStarted || encounter.isPaused || !encounter.currentTurnParticipantId || !encounter.turnOrderIds || encounter.turnOrderIds.length === 0) return;
const activePsInOrder = encounter.turnOrderIds.map(id => encounter.participants.find(p => p.id === id && p.isActive)).filter(Boolean);
if (activePsInOrder.length === 0) { alert("No active participants left."); await updateDoc(doc(db, encounterPath), { isStarted: false, isPaused: false, currentTurnParticipantId: null, round: encounter.round }); return; }
const currentIndex = activePsInOrder.findIndex(p => p.id === encounter.currentTurnParticipantId);
let nextIndex = (currentIndex + 1) % activePsInOrder.length;
let nextRound = encounter.round;
if (nextIndex === 0 && currentIndex !== -1) nextRound += 1;
try { await updateDoc(doc(db, encounterPath), { currentTurnParticipantId: activePsInOrder[nextIndex].id, round: nextRound }); } catch (err) { console.error("Error advancing turn:", err); }
};
const requestEndEncounter = () => { setShowEndEncounterConfirm(true); };
const confirmEndEncounter = async () => {
if (!db) return;
try {
await updateDoc(doc(db, encounterPath), { isStarted: false, isPaused: false, currentTurnParticipantId: null, round: 0, turnOrderIds: [] });
await setDoc(doc(db, getActiveDisplayDocPath()), { activeCampaignId: null, activeEncounterId: null }, { merge: true });
console.log("Encounter ended and deactivated from Player Display.");
} catch (err) { console.error("Error ending encounter:", err); }
setShowEndEncounterConfirm(false);
};
if (!encounter || !encounter.participants) return null;
return (
<>
Combat Controls
{!encounter.isStarted ? (
) : (
<>
Round: {encounter.round}
{encounter.isPaused &&
(Paused)
}
>
)}
setShowEndEncounterConfirm(false)} onConfirm={confirmEndEncounter} title="End Encounter?" message={`Are you sure you want to end this encounter? Initiative order will be reset and it will be removed from the Player Display.`}/>
>
);
}
function DisplayView() {
const { data: activeDisplayData, isLoading: isLoadingActiveDisplay, error: activeDisplayError } = useFirestoreDocument(getActiveDisplayDocPath());
const [activeEncounterData, setActiveEncounterData] = useState(null);
const [isLoadingEncounter, setIsLoadingEncounter] = useState(true);
const [encounterError, setEncounterError] = useState(null);
const [campaignBackgroundUrl, setCampaignBackgroundUrl] = useState('');
const [isPlayerDisplayActive, setIsPlayerDisplayActive] = useState(false);
useEffect(() => {
if (!db) { setEncounterError("Firestore not available."); setIsLoadingEncounter(false); setIsPlayerDisplayActive(false); return; }
let unsubscribeEncounter;
let unsubscribeCampaign;
if (activeDisplayData) {
const { activeCampaignId, activeEncounterId } = activeDisplayData;
if (activeCampaignId && activeEncounterId) {
setIsPlayerDisplayActive(true); setIsLoadingEncounter(true); setEncounterError(null);
const campaignDocRef = doc(db, getCampaignDocPath(activeCampaignId));
unsubscribeCampaign = onSnapshot(campaignDocRef, (campSnap) => { if (campSnap.exists()) { setCampaignBackgroundUrl(campSnap.data().playerDisplayBackgroundUrl || ''); } else { setCampaignBackgroundUrl(''); } }, (err) => console.error("Error fetching campaign background:", err));
const encounterPath = getEncounterDocPath(activeCampaignId, activeEncounterId);
unsubscribeEncounter = onSnapshot(doc(db, encounterPath), (encDocSnap) => {
if (encDocSnap.exists()) { setActiveEncounterData({ id: encDocSnap.id, ...encDocSnap.data() }); }
else { setActiveEncounterData(null); setEncounterError("Active encounter data not found."); }
setIsLoadingEncounter(false);
}, (err) => { console.error("Error fetching active encounter details:", err); setEncounterError("Error loading active encounter data."); setIsLoadingEncounter(false);});
} else { setActiveEncounterData(null); setCampaignBackgroundUrl(''); setIsPlayerDisplayActive(false); setIsLoadingEncounter(false); }
} else if (!isLoadingActiveDisplay) { setActiveEncounterData(null); setCampaignBackgroundUrl(''); setIsPlayerDisplayActive(false); setIsLoadingEncounter(false); }
return () => { if (unsubscribeEncounter) unsubscribeEncounter(); if (unsubscribeCampaign) unsubscribeCampaign(); };
}, [activeDisplayData, isLoadingActiveDisplay]);
if (isLoadingActiveDisplay || (isPlayerDisplayActive && isLoadingEncounter)) { return Loading Player Display...
; }
if (activeDisplayError || (isPlayerDisplayActive && encounterError)) { return {activeDisplayError || encounterError}
; }
if (!isPlayerDisplayActive || !activeEncounterData) {
return (
Game Session Paused
The Dungeon Master has not activated an encounter for display.
);
}
const { name, participants, round, currentTurnParticipantId, isStarted, isPaused } = activeEncounterData;
let participantsToRender = [];
if (participants) {
if (isStarted && activeEncounterData.turnOrderIds?.length > 0 ) {
const inTurnOrderAndActive = activeEncounterData.turnOrderIds.map(id => participants.find(p => p.id === id)).filter(p => p && p.isActive);
const notInTurnOrderButActive = participants.filter(p => p.isActive && !activeEncounterData.turnOrderIds.includes(p.id)).sort((a,b) => { if(a.initiative === b.initiative) { const indexA = participants.findIndex(op => op.id === a.id); const indexB = participants.findIndex(op => op.id === b.id); return indexA - indexB; } return b.initiative - a.initiative; });
participantsToRender = [...inTurnOrderAndActive, ...notInTurnOrderButActive];
} else {
participantsToRender = [...participants].filter(p => p.isActive).sort((a, b) => { if (a.initiative === b.initiative) { const indexA = participants.findIndex(p => p.id === a.id); const indexB = participants.findIndex(p => p.id === b.id); return indexA - indexB; } return b.initiative - a.initiative; });
}
}
const displayStyles = campaignBackgroundUrl ? { backgroundImage: `url(${campaignBackgroundUrl})`, backgroundSize: 'cover', backgroundPosition: 'center center', backgroundRepeat: 'no-repeat', minHeight: '100vh' } : { minHeight: '100vh' };
return (
{name}
{isStarted &&
Round: {round}
}
{isStarted && isPaused &&
(Combat Paused)
}
{!isStarted && participants?.length > 0 &&
Awaiting Start
}
{!isStarted && (!participants || participants.length === 0) &&
No participants.
}
{participantsToRender.length === 0 && isStarted &&
No active participants.
}
{participantsToRender.map(p => {
let participantBgColor = p.type === 'monster' ? (p.isNpc ? 'bg-slate-700' : 'bg-[#8e351c]') : 'bg-sky-700';
if (p.id === currentTurnParticipantId && isStarted && !isPaused) {
participantBgColor = 'bg-green-700 ring-4 ring-green-400 scale-105';
} else if (isPaused && p.id === currentTurnParticipantId) {
participantBgColor += ' ring-2 ring-yellow-400';
}
return (
{p.name}{p.id === currentTurnParticipantId && isStarted && !isPaused && (Current)}
{/* Ensure monster text is white with new bg */}
Init: {p.initiative}
{p.type !== 'monster' && (
HP: {p.currentHp} / {p.maxHp})}
{p.conditions?.length > 0 &&
Conditions: {p.conditions.join(', ')}
}
{!p.isActive &&
(Inactive)
}
);
})}
);
}
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 (
);
}
// --- Icons ---
// PlayIcon, SkipForwardIcon, StopCircleIcon are now imported from lucide-react at the top
export default App;