import React, { useState, useEffect, useRef, useMemo } from 'react'; import { initializeApp } from './storage'; import { getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken } from './storage'; import { getFirestore, doc, setDoc, addDoc, collection, onSnapshot, updateDoc, deleteDoc, query, orderBy, limit, writeBatch, getStorage, getStorageMode } from './storage'; 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, ChevronUp, ChevronDown, ScrollText, Maximize2, Minimize2, Moon, Coffee } from 'lucide-react'; // Custom CSS for death animation (player view only) const deathAnimationStyles = ` @keyframes death-dissolve { 0% { opacity: 1; transform: scale(1) translateY(0); } 50% { opacity: 0.5; transform: scale(0.95) translateY(-5px); filter: blur(2px); } 100% { opacity: 0; transform: scale(0.8) translateY(-10px); filter: blur(4px); } } .animate-death-dissolve { animation: death-dissolve 2s ease-in-out forwards; } `; // Inject styles if (typeof document !== 'undefined') { const styleElement = document.createElement('style'); styleElement.innerHTML = deathAnimationStyles; document.head.appendChild(styleElement); } // ============================================================================ // CONSTANTS // ============================================================================ const APP_VERSION = 'v0.3'; const DEFAULT_MAX_HP = 10; const DEFAULT_INIT_MOD = 0; const MONSTER_DEFAULT_INIT_MOD = 2; const ROLL_DISPLAY_DURATION = 5000; const CONDITIONS = [ { id: 'alchemist_fire', label: 'Alchemist Fire', emoji: 'πŸ”₯' }, { id: 'bardic_inspiration', label: 'Bardic Inspiration', emoji: '🎡' }, { id: 'blinded', label: 'Blinded', emoji: 'πŸ™ˆ' }, { id: 'charmed', label: 'Charmed', emoji: 'πŸ’˜' }, { id: 'deafened', label: 'Deafened', emoji: 'πŸ”‡' }, { id: 'exhaustion', label: 'Exhaustion', emoji: '😴' }, { id: 'frightened', label: 'Frightened', emoji: '😱' }, { id: 'grappled', label: 'Grappled', emoji: '🀜' }, { id: 'grazed', label: 'Grazed', emoji: '🩹' }, { id: 'incapacitated', label: 'Incapacitated', emoji: 'πŸ’«' }, { id: 'invisible', label: 'Invisible', emoji: 'πŸ‘»' }, { id: 'paralyzed', label: 'Paralyzed', emoji: '⚑' }, { id: 'petrified', label: 'Petrified', emoji: 'πŸ—Ώ' }, { id: 'poisoned', label: 'Poisoned', emoji: '🀒' }, { id: 'prone', label: 'Prone', emoji: '⬇️' }, { id: 'restrained', label: 'Restrained', emoji: 'πŸ•ΈοΈ' }, { id: 'sapped', label: 'Sapped', emoji: 'πŸ”¨' }, { id: 'shield', label: 'Shield', emoji: 'πŸ›‘οΈ' }, { id: 'slowed', label: 'Slowed', emoji: '🐌' }, { id: 'stunned', label: 'Stunned', emoji: 'πŸ’₯' }, { id: 'unconscious', label: 'Unconscious', emoji: 'πŸ’€' }, { id: 'vexed', label: 'Vexed', emoji: '🎯' }, ]; // ============================================================================ // 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 }; const APP_ID = process.env.REACT_APP_TRACKER_APP_ID || 'ttrpg-initiative-tracker-default'; const PUBLIC_DATA_PATH = `artifacts/${APP_ID}/public/data`; let app; let db; let auth; let storage; const STORAGE_MODE = getStorageMode(); // Initialize storage backend. firebase mode = real SDK init. // ws/memory mode = mock auth, no firebase. const initializeStorage = () => { if (STORAGE_MODE === 'firebase') { const requiredKeys = ['apiKey', 'authDomain', 'projectId', 'appId']; const missingKeys = requiredKeys.filter(key => !firebaseConfig[key]); if (missingKeys.length > 0) { console.error(`CRITICAL: Missing Firebase config values: ${missingKeys.join(', ')}`); return false; } try { app = initializeApp(firebaseConfig); db = getFirestore(app); auth = getAuth(app); storage = getStorage(); return true; } catch (error) { console.error("Error initializing Firebase:", error); return false; } } // ws / memory mode: stub auth so App's anon-sign-in path works. const FAKE_USER = { uid: 'local-user', isAnonymous: true }; auth = { currentUser: FAKE_USER, }; storage = getStorage(); return true; }; const isInitialized = initializeStorage(); // ============================================================================ // FIRESTORE PATH HELPERS // ============================================================================ const getPath = { campaigns: () => `${PUBLIC_DATA_PATH}/campaigns`, campaign: (id) => `${PUBLIC_DATA_PATH}/campaigns/${id}`, encounters: (campaignId) => `${PUBLIC_DATA_PATH}/campaigns/${campaignId}/encounters`, encounter: (campaignId, encounterId) => `${PUBLIC_DATA_PATH}/campaigns/${campaignId}/encounters/${encounterId}`, activeDisplay: () => `${PUBLIC_DATA_PATH}/activeDisplay/status`, logs: () => `${PUBLIC_DATA_PATH}/logs` }; // ============================================================================ // UTILITY FUNCTIONS // ============================================================================ const generateId = () => crypto.randomUUID(); const rollD20 = () => Math.floor(Math.random() * 20) + 1; const formatInitMod = (mod) => { if (mod === undefined || mod === null) return 'N/A'; return mod >= 0 ? `+${mod}` : `${mod}`; }; const sortParticipantsByInitiative = (participants, originalOrder) => { return [...participants].sort((a, b) => { if (a.initiative === b.initiative) { const indexA = originalOrder.findIndex(p => p.id === a.id); const indexB = originalOrder.findIndex(p => p.id === b.id); return indexA - indexB; } return b.initiative - a.initiative; }); }; const LOG_QUERY = [orderBy('timestamp', 'desc'), limit(500)]; const logAction = async (message, context = {}, undoData = null) => { if (!db) return; try { const entry = { timestamp: Date.now(), message, ...context }; if (undoData) entry.undo = undoData; await storage.addDoc(getPath.logs(), entry); } catch (err) { console.error('Error writing log:', err); } }; // Returns turnOrderIds/currentTurnParticipantId updates when a participant leaves active combat. const computeTurnOrderAfterRemoval = (encounter, removedId, updatedParticipants) => { if (!encounter.isStarted) return {}; const currentIds = encounter.turnOrderIds || []; const newIds = currentIds.filter(id => id !== removedId); const updates = { turnOrderIds: newIds }; if (encounter.currentTurnParticipantId === removedId) { const removedPos = currentIds.indexOf(removedId); const candidates = [...currentIds.slice(removedPos + 1), ...currentIds.slice(0, removedPos)]; const nextId = candidates.find(id => updatedParticipants.find(p => p.id === id && p.isActive)) ?? null; updates.currentTurnParticipantId = nextId; } return updates; }; // Returns turnOrderIds update when a participant re-enters active combat mid-encounter. const computeTurnOrderAfterAddition = (encounter, addedId) => { if (!encounter.isStarted) return {}; const currentIds = encounter.turnOrderIds || []; if (currentIds.includes(addedId)) return {}; return { turnOrderIds: [...currentIds, addedId] }; }; // ============================================================================ // CUSTOM HOOKS // ============================================================================ function useFirestoreDocument(docPath) { const [data, setData] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { if (!docPath) { setData(null); setIsLoading(false); setError("Document path not provided."); return; } setIsLoading(true); setError(null); const storage = getStorage(); const unsubscribe = storage.subscribeDoc(docPath, (doc) => { setData(doc); setIsLoading(false); }); return () => { if (typeof unsubscribe === 'function') 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 (!collectionPath) { setData([]); setIsLoading(false); setError("Collection path not provided."); return; } setIsLoading(true); setError(null); const storage = getStorage(); const unsubscribe = storage.subscribeCollection(collectionPath, (items) => { setData(items); setIsLoading(false); }); return () => { if (typeof unsubscribe === 'function') unsubscribe(); }; // queryString, not array ref // eslint-disable-next-line react-hooks/exhaustive-deps }, [collectionPath, queryString]); return { data, isLoading, error }; } // ============================================================================ // REUSABLE COMPONENTS // ============================================================================ 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 (

{title}

{children}
); } function ConfirmationModal({ isOpen, onClose, onConfirm, title, message }) { if (!isOpen) return null; return (

{title || "Confirm Action"}

{message || "Are you sure you want to proceed?"}

); } function LoadingSpinner({ message = "Loading..." }) { return (

{message}

); } function ErrorDisplay({ message, critical = false }) { return (

{critical ? 'Configuration Error' : 'Error'}

{message}

); } // ============================================================================ // FORM COMPONENTS // ============================================================================ function CreateCampaignForm({ onCreate, onCancel }) { const [name, setName] = useState(''); const [backgroundUrl, setBackgroundUrl] = useState(''); const handleSubmit = (e) => { e.preventDefault(); if (name.trim()) { onCreate(name, backgroundUrl); } }; return (
setName(e.target.value)} className="mt-1 block w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white" required />
setBackgroundUrl(e.target.value)} placeholder="https://example.com/image.jpg" className="mt-1 block w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white" />
); } function CreateEncounterForm({ onCreate, onCancel }) { const [name, setName] = useState(''); const handleSubmit = (e) => { e.preventDefault(); if (name.trim()) { onCreate(name); } }; return (
setName(e.target.value)} className="mt-1 block w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white" required />
); } 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 (
setName(e.target.value)} className="mt-1 block w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white" />
setInitiative(e.target.value)} className="mt-1 block w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white" />
setCurrentHp(e.target.value)} className="mt-1 block w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white" />
setMaxHp(e.target.value)} className="mt-1 block w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white" />
{participant.type === 'monster' && (
setIsNpc(e.target.checked)} className="h-4 w-4 text-violet-600 border-stone-400 rounded focus:ring-violet-500" />
)}
); } // ============================================================================ // CHARACTER MANAGER COMPONENT // ============================================================================ function CharacterManager({ campaignId, campaignCharacters }) { const [characterName, setCharacterName] = useState(''); const [defaultMaxHp, setDefaultMaxHp] = useState(DEFAULT_MAX_HP); const [defaultInitMod, setDefaultInitMod] = useState(DEFAULT_INIT_MOD); const [editingCharacter, setEditingCharacter] = useState(null); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [itemToDelete, setItemToDelete] = useState(null); const [isOpen, setIsOpen] = useState(true); 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 storage.updateDoc(getPath.campaign(campaignId), { players: [...campaignCharacters, newCharacter] }); setCharacterName(''); setDefaultMaxHp(DEFAULT_MAX_HP); setDefaultInitMod(DEFAULT_INIT_MOD); } catch (err) { console.error("Error adding character:", err); alert("Failed to add character. Please try again."); } }; 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 storage.updateDoc(getPath.campaign(campaignId), { players: updatedCharacters }); setEditingCharacter(null); } catch (err) { console.error("Error updating character:", err); alert("Failed to update character. Please try again."); } }; const requestDeleteCharacter = (characterId, charName) => { setItemToDelete({ id: characterId, name: charName }); setShowDeleteConfirm(true); }; const confirmDeleteCharacter = async () => { if (!db || !itemToDelete) return; const updatedCharacters = campaignCharacters.filter(c => c.id !== itemToDelete.id); try { await storage.updateDoc(getPath.campaign(campaignId), { players: updatedCharacters }); } catch (err) { console.error("Error deleting character:", err); alert("Failed to delete character. Please try again."); } setShowDeleteConfirm(false); setItemToDelete(null); }; return ( <>

Campaign Characters

{isOpen && ( <>
{ e.preventDefault(); handleAddCharacter(); }} className="grid grid-cols-1 sm:grid-cols-3 gap-2 mb-4 items-end">
setCharacterName(e.target.value)} placeholder="Character name" className="w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white" />
setDefaultMaxHp(e.target.value)} className="w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white" />
setDefaultInitMod(e.target.value)} className="w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white" />
{campaignCharacters.length === 0 && (

No characters added yet.

)} )}
setShowDeleteConfirm(false)} onConfirm={confirmDeleteCharacter} title="Delete Character?" message={`Are you sure you want to remove "${itemToDelete?.name}" from this campaign?`} /> ); } // ============================================================================ // PARTICIPANT MANAGER COMPONENT // ============================================================================ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) { const [participantName, setParticipantName] = useState(''); const [participantType, setParticipantType] = useState('monster'); const [selectedCharacterId, setSelectedCharacterId] = useState(''); const [monsterInitMod, setMonsterInitMod] = useState(MONSTER_DEFAULT_INIT_MOD); const [maxHp, setMaxHp] = useState(DEFAULT_MAX_HP); const [isNpc, setIsNpc] = useState(false); const [editingParticipant, setEditingParticipant] = useState(null); const [hpChangeValues, setHpChangeValues] = useState({}); const [draggedItemId, setDraggedItemId] = useState(null); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [itemToDelete, setItemToDelete] = useState(null); const [lastRollDetails, setLastRollDetails] = useState(null); const [openConditionsId, setOpenConditionsId] = useState(null); const participants = encounter.participants || []; useEffect(() => { if (participantType === 'character' && selectedCharacterId) { const selectedChar = campaignCharacters.find(c => c.id === selectedCharacterId); if (selectedChar && selectedChar.defaultMaxHp) { setMaxHp(selectedChar.defaultMaxHp); } else { setMaxHp(DEFAULT_MAX_HP); } setIsNpc(false); } else if (participantType === 'monster') { setMaxHp(DEFAULT_MAX_HP); setMonsterInitMod(MONSTER_DEFAULT_INIT_MOD); } }, [selectedCharacterId, participantType, campaignCharacters]); const handleAddParticipant = async () => { if (!db) return; if (participantType === 'monster' && !participantName.trim()) return; if (participantType === 'character' && !selectedCharacterId) return; let nameToAdd = participantName.trim(); const initiativeRoll = rollD20(); let modifier = 0; let currentMaxHp = parseInt(maxHp, 10) || DEFAULT_MAX_HP; 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; } else { modifier = parseInt(monsterInitMod, 10) || 0; participantIsNpc = isNpc; } const finalInitiative = initiativeRoll + modifier; 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, deathSaves: 0, // Track failed death saves (0-3) isDying: false, // For death animation on player display }; try { await storage.updateDoc(encounterPath, { participants: [...participants, newParticipant] }); logAction(`${nameToAdd} added to encounter (Initiative: ${finalInitiative})`, { encounterName: encounter.name }, { encounterPath, updates: { participants: [...participants] }, }); setLastRollDetails({ name: nameToAdd, roll: initiativeRoll, mod: modifier, total: finalInitiative, type: participantIsNpc ? 'NPC' : participantType }); setTimeout(() => setLastRollDetails(null), ROLL_DISPLAY_DURATION); // Reset form setParticipantName(''); setMaxHp(DEFAULT_MAX_HP); setSelectedCharacterId(''); setMonsterInitMod(MONSTER_DEFAULT_INIT_MOD); setIsNpc(false); } catch (err) { console.error("Error adding participant:", err); alert("Failed to add participant. Please try again."); } }; const handleAddAllCampaignCharacters = async () => { if (!db || !campaignCharacters || campaignCharacters.length === 0) return; const existingCharacterIds = participants .filter(p => p.type === 'character' && p.originalCharacterId) .map(p => p.originalCharacterId); const newParticipants = campaignCharacters .filter(char => !existingCharacterIds.includes(char.id)) .map(char => { const initiativeRoll = rollD20(); const modifier = char.defaultInitMod || 0; const finalInitiative = initiativeRoll + modifier; return { id: generateId(), name: char.name, type: 'character', originalCharacterId: char.id, initiative: finalInitiative, maxHp: char.defaultMaxHp || DEFAULT_MAX_HP, currentHp: char.defaultMaxHp || DEFAULT_MAX_HP, conditions: [], isActive: true, isNpc: false, deathSaves: 0, isDying: false, }; }); if (newParticipants.length === 0) { alert("All campaign characters are already in this encounter."); return; } try { await storage.updateDoc(encounterPath, { participants: [...participants, ...newParticipants] }); console.log(`Added ${newParticipants.length} characters to the encounter.`); } catch (err) { console.error("Error adding all campaign characters:", err); alert("Failed to add all characters. Please try again."); } }; const handleUpdateParticipant = async (updatedData) => { if (!db || !editingParticipant) return; const updatedParticipants = participants.map(p => p.id === editingParticipant.id ? { ...p, ...updatedData } : p ); try { await storage.updateDoc(encounterPath, { participants: updatedParticipants }); setEditingParticipant(null); } catch (err) { console.error("Error updating participant:", err); alert("Failed to update participant. Please try again."); } }; const requestDeleteParticipant = (participantId, participantName) => { setItemToDelete({ id: participantId, name: participantName }); setShowDeleteConfirm(true); }; const confirmDeleteParticipant = async () => { if (!db || !itemToDelete) return; const updatedParticipants = participants.filter(p => p.id !== itemToDelete.id); const deleteUndoData = { encounterPath, updates: { participants: [...participants], ...(encounter.isStarted ? { currentTurnParticipantId: encounter.currentTurnParticipantId, turnOrderIds: [...(encounter.turnOrderIds || [])], } : {}), }, }; try { await storage.updateDoc(encounterPath, { participants: updatedParticipants, ...computeTurnOrderAfterRemoval(encounter, itemToDelete.id, updatedParticipants) }); logAction(`${itemToDelete.name} removed from encounter`, { encounterName: encounter.name }, deleteUndoData); } catch (err) { console.error("Error deleting participant:", err); alert("Failed to delete participant. Please try again."); } setShowDeleteConfirm(false); setItemToDelete(null); }; const toggleParticipantActive = async (participantId) => { if (!db) return; const participant = participants.find(p => p.id === participantId); if (!participant) return; const newIsActive = !participant.isActive; const updatedParticipants = participants.map(p => p.id === participantId ? { ...p, isActive: newIsActive } : p ); const turnUpdates = newIsActive ? computeTurnOrderAfterAddition(encounter, participantId) : computeTurnOrderAfterRemoval(encounter, participantId, updatedParticipants); try { await storage.updateDoc(encounterPath, { participants: updatedParticipants, ...turnUpdates }); logAction(`${participant.name} ${newIsActive ? 'reactivated' : 'deactivated'}`, { encounterName: encounter.name }, { encounterPath, updates: { participants: [...participants], ...(encounter.isStarted ? { currentTurnParticipantId: encounter.currentTurnParticipantId, turnOrderIds: [...(encounter.turnOrderIds || [])], } : {}), }, }); } catch (err) { console.error("Error toggling active state:", err); } }; const applyHpChange = async (participantId, changeType) => { if (!db) return; const amountStr = hpChangeValues[participantId]; if (!amountStr || amountStr.trim() === '') return; const amount = parseInt(amountStr, 10); if (isNaN(amount) || amount === 0) { setHpChangeValues(prev => ({ ...prev, [participantId]: '' })); 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); } // Determine if participant died or was resurrected const wasDead = participant.currentHp === 0; const isDead = newHp === 0; const wasResurrected = wasDead && newHp > 0; const updatedParticipants = participants.map(p => { if (p.id === participantId) { const updates = { ...p, currentHp: newHp }; // Handle death - deactivate and start death saves if (isDead && !wasDead) { updates.isActive = false; updates.deathSaves = p.deathSaves || 0; updates.isDying = false; } // Handle resurrection - reactivate and reset death saves if (wasResurrected) { updates.isActive = true; updates.deathSaves = 0; updates.isDying = false; } return updates; } return p; }); const turnUpdates = (isDead && !wasDead) ? computeTurnOrderAfterRemoval(encounter, participantId, updatedParticipants) : wasResurrected ? computeTurnOrderAfterAddition(encounter, participantId) : {}; const hpUndoData = { encounterPath, updates: { participants: [...participants], ...((isDead && !wasDead) || wasResurrected ? { currentTurnParticipantId: encounter.currentTurnParticipantId, turnOrderIds: [...(encounter.turnOrderIds || [])], } : {}), }, }; try { await storage.updateDoc(encounterPath, { participants: updatedParticipants, ...turnUpdates }); setHpChangeValues(prev => ({ ...prev, [participantId]: '' })); const hpLine = `${participant.currentHp} β†’ ${newHp} HP`; const deathSuffix = (isDead && !wasDead) ? (participant.type === 'character' ? ' β€” Unconscious' : ' β€” Defeated') : ''; const resurSuffix = wasResurrected ? ' β€” Revived' : ''; if (changeType === 'damage') { logAction(`${participant.name} took ${amount} damage (${hpLine})${deathSuffix}`, { encounterName: encounter.name }, hpUndoData); } else { logAction(`${participant.name} healed for ${amount} (${hpLine})${resurSuffix}`, { encounterName: encounter.name }, hpUndoData); } } catch (err) { console.error("Error applying HP change:", err); } }; const handleDeathSaveChange = async (participantId, saveNumber) => { if (!db) return; const participant = participants.find(p => p.id === participantId); if (!participant) return; const currentSaves = participant.deathSaves || 0; const newSaves = currentSaves === saveNumber ? saveNumber - 1 : saveNumber; // If clicking the third death save, mark as dying (for player view animation) if (newSaves === 3) { const updatedParticipants = participants.map(p => p.id === participantId ? { ...p, deathSaves: newSaves, isDying: true } : p ); try { await storage.updateDoc(encounterPath, { participants: updatedParticipants }); // Wait for animation to complete on player display (2 seconds) then remove participant setTimeout(async () => { const finalParticipants = participants.filter(p => p.id !== participantId); try { await storage.updateDoc(encounterPath, { participants: finalParticipants }); } catch (err) { console.error("Error removing dead participant:", err); } }, 2000); } catch (err) { console.error("Error marking participant as dying:", err); } } else { // Normal death save update const updatedParticipants = participants.map(p => p.id === participantId ? { ...p, deathSaves: newSaves } : p ); try { await storage.updateDoc(encounterPath, { participants: updatedParticipants }); } catch (err) { console.error("Error updating death saves:", err); } } }; const toggleCondition = async (participantId, conditionId) => { if (!db) return; const participant = participants.find(p => p.id === participantId); if (!participant) return; const wasActive = (participant.conditions || []).includes(conditionId); const updatedParticipants = participants.map(p => { if (p.id !== participantId) return p; const current = p.conditions || []; const next = wasActive ? current.filter(c => c !== conditionId) : [...current, conditionId]; return { ...p, conditions: next }; }); try { await storage.updateDoc(encounterPath, { participants: updatedParticipants }); const cond = CONDITIONS.find(c => c.id === conditionId); const condLabel = cond ? `${cond.label} ${cond.emoji}` : conditionId; logAction(`${participant.name} ${wasActive ? 'lost' : 'gained'} ${condLabel}`, { encounterName: encounter.name }, { encounterPath, updates: { participants: [...participants] }, }); } catch (err) { console.error("Error updating conditions:", 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 draggedIndex = currentParticipants.findIndex(p => p.id === draggedItemId); const targetIndex = currentParticipants.findIndex(p => p.id === targetId); if (draggedIndex === -1 || targetIndex === -1) { console.error("Dragged or target item not found."); setDraggedItemId(null); return; } const draggedItem = currentParticipants[draggedIndex]; const targetItem = currentParticipants[targetIndex]; if (draggedItem.initiative !== targetItem.initiative) { console.log("Drag-drop only allowed for participants with same initiative."); setDraggedItemId(null); return; } const [removedItem] = currentParticipants.splice(draggedIndex, 1); currentParticipants.splice(targetIndex, 0, removedItem); try { await storage.updateDoc(encounterPath, { participants: currentParticipants }); } catch (err) { console.error("Error reordering participants:", err); } setDraggedItemId(null); }; const sortedParticipants = sortParticipantsByInitiative(participants, participants); 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

{/* Warning when combat is active */} {encounter.isStarted && !encounter.isPaused && (

Combat is Running

Pause combat to add or remove participants. The turn order will be recalculated when combat is resumed.

)}
{ e.preventDefault(); handleAddParticipant(); }} className="grid grid-cols-1 md:grid-cols-6 gap-x-4 gap-y-2 mb-4 p-3 bg-stone-800 rounded items-end" >
{participantType === 'monster' ? ( <>
setParticipantName(e.target.value)} placeholder="e.g., Dire Wolf" className="mt-1 w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white" />
setMonsterInitMod(e.target.value)} className="mt-1 w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white" />
setMaxHp(e.target.value)} className="mt-1 w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white" />
setIsNpc(e.target.checked)} className="h-4 w-4 text-violet-600 border-stone-400 rounded focus:ring-violet-500" />
) : ( <>
setMaxHp(e.target.value)} className="mt-1 w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white" />
)}
{lastRollDetails && (

{lastRollDetails.name} ({lastRollDetails.type === 'character' ? 'Character' : lastRollDetails.type === 'monster' ? 'Monster' : lastRollDetails.type}) : Rolled d20 ({lastRollDetails.roll}) {formatInitMod(lastRollDetails.mod)} = {lastRollDetails.total} Initiative

)} {participants.length === 0 &&

No participants added yet.

} {editingParticipant && ( setEditingParticipant(null)} onSave={handleUpdateParticipant} /> )}
setShowDeleteConfirm(false)} onConfirm={confirmDeleteParticipant} title="Delete Participant?" message={`Are you sure you want to remove "${itemToDelete?.name}" from this encounter?`} /> ); } // ============================================================================ // INITIATIVE CONTROLS COMPONENT // ============================================================================ function InitiativeControls({ campaignId, encounter, encounterPath }) { const [showEndConfirm, setShowEndConfirm] = useState(false); const { data: activeDisplayData } = useFirestoreDocument(getPath.activeDisplay()); const hidePlayerHp = activeDisplayData?.hidePlayerHp ?? true; const handleToggleHidePlayerHp = async () => { if (!db) return; try { await storage.setDoc(getPath.activeDisplay(), { hidePlayerHp: !hidePlayerHp }, { merge: true }); } catch (err) { console.error("Error toggling hidePlayerHp:", err); } }; const handleStartEncounter = async () => { if (!db || !encounter.participants || encounter.participants.length === 0) { alert("Add participants first."); return; } const activeParticipants = encounter.participants.filter(p => p.isActive); if (activeParticipants.length === 0) { alert("No active participants."); return; } const sortedParticipants = sortParticipantsByInitiative(activeParticipants, encounter.participants); try { await storage.updateDoc(encounterPath, { isStarted: true, isPaused: false, round: 1, currentTurnParticipantId: sortedParticipants[0].id, turnOrderIds: sortedParticipants.map(p => p.id) }); await storage.setDoc(getPath.activeDisplay(), { activeCampaignId: campaignId, activeEncounterId: encounter.id }, { merge: true }); logAction(`Combat started: "${encounter.name}" β€” ${sortedParticipants[0].name}'s turn (Round 1)`, { encounterName: encounter.name }, { encounterPath, updates: { isStarted: encounter.isStarted ?? false, isPaused: encounter.isPaused ?? false, round: encounter.round ?? 0, currentTurnParticipantId: encounter.currentTurnParticipantId ?? null, turnOrderIds: [...(encounter.turnOrderIds || [])], }, }); console.log("Encounter started and set as active display."); } catch (err) { console.error("Error starting encounter:", err); alert("Failed to start encounter. Please try again."); } }; 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 sortedParticipants = sortParticipantsByInitiative(activeParticipants, encounter.participants); newTurnOrderIds = sortedParticipants.map(p => p.id); } try { await storage.updateDoc(encounterPath, { isPaused: newPausedState, turnOrderIds: newTurnOrderIds }); logAction(`Combat ${newPausedState ? 'paused' : 'resumed'}: "${encounter.name}"`, { encounterName: encounter.name }, { encounterPath, updates: { isPaused: encounter.isPaused ?? false, turnOrderIds: [...(encounter.turnOrderIds || [])], }, }); } catch (err) { console.error("Error toggling pause state:", err); } }; const handleNextTurn = async () => { if (!db || !encounter.isStarted || encounter.isPaused) return; if (!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 storage.updateDoc(encounterPath, { isStarted: false, isPaused: false, currentTurnParticipantId: null, round: encounter.round }); return; } let currentIndex = activePsInOrder.findIndex(p => p.id === encounter.currentTurnParticipantId); let nextRound = encounter.round; // Current participant was removed; find the next one after their old position in turnOrderIds if (currentIndex === -1) { const rawPos = (encounter.turnOrderIds || []).indexOf(encounter.currentTurnParticipantId); const candidateIds = [...(encounter.turnOrderIds || []).slice(rawPos + 1), ...(encounter.turnOrderIds || []).slice(0, rawPos)]; const nextP = candidateIds.map(id => activePsInOrder.find(p => p.id === id)).find(Boolean); currentIndex = nextP ? activePsInOrder.findIndex(p => p.id === nextP.id) - 1 : -1; } let nextIndex = (currentIndex + 1) % activePsInOrder.length; let newTurnOrderIds = encounter.turnOrderIds; if (nextIndex === 0 && currentIndex !== -1) { nextRound += 1; // Rebuild turn order by initiative at the start of each new round so that participants // activated mid-round (appended to the end) slot into proper initiative position next round. const activePs = encounter.participants.filter(p => p.isActive); const sorted = sortParticipantsByInitiative(activePs, encounter.participants); newTurnOrderIds = sorted.map(p => p.id); } // When wrapping to a new round the next participant is first in the rebuilt order const nextParticipant = (nextIndex === 0 && currentIndex !== -1) ? encounter.participants.find(p => p.id === newTurnOrderIds[0]) : activePsInOrder[nextIndex]; if (!nextParticipant) return; try { await storage.updateDoc(encounterPath, { currentTurnParticipantId: nextParticipant.id, round: nextRound, turnOrderIds: newTurnOrderIds, }); logAction(`${nextParticipant.name}'s turn (Round ${nextRound})`, { encounterName: encounter.name }, { encounterPath, updates: { currentTurnParticipantId: encounter.currentTurnParticipantId, round: encounter.round, turnOrderIds: [...encounter.turnOrderIds], }, }); } catch (err) { console.error("Error advancing turn:", err); } }; const confirmEndEncounter = async () => { if (!db) return; try { await storage.updateDoc(encounterPath, { isStarted: false, isPaused: false, currentTurnParticipantId: null, round: 0, turnOrderIds: [] }); await storage.setDoc(getPath.activeDisplay(), { activeCampaignId: null, activeEncounterId: null }, { merge: true }); logAction(`Combat ended: "${encounter.name}"`, { encounterName: encounter.name }, { encounterPath, updates: { isStarted: encounter.isStarted ?? false, isPaused: encounter.isPaused ?? false, round: encounter.round ?? 0, currentTurnParticipantId: encounter.currentTurnParticipantId ?? null, turnOrderIds: [...(encounter.turnOrderIds || [])], }, }); console.log("Encounter ended and deactivated from Player Display."); } catch (err) { console.error("Error ending encounter:", err); } setShowEndConfirm(false); }; if (!encounter || !encounter.participants) return null; return ( <>

Combat Controls

{!encounter.isStarted ? ( ) : ( <> {/* Round Counter */}

Round: {encounter.round}

{encounter.isPaused && (

(Paused)

)}
)}
{/* Display Settings */}
Player Display
setShowEndConfirm(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." /> ); } // ============================================================================ // ENCOUNTER MANAGER COMPONENT // ============================================================================ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharacters }) { const { data: encountersData, isLoading: isLoadingEncounters } = useFirestoreCollection( campaignId ? getPath.encounters(campaignId) : null ); const { data: activeDisplayInfo } = useFirestoreDocument(getPath.activeDisplay()); const [encounters, setEncounters] = useState([]); const [selectedEncounterId, setSelectedEncounterId] = useState(null); const [showCreateModal, setShowCreateModal] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = 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 ( activeDisplayInfo && activeDisplayInfo.activeCampaignId === campaignId && encounters.some(e => e.id === activeDisplayInfo.activeEncounterId) ) { setSelectedEncounterId(activeDisplayInfo.activeEncounterId); } } } else if (encounters && encounters.length === 0) { setSelectedEncounterId(null); } }, [campaignId, initialActiveEncounterId, activeDisplayInfo, encounters]); const handleCreateEncounter = async (name) => { if (!db || !name.trim() || !campaignId) return; const newEncounterId = generateId(); try { await storage.setDoc(`${getPath.encounters(campaignId)}/${newEncounterId}`, { name: name.trim(), createdAt: new Date().toISOString(), participants: [], round: 0, currentTurnParticipantId: null, isStarted: false, isPaused: false }); setShowCreateModal(false); setSelectedEncounterId(newEncounterId); } catch (err) { console.error("Error creating encounter:", err); alert("Failed to create encounter. Please try again."); } }; const requestDeleteEncounter = (encounterId, encounterName) => { setItemToDelete({ id: encounterId, name: encounterName }); setShowDeleteConfirm(true); }; const confirmDeleteEncounter = async () => { if (!db || !itemToDelete) return; const encounterId = itemToDelete.id; try { await storage.deleteDoc(getPath.encounter(campaignId, encounterId)); if (selectedEncounterId === encounterId) { setSelectedEncounterId(null); } if (activeDisplayInfo && activeDisplayInfo.activeEncounterId === encounterId) { await storage.updateDoc(getPath.activeDisplay(), { activeCampaignId: null, activeEncounterId: null }); } } catch (err) { console.error("Error deleting encounter:", err); alert("Failed to delete encounter. Please try again."); } setShowDeleteConfirm(false); setItemToDelete(null); }; const handleTogglePlayerDisplay = async (encounterId) => { if (!db) return; try { const currentActiveCampaign = activeDisplayInfo?.activeCampaignId; const currentActiveEncounter = activeDisplayInfo?.activeEncounterId; if (currentActiveCampaign === campaignId && currentActiveEncounter === encounterId) { await storage.setDoc(getPath.activeDisplay(), { activeCampaignId: null, activeEncounterId: null, }, { merge: true }); } else { await storage.setDoc(getPath.activeDisplay(), { activeCampaignId: campaignId, activeEncounterId: encounterId, }, { merge: true }); } } catch (err) { console.error("Error toggling Player Display:", 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 isLive = activeDisplayInfo && activeDisplayInfo.activeCampaignId === campaignId && activeDisplayInfo.activeEncounterId === encounter.id; return (
setSelectedEncounterId(encounter.id)} className="cursor-pointer flex-grow">

{encounter.name}

Participants: {encounter.participants?.length || 0}

{isLive && ( LIVE ON PLAYER DISPLAY )}
); })}
{showCreateModal && ( setShowCreateModal(false)} title="Create New Encounter"> setShowCreateModal(false)} /> )} {selectedEncounter && (

Managing Encounter: {selectedEncounter.name}

{/* Combat Controls - Left Side (Sticky on large screens) */}
{/* Participant Manager - Right Side */}
)}
setShowDeleteConfirm(false)} onConfirm={confirmDeleteEncounter} title="Delete Encounter?" message={`Are you sure you want to delete the encounter "${itemToDelete?.name}"? This action cannot be undone.`} /> ); } // ============================================================================ // ADMIN VIEW COMPONENT // ============================================================================ function AdminView({ userId }) { const { data: campaignsData, isLoading: isLoadingCampaigns, error: campaignsError } = useFirestoreCollection( getPath.campaigns() ); const { data: initialActiveInfo } = useFirestoreDocument(getPath.activeDisplay()); const [campaignsWithDetails, setCampaignsWithDetails] = useState([]); const [selectedCampaignId, setSelectedCampaignId] = useState(null); const [showCreateModal, setShowCreateModal] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [itemToDelete, setItemToDelete] = useState(null); useEffect(() => { if (campaignsData && db) { const fetchDetails = async () => { const detailedCampaigns = await Promise.all( campaignsData.map(async (campaign) => { const characters = campaign.players || []; let encounterCount = 0; try { const encounters = await storage.getCollection(getPath.encounters(campaign.id)); encounterCount = encounters.length; } catch (err) { console.error(`Failed to fetch encounters for campaign ${campaign.id}:`, err); } return { ...campaign, characters, encounterCount }; }) ); setCampaignsWithDetails(detailedCampaigns); }; fetchDetails(); } else if (campaignsData) { setCampaignsWithDetails( campaignsData.map(c => ({ ...c, characters: c.players || [], encounterCount: 0 })) ); } }, [campaignsData]); useEffect(() => { if ( initialActiveInfo && initialActiveInfo.activeCampaignId && campaignsWithDetails.length > 0 && !selectedCampaignId ) { const campaignExists = campaignsWithDetails.some(c => c.id === initialActiveInfo.activeCampaignId); if (campaignExists) { setSelectedCampaignId(initialActiveInfo.activeCampaignId); } } }, [initialActiveInfo, campaignsWithDetails, selectedCampaignId]); const handleCreateCampaign = async (name, backgroundUrl) => { if (!db || !name.trim()) return; const newCampaignId = generateId(); try { await storage.setDoc(getPath.campaign(newCampaignId), { name: name.trim(), playerDisplayBackgroundUrl: backgroundUrl.trim() || '', ownerId: userId, createdAt: new Date().toISOString(), players: [], }); setShowCreateModal(false); setSelectedCampaignId(newCampaignId); } catch (err) { console.error("Error creating campaign:", err); alert("Failed to create campaign. Please try again."); } }; const requestDeleteCampaign = (campaignId, campaignName) => { setItemToDelete({ id: campaignId, name: campaignName }); setShowDeleteConfirm(true); }; const confirmDeleteCampaign = async () => { if (!db || !itemToDelete) return; const campaignId = itemToDelete.id; try { const encountersPath = getPath.encounters(campaignId); const encounters = await storage.getCollection(encountersPath); const deleteOps = encounters.map(e => { const id = e.id || e.path?.split('/').pop(); return { type: 'delete', path: `${encountersPath}/${id}` }; }); if (deleteOps.length > 0) await storage.batchWrite(deleteOps); await storage.deleteDoc(getPath.campaign(campaignId)); if (selectedCampaignId === campaignId) { setSelectedCampaignId(null); } const activeDisplay = await storage.getDoc(getPath.activeDisplay()); if (activeDisplay && activeDisplay.activeCampaignId === campaignId) { await storage.updateDoc(getPath.activeDisplay(), { activeCampaignId: null, activeEncounterId: null }); } } catch (err) { console.error("Error deleting campaign:", err); alert("Failed to delete campaign. Please try again."); } setShowDeleteConfirm(false); setItemToDelete(null); }; const selectedCampaign = campaignsWithDetails.find(c => c.id === selectedCampaignId); if (isLoadingCampaigns) { return

Loading campaigns...

; } if (campaignsError) { return (

Error loading campaigns: {campaignsError.message || String(campaignsError)}

); } return ( <>

Campaigns

{campaignsWithDetails.length === 0 && !isLoadingCampaigns && (

No campaigns yet. Create one to get started!

)}
{campaignsWithDetails.map(campaign => { const cardStyle = campaign.playerDisplayBackgroundUrl ? { backgroundImage: `url(${campaign.playerDisplayBackgroundUrl})` } : {}; const cardClasses = `h-40 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-amber-500' : ''} ${!campaign.playerDisplayBackgroundUrl ? 'bg-stone-800 hover:bg-stone-700' : 'hover:shadow-xl'}`; return (
setSelectedCampaignId(campaign.id)} className={cardClasses} style={cardStyle} >

{campaign.name}

{campaign.characters?.length || 0} Characters {campaign.encounterCount === undefined ? '...' : campaign.encounterCount} Encounters
); })}
{showCreateModal && ( setShowCreateModal(false)} title="Create New Campaign"> setShowCreateModal(false)} /> )} {selectedCampaign && (

Managing: {selectedCampaign.name}


)}
setShowDeleteConfirm(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.`} /> ); } // ============================================================================ // DISPLAY VIEW COMPONENT (Player View) // ============================================================================ function DisplayView() { const { data: activeDisplayData, isLoading: isLoadingActiveDisplay, error: activeDisplayError } = useFirestoreDocument( getPath.activeDisplay() ); const [activeEncounterData, setActiveEncounterData] = useState(null); const [isLoadingEncounter, setIsLoadingEncounter] = useState(true); const [encounterError, setEncounterError] = useState(null); const [campaignBackgroundUrl, setCampaignBackgroundUrl] = useState(''); const [isPlayerDisplayActive, setIsPlayerDisplayActive] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); const [wakeLockEnabled, setWakeLockEnabled] = useState(false); const wakeLockRef = useRef(null); const currentParticipantRef = useRef(null); useEffect(() => { const onFsChange = () => setIsFullscreen(!!document.fullscreenElement); document.addEventListener('fullscreenchange', onFsChange); return () => document.removeEventListener('fullscreenchange', onFsChange); }, []); const toggleFullscreen = () => { if (!document.fullscreenElement) { document.documentElement.requestFullscreen(); } else { document.exitFullscreen(); } }; useEffect(() => { if (!wakeLockEnabled) { wakeLockRef.current?.release(); wakeLockRef.current = null; return; } const acquire = async () => { try { wakeLockRef.current = await navigator.wakeLock.request('screen'); } catch (e) { console.error('Wake lock failed:', e); } }; acquire(); // Re-acquire after tab becomes visible again (browser auto-releases on hide) const onVisChange = () => { if (document.visibilityState === 'visible') acquire(); }; document.addEventListener('visibilitychange', onVisChange); return () => { document.removeEventListener('visibilitychange', onVisChange); wakeLockRef.current?.release(); wakeLockRef.current = null; }; }, [wakeLockEnabled]); 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, getPath.campaign(activeCampaignId)); unsubscribeCampaign = onSnapshot( campaignDocRef, (campSnap) => { if (campSnap.exists()) { setCampaignBackgroundUrl(campSnap.data().playerDisplayBackgroundUrl || ''); } else { setCampaignBackgroundUrl(''); } }, (err) => console.error("Error fetching campaign background:", err) ); const encounterPath = getPath.encounter(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]); // Auto-scroll current participant into view useEffect(() => { if (currentParticipantRef.current && activeEncounterData?.isStarted && !activeEncounterData?.isPaused) { currentParticipantRef.current.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' }); } }, [activeEncounterData?.currentTurnParticipantId, activeEncounterData?.isStarted, activeEncounterData?.isPaused]); 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; const hidePlayerHp = activeDisplayData?.hidePlayerHp ?? true; let participantsToRender = []; if (participants) { // Hide inactive monsters (pre-staged/summoned reserves) from the player view const visibleParticipants = participants.filter(p => p.isActive || p.type !== 'monster'); participantsToRender = sortParticipantsByInitiative(visibleParticipants, visibleParticipants); } 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 => { const isDead = p.currentHp === 0; const isDying = p.isDying || false; let participantBgColor = p.type === 'monster' ? (p.isNpc ? 'bg-stone-800' : 'bg-[#8e351c]') : 'bg-indigo-950'; const isCurrentTurn = p.id === currentTurnParticipantId && isStarted && !isPaused; if (isCurrentTurn) { participantBgColor = 'bg-green-700 ring-4 ring-green-400 scale-105'; } else if (isPaused && p.id === currentTurnParticipantId) { participantBgColor += ' ring-2 ring-yellow-400'; } return (

{isDead && ☠️} {p.name} {isCurrentTurn && ( (Current) )} {isDead && {p.type === 'character' ? '(Unconscious)' : '(Dead)'}}

Init: {p.initiative}
{!(hidePlayerHp && p.type === 'character') && (
{p.type !== 'monster' && ( HP: {p.currentHp} / {p.maxHp} )}
)} {p.conditions?.length > 0 && (
{p.conditions.map(cId => { const cond = CONDITIONS.find(c => c.id === cId); return cond ? ( {cond.emoji} {cond.label} ) : null; })}
)} {!p.isActive && !isDead && (

(Inactive)

)}
); })}
); } // ============================================================================ // LOGS VIEW COMPONENT // ============================================================================ function LogsView() { const { data: logs, isLoading } = useFirestoreCollection(getPath.logs(), LOG_QUERY); const [showClearConfirm, setShowClearConfirm] = useState(false); const [undoingId, setUndoingId] = useState(null); const handleClearLogs = async () => { try { const logs = await storage.getCollection(getPath.logs()); if (logs.length > 0) { const ops = logs.map(l => { const id = l.id || l.path?.split('/').pop(); return { type: 'delete', path: `${getPath.logs()}/${id}` }; }); await storage.batchWrite(ops); } } catch (err) { console.error('Error clearing logs:', err); } setShowClearConfirm(false); }; const handleUndo = async (entry) => { if (!db || !entry.undo) return; setUndoingId(entry.id); try { await storage.updateDoc(entry.undo.encounterPath, entry.undo.updates); await storage.updateDoc(`${getPath.logs()}/${entry.id}`, { undone: true }); } catch (err) { console.error('Error undoing action:', err); alert('Failed to roll back. The encounter may have changed or no longer exists.'); } setUndoingId(null); }; return (

Combat Log

← Back to Tracker
{isLoading ? ( ) : logs.length === 0 ? (

No log entries yet.

) : ( <>

{logs.length} entries β€” newest first

{logs.map(entry => (
{new Date(entry.timestamp).toLocaleString()} {entry.encounterName && ( [{entry.encounterName}] )} {entry.message} {entry.undone ? ( rolled back ) : entry.undo ? ( ) : null}
))}
)}
setShowClearConfirm(false)} onConfirm={handleClearLogs} title="Clear All Logs?" message="This will permanently delete all log entries and cannot be undone." />
); } // ============================================================================ // 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); const [isLogsMode, setIsLogsMode] = useState(false); useEffect(() => { const queryParams = new URLSearchParams(window.location.search); if (queryParams.get('playerView') === 'true' || window.location.pathname === '/display') { setIsPlayerViewOnlyMode(true); } if (window.location.pathname === '/logs') { setIsLogsMode(true); } if (!auth) { setError("Auth not initialized."); setIsLoading(false); setIsAuthReady(false); return; } // ws/memory mode: stub auth, no SDK sign-in. Unblock UI immediately. if (STORAGE_MODE !== 'firebase') { setUserId(auth.currentUser?.uid || 'local-user'); setIsAuthReady(true); setIsLoading(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."); // Auth failed and onAuthStateChanged won't fire with a user, so unblock the UI here setIsAuthReady(true); setIsLoading(false); } }; const unsubscribe = onAuthStateChanged(auth, (user) => { setUserId(user ? user.uid : null); // Only mark auth ready once we have an actual authenticated user. // onAuthStateChanged fires with null before signInAnonymously completes, // which would cause Firestore queries to run unauthenticated. if (user) { setIsAuthReady(true); setIsLoading(false); } }); initAuth(); return () => unsubscribe(); }, []); if (!isInitialized || !auth) { return ( ); } if (isLoading || !isAuthReady) { return ; } if (error) { return ; } const openPlayerWindow = () => { const playerViewUrl = window.location.origin + '/display'; window.open(playerViewUrl, '_blank', 'noopener,noreferrer,width=1024,height=768'); }; if (isPlayerViewOnlyMode) { return (
{isAuthReady && } {!isAuthReady && !error &&

Authenticating for Player Display...

}
); } if (isLogsMode) { return isAuthReady ? : ; } return (

TTRPG Initiative Tracker

View Logs
{isAuthReady && userId && } {!isAuthReady && !error &&

Authenticating...

}
); } export default App;