import React, { useState, useEffect, useRef, useMemo, createContext, useContext, useCallback } from 'react'; import * as shared from '@ttrpg/shared'; import { initializeApp, getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken, getFirestore, where, orderBy, limit, offset, 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, Clock, ChevronRight, X, Undo2, Redo2, Crosshair } from 'lucide-react'; // ----- UI feedback: toast (transient) + info modal (persistent) ----- const UIFeedbackContext = createContext(null); const useUIFeedback = () => useContext(UIFeedbackContext); function ToastStack({ toasts, onDismiss }) { if (!toasts.length) return null; return (
{toasts.map(t => (
{t.message}
))}
); } function InfoModal({ message, onClose }) { if (!message) return null; return (

Notice

{message}

); } function UIFeedbackProvider({ children }) { const [toasts, setToasts] = useState([]); const [info, setInfo] = useState(null); const nextId = useRef(1); const showToast = useCallback((message) => { const id = nextId.current++; setToasts(prev => [...prev, { id, message }]); setTimeout(() => setToasts(prev => prev.filter(t => t.id !== id)), 6000); }, []); const showInfo = useCallback((message) => { setInfo(message); }, []); const dismissToast = useCallback((id) => { setToasts(prev => prev.filter(t => t.id !== id)); }, []); return ( {children} setInfo(null)} /> ); } // 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.4'; const { DEFAULT_MAX_HP, DEFAULT_INIT_MOD, MONSTER_DEFAULT_INIT_MOD, generateId, rollD20, formatInitMod, startEncounter, nextTurn, togglePause, endEncounter, addParticipant, addParticipants, updateParticipant, removeParticipant, toggleParticipantActive: combatToggleActive, applyHpChange: combatApplyHpChange, deathSave: combatDeathSave, stabilizeParticipant, reviveParticipant, toggleCondition: combatToggleCondition, reorderParticipants, } = shared; 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. // db stays a truthy sentinel object so legacy `if (!db) return` guards pass; // all real reads/writes route through `storage.*`, never the SDK `db`. const FAKE_USER = { uid: 'local-user', isAnonymous: true }; auth = { currentUser: FAKE_USER, }; db = { __localStub: true }; 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 // ============================================================================ // generateId, rollD20, formatInitMod, sortParticipantsByInitiative, // computeTurnOrderAfterRemoval/Addition: imported from @ttrpg/shared (1-list model). // Display limit: recent logs only. Download/copy fetch full set on demand. // Keep small — 500 = ~900KB per snapshot = slow load. const LOG_PAGE_SIZE = 100; const LOG_ORDER = orderBy('ts', 'desc'); // Combat actions now live in shared/turn.js and WRITE THEIR OWN LOGS. // Every mutating func is async, takes ctx {storage, encPath, logPath}, // persists encounter patch + log entry itself. No separate logEvent call. // // buildCtx: standard context for combat action calls. Lazy getStorage() // so handlers work even if module-level `storage` assigned late (test/auth). const buildCtx = (encounterPath) => ({ storage: getStorage(), encPath: encounterPath, logPath: getPath.logs(), }); // Read undo payload: new lean `undo` (id-based) or legacy (`undo_payload`/`undo`). // Lean events need expandUndo() at click time (needs current enc doc). // expandUndo lives in shared/turn.js (re-exported via @ttrpg/shared). const { expandUndo } = shared; const getUndo = (entry) => entry.undo_payload || entry.undo || null; // ============================================================================ // 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); }, (err) => { console.error(`Error fetching document ${docPath}:`, err); setError(err.message || "Failed to fetch document."); setIsLoading(false); setData(null); }); 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); }, queryConstraints, (err) => { console.error(`[useFirestoreCollection] ERR ${collectionPath}:`, err); setError(err.message || "Failed to fetch collection."); setIsLoading(false); setData([]); }); 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 }) { const [pending, setPending] = useState(false); const [error, setError] = useState(null); // Reset transient state each time modal opens. useEffect(() => { if (isOpen) { setPending(false); setError(null); } }, [isOpen]); if (!isOpen) return null; const handleConfirm = async () => { if (pending) return; // double-click guard setPending(true); setError(null); try { await onConfirm(); onClose(); } catch (err) { console.error('ConfirmationModal action failed:', err); setError(err?.message || 'Action failed. Try again.'); setPending(false); } }; const close = () => { if (pending) return; // block close mid-action onClose(); }; return (

{title || "Confirm Action"}

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

{error && (

{error}

)}
); } 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 [asNpc, setAsNpc] = useState(participant.type === 'npc'); const handleSubmit = (e) => { e.preventDefault(); onSave({ name: name.trim(), initiative: parseInt(initiative, 10), currentHp: parseInt(currentHp, 10), maxHp: parseInt(maxHp, 10), type: participant.type === 'monster' || participant.type === 'npc' ? (asNpc ? 'npc' : 'monster') : participant.type, }); }; 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' || participant.type === 'npc') && (
setAsNpc(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 { showToast, showInfo } = useUIFeedback(); 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) { showInfo("Please enter a valid positive number for Default Max HP."); return; } if (isNaN(initMod)) { showInfo("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); showToast("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) { showInfo("Please enter a valid positive number for Default Max HP."); setEditingCharacter(null); return; } if (isNaN(initMod)) { showInfo("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); showToast("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); showToast("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, campaignId }) { const { showToast, showInfo } = useUIFeedback(); 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 [manualInitiative, setManualInitiative] = useState(''); const [asNpc, setAsNpc] = 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 [customConditionInput, setCustomConditionInput] = useState(''); // Per-campaign custom conditions (DM-added freeform strings). const { data: campaignDoc } = useFirestoreDocument(campaignId ? getPath.campaign(campaignId) : null); const customConditions = (campaignDoc && campaignDoc.customConditions) || []; // Merged condition list: built-ins + custom. Custom = { id: label, label, emoji: '' }. // id may contain spaces/emoji — toggleCondition stores raw string. const allConditions = [ ...CONDITIONS, ...customConditions.map(label => ({ id: label, label, emoji: '' })), ]; 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); } setAsNpc(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 participantAsNpc = false; const manualInit = manualInitiative !== '' && !isNaN(parseInt(manualInitiative, 10)); const finalInitiative = manualInit ? parseInt(manualInitiative, 10) : null; if (participantType === 'character') { const character = campaignCharacters.find(c => c.id === selectedCharacterId); if (!character) { return; } if (participants.some(p => p.type === 'character' && p.originalCharacterId === selectedCharacterId)) { showInfo(`${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; participantAsNpc = asNpc; } const computedInitiative = manualInit ? finalInitiative : (initiativeRoll + modifier); const newParticipant = { id: generateId(), name: nameToAdd, type: participantType === 'monster' && participantAsNpc ? 'npc' : participantType, originalCharacterId: participantType === 'character' ? selectedCharacterId : null, initiative: computedInitiative, maxHp: currentMaxHp, currentHp: currentMaxHp, conditions: [], isActive: true, status: 'conscious', deathSaveSuccesses: 0, deathSaveFailures: 0, }; try { await addParticipant(encounter, newParticipant, buildCtx(encounterPath)); setLastRollDetails({ name: nameToAdd, roll: manualInit ? null : initiativeRoll, mod: manualInit ? null : modifier, total: computedInitiative, type: participantAsNpc ? 'NPC' : participantType, manual: manualInit, }); setTimeout(() => setLastRollDetails(null), ROLL_DISPLAY_DURATION); setParticipantName(''); setMaxHp(DEFAULT_MAX_HP); setSelectedCharacterId(''); setMonsterInitMod(MONSTER_DEFAULT_INIT_MOD); setAsNpc(false); setManualInitiative(''); } catch (err) { showToast("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, status: 'conscious', deathSaveSuccesses: 0, deathSaveFailures: 0, }; }); if (newParticipants.length === 0) { showInfo("All campaign characters are already in this encounter."); return; } try { await addParticipants(encounter, newParticipants, buildCtx(encounterPath)); } catch (err) { showToast("Failed to add all characters. Please try again."); } }; const handleUpdateParticipant = async (updatedData) => { if (!db || !editingParticipant) return; try { await updateParticipant(encounter, editingParticipant.id, updatedData, buildCtx(encounterPath)); setEditingParticipant(null); } catch (err) { showToast("Failed to update participant. Please try again."); } }; // Inline initiative edit (FEAT-3): blur/Enter commits. Reslots participant // into correct list position (stable sort by init desc, tie-break original // index). Display + AdminView both reflect new order. Pre-combat only — // field gated to !started||paused elsewhere. const handleInlineInitiative = async (participantId, value) => { if (!db) return; const n = parseInt(value, 10); if (isNaN(n)) return; try { await updateParticipant(encounter, participantId, { initiative: n }, buildCtx(encounterPath)); } catch (err) { // fall through silently } }; const requestDeleteParticipant = (participantId, participantName) => { setItemToDelete({ id: participantId, name: participantName }); setShowDeleteConfirm(true); }; const confirmDeleteParticipant = async () => { if (!db || !itemToDelete) return; try { await removeParticipant(encounter, itemToDelete.id, buildCtx(encounterPath)); } catch (err) { showToast("Failed to delete participant. Please try again."); } setShowDeleteConfirm(false); setItemToDelete(null); }; const toggleParticipantActive = async (participantId) => { if (!db) return; try { await combatToggleActive(encounter, participantId, buildCtx(encounterPath)); } catch (err) { // fall through silently } }; 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; } try { await combatApplyHpChange(encounter, participantId, changeType, amount, buildCtx(encounterPath)); setHpChangeValues(prev => ({ ...prev, [participantId]: '' })); } catch (err) { // fall through silently } }; const handleDeathSaveChange = async (participantId, outcome) => { if (!db) return; try { await combatDeathSave(encounter, participantId, outcome, buildCtx(encounterPath)); } catch (err) { showToast(err.message); } }; const handleNat20 = async (participantId) => handleDeathSaveChange(participantId, 'nat20'); const handleNat1 = async (participantId) => handleDeathSaveChange(participantId, 'nat1'); const handleStabilize = async (participantId) => { if (!db) return; try { await stabilizeParticipant(encounter, participantId, buildCtx(encounterPath)); } catch (err) { showToast(err.message); } }; const handleRevive = async (participantId) => { if (!db) return; try { await reviveParticipant(encounter, participantId, buildCtx(encounterPath)); } catch (err) { showToast(err.message); } }; const handleCritDamage = async (participantId) => { if (!db) return; try { // Any damage at 0 HP + isCriticalHit = 2 death-save failures (5e crit-at-0). await combatApplyHpChange(encounter, participantId, 'damage', 1, { isCriticalHit: true }, buildCtx(encounterPath)); } catch (err) { showToast(err.message); } }; const toggleCondition = async (participantId, conditionId) => { if (!db) return; try { await combatToggleCondition(encounter, participantId, conditionId, buildCtx(encounterPath)); } catch (err) { // fall through silently } }; // Add freeform condition: persist to campaign palette + apply to current participant. const addCustomCondition = async (label, participantId = null) => { const trimmed = (label || '').trim(); if (!trimmed || !campaignId) return; setCustomConditionInput(''); const exists = allConditions.some(c => c.label.toLowerCase() === trimmed.toLowerCase()); if (!exists) { try { await storage.updateDoc(getPath.campaign(campaignId), { customConditions: [...customConditions, trimmed] }); } catch (err) {} } if (participantId) { const cur = (encounter.participants.find(p => p.id === participantId)?.conditions) || []; if (!cur.includes(trimmed)) { try { await combatToggleCondition(encounter, participantId, trimmed, buildCtx(encounterPath)); } catch (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; } try { await reorderParticipants(encounter, draggedItemId, targetId, buildCtx(encounterPath)); } catch (err) { // drag invalid (id not found) — ignore } setDraggedItemId(null); }; // 1-list model: participants[] IS the display order. No re-sort. const sortedParticipants = 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" />
setManualInitiative(e.target.value)} placeholder="auto" 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" />
setAsNpc(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" />
setManualInitiative(e.target.value)} placeholder="auto" 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.manual ? `${lastRollDetails.name} (${lastRollDetails.type === 'character' ? 'Character' : lastRollDetails.type === 'monster' ? 'Monster' : lastRollDetails.type === 'npc' ? 'NPC' : lastRollDetails.type}): Set initiative ${lastRollDetails.total}` : `${lastRollDetails.name} (${lastRollDetails.type === 'character' ? 'Character' : lastRollDetails.type === 'monster' ? 'Monster' : lastRollDetails.type === 'npc' ? 'NPC' : 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 { showToast, showInfo } = useUIFeedback(); const [showEndConfirm, setShowEndConfirm] = useState(false); const { data: activeDisplayData } = useFirestoreDocument(getPath.activeDisplay()); const hidePlayerHp = activeDisplayData?.hidePlayerHp ?? true; const hideNpcHp = activeDisplayData?.hideNpcHp ?? false; // Undo/redo queried on click. No live logs subscription during combat. const latestLogQuery = [where('encounterPath', '==', encounterPath), LOG_ORDER, limit(25)]; const findUndoTarget = async (redo = false) => { const rows = (await storage.getCollection(getPath.logs(), latestLogQuery) || []) .filter(l => l.undo || l.undo_payload); if (!redo) return rows.find(l => !l.undone) || null; // Redo stack = contiguous undone entries at top of log (newest first). // If A3 then A2 undone, redo must replay A2 first, then A3. const undoneBlock = []; for (const row of rows) { if (!row.undone) break; undoneBlock.push(row); } return undoneBlock.length ? undoneBlock[undoneBlock.length - 1] : null; }; const handleUndo = async () => { if (!db) return; try { if (storage.undo) { const undoTarget = await findUndoTarget(false); if (!undoTarget) { showToast('No action to undo.'); return; } const expanded = undoTarget.undo ? expandUndo(undoTarget, encounter) // lean: build patch from current enc : getUndo(undoTarget); // legacy if (!expanded) return; await storage.undo({ logPath: `${getPath.logs()}/${undoTarget.id}`, undo: expanded }); } } catch (err) { console.error('Error undoing action:', err); showToast('Failed to undo. The encounter may have changed or no longer exists.'); } }; const handleRedo = async () => { if (!db) return; try { if (storage.undo) { const redoTarget = await findUndoTarget(true); if (!redoTarget) { showToast('No action to redo.'); return; } const expanded = redoTarget.undo ? expandUndo(redoTarget, encounter) : getUndo(redoTarget); if (!expanded) return; await storage.undo({ logPath: `${getPath.logs()}/${redoTarget.id}`, undo: expanded, redo: true }); } } catch (err) { console.error('Error redoing action:', err); showToast('Failed to redo.'); } }; 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 handleToggleHideNpcHp = async () => { if (!db) return; try { await storage.setDoc(getPath.activeDisplay(), { hideNpcHp: !hideNpcHp }, { merge: true }); } catch (err) { console.error("Error toggling hideNpcHp:", err); } }; const handleStartEncounter = async () => { if (!db || !encounter.participants || encounter.participants.length === 0) { showInfo("Add participants first."); return; } try { await startEncounter(encounter, buildCtx(encounterPath)); await storage.setDoc(getPath.activeDisplay(), { activeCampaignId: campaignId, activeEncounterId: encounter.id }, { merge: true }); } catch (err) { showToast(err.message || "Failed to start encounter. Please try again."); } }; const handleTogglePause = async () => { if (!db || !encounter || !encounter.isStarted) return; try { await togglePause(encounter, buildCtx(encounterPath)); } catch (err) { // fall through silently } }; const handleNextTurn = async () => { if (!db || !encounter.isStarted || encounter.isPaused) return; if (!encounter.currentTurnParticipantId || !encounter.turnOrderIds || encounter.turnOrderIds.length === 0) return; try { await nextTurn(encounter, buildCtx(encounterPath)); } catch (err) { // nextTurn throws if no active participants — auto-end combat if (err.message === 'Encounter not running.' || err.message === 'No active turn.') return; showInfo("No active participants left."); await storage.updateDoc(encounterPath, { isStarted: false, isPaused: false, currentTurnParticipantId: null, round: encounter.round }); } }; const confirmEndEncounter = async () => { if (!db) return; try { await endEncounter(encounter, buildCtx(encounterPath)); await storage.setDoc(getPath.activeDisplay(), { activeCampaignId: null, activeEncounterId: null }, { merge: true }); } catch (err) { showToast("Failed to end encounter. Please try again."); } setShowEndConfirm(false); }; if (!encounter || !encounter.participants) return null; return ( <>

Combat Controls

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

Round: {encounter.round}

{encounter.isPaused && (

(Paused)

)}
)}
{/* Undo / Redo — queried on click (no live logs subscription during combat) */}
{/* 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 { showToast } = useUIFeedback(); 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); showToast("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 { // cascade: delete logs for this encounter const encPath = getPath.encounter(campaignId, encounterId); const allLogs = await storage.getCollection(getPath.logs()); const encLogs = allLogs.filter(l => (l.encounterId && l.encounterId === encounterId) || (l.encounterPath && l.encounterPath.includes(encPath)) ); if (encLogs.length > 0) { await storage.batchWrite(encLogs.map(l => ({ type: 'delete', path: `${getPath.logs()}/${l.id || l.path?.split('/').pop()}`, }))); } await storage.deleteDoc(encPath); 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); showToast("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, }); } } 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 { showToast } = useUIFeedback(); 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); const [campaignsCollapsed, setCampaignsCollapsed] = useState(() => { try { return localStorage.getItem('ttrpg.campaignsCollapsed') === 'true'; } catch { return false; } }); useEffect(() => { try { localStorage.setItem('ttrpg.campaignsCollapsed', String(campaignsCollapsed)); } catch {} }, [campaignsCollapsed]); 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 }; }) ); // newest first by createdAt (fallback to name for stable order). detailedCampaigns.sort((a, b) => { const at = a.createdAt || 0; const bt = b.createdAt || 0; if (at === bt) return (a.name || '').localeCompare(b.name || ''); return bt - at; }); setCampaignsWithDetails(detailedCampaigns); }; fetchDetails(); } else if (campaignsData) { const sorted = [...campaignsData].sort((a, b) => { const at = a.createdAt || 0; const bt = b.createdAt || 0; if (at === bt) return (a.name || '').localeCompare(b.name || ''); return bt - at; }); setCampaignsWithDetails( sorted.map(c => ({ ...c, characters: c.players || [], encounterCount: 0 })) ); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [campaignsData]); useEffect(() => { if ( initialActiveInfo && initialActiveInfo.activeCampaignId && campaignsWithDetails.length > 0 ) { const campaignExists = campaignsWithDetails.some(c => c.id === initialActiveInfo.activeCampaignId); if (campaignExists && selectedCampaignId !== initialActiveInfo.activeCampaignId) { 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); showToast("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; await deleteCampaignCascade(campaignId); if (selectedCampaignId === campaignId) { setSelectedCampaignId(null); } setShowDeleteConfirm(false); setItemToDelete(null); showToast('Campaign deleted.'); }; // Cascade delete: encounters + logs + display + campaign doc. const deleteCampaignCascade = async (campaignId) => { 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}` }; }); const allLogs = await storage.getCollection(getPath.logs()); const encPaths = new Set(encounters.map(e => `${encountersPath}/${e.id || e.path?.split('/').pop()}`)); const encIds = new Set(encounters.map(e => e.id || e.path?.split('/').pop())); const logOps = allLogs .filter(l => (l.encounterId && encIds.has(l.encounterId)) || (l.encounterPath && [...encPaths].some(p => l.encounterPath.includes(p))) ) .map(l => ({ type: 'delete', path: `${getPath.logs()}/${l.id || l.path?.split('/').pop()}`, })); if (deleteOps.length > 0 || logOps.length > 0) { await storage.batchWrite([...deleteOps, ...logOps]); } await storage.deleteDoc(getPath.campaign(campaignId)); const activeDisplay = await storage.getDoc(getPath.activeDisplay()); if (activeDisplay && activeDisplay.activeCampaignId === campaignId) { await storage.updateDoc(getPath.activeDisplay(), { activeCampaignId: null, activeEncounterId: null }); } }; // Dev-only: wipe all campaigns + encounters + logs. Irreversible. // Uses deleteCollection (SQL bulk DELETE, no fetch). Parallel per campaign. const [showDeleteAllConfirm, setShowDeleteAllConfirm] = useState(false); const handleDeleteAllCampaigns = async () => { if (!db) return; try { const all = await storage.getCollection(getPath.campaigns()); await Promise.all(all.map(async (c) => { const cid = c.id || c.path?.split('/').pop(); // bulk-delete all encounters under this campaign (SQL DELETE, no fetch) if (storage.deleteCollection) { await storage.deleteCollection(getPath.encounters(cid)); } await storage.deleteDoc(getPath.campaign(cid)); })); // nuke all logs in one shot (orphans acceptable in dev; logs span campaigns) if (storage.deleteCollection) { await storage.deleteCollection(getPath.logs()); } const activeDisplay = await storage.getDoc(getPath.activeDisplay()); if (activeDisplay && activeDisplay.activeCampaignId) { await storage.updateDoc(getPath.activeDisplay(), { activeCampaignId: null, activeEncounterId: null }); } setSelectedCampaignId(null); setShowDeleteAllConfirm(false); showToast(`Deleted ${all.length} campaign(s).`); } catch (err) { console.error('Error deleting all campaigns:', err); showToast('Failed to delete all campaigns.'); } }; const selectedCampaign = campaignsWithDetails.find(c => c.id === selectedCampaignId); if (isLoadingCampaigns) { return

Loading campaigns...

; } if (campaignsError) { return (

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

); } return ( <>
{!campaignsCollapsed && ( <> {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
{campaign.createdAt && (
Created: {new Date(campaign.createdAt).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false })}
)}
); })}
)}
{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.`} /> {process.env.NODE_ENV === 'development' && campaignsWithDetails.length > 0 && (
)} setShowDeleteAllConfirm(false)} onConfirm={handleDeleteAllCampaigns} title="Delete ALL Campaigns?" message="DEV ONLY: This permanently deletes EVERY campaign, all encounters, and all logs. Cannot be undone. Are you absolutely sure?" /> ); } // ============================================================================ // DISPLAY VIEW COMPONENT (Player View) // ============================================================================ // Player participant card: animate state transitions. // mount/activate : animate in (opacity+scale+slide) // deactivate : animate out then signal exit // alive→dead : transition to death cue (dim+desaturate+skull pulse+red rim) // conscious/dying/stable: full visible, no fade function PlayerParticipantCard({ id, isActive, status, onExit, className, children, divRef }) { // phase: 'hidden' (opacity-0, start state) -> 'visible' (opacity-100) // disable: 'visible' -> 'exiting' (opacity-0, transition plays) -> onExit const [phase, setPhase] = useState('hidden'); const isDead = status === 'dead'; useEffect(() => { if (!isActive) { // ensure current 'visible' frame painted, THEN flip to exiting so // browser sees opacity change and plays the fade-out. const raf = requestAnimationFrame(() => { setPhase('exiting'); }); const t = setTimeout(() => onExit(id), 1000); return () => { cancelAnimationFrame(raf); clearTimeout(t); }; } // enable/reactivate: paint hidden frame first, THEN flip to visible // so browser sees opacity change and plays the transition. setPhase('hidden'); const raf = requestAnimationFrame(() => { requestAnimationFrame(() => setPhase('visible')); }); return () => cancelAnimationFrame(raf); }, [isActive, id, onExit]); // animate: opacity + scale + slide. Visible motion, not just fade. const fadeClass = phase === 'visible' ? 'opacity-100 scale-100 translate-y-0' : 'opacity-0 scale-75 translate-y-4'; // death transition: fires once when alive→dead. dim + desaturate + red rim. const deathClass = isDead ? 'brightness-50 saturate-0 border-2 border-red-900/70 shadow-red-900/50 shadow-2xl' : ''; return (
{children} {isDead && (
☠️
)}
); } 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 [displayParticipants, setDisplayParticipants] = useState([]); const wakeLockRef = useRef(null); const currentParticipantRef = useRef(null); // Player display transition state. Active participants render normally. // Active→inactive: keep prior card, mark __displayActive=false, animate out, // then PlayerParticipantCard calls handleExit to remove from display list. // Inactive→active: card reappears with __displayActive=true and fades in. useEffect(() => { const source = (activeEncounterData && activeEncounterData.participants) || []; setDisplayParticipants(prev => { const prevById = new Map(prev.map(p => [p.id, p])); const out = []; for (const p of source) { const prevP = prevById.get(p.id); if (p.isActive !== false) { out.push({ ...p, __displayActive: true }); } else if (prevP && prevP.__displayActive !== false) { out.push({ ...p, __displayActive: false }); } else if (prevP && prevP.__displayActive === false) { out.push(prevP); } } return out; }); }, [activeEncounterData]); const handleExit = useCallback((id) => { setDisplayParticipants(prev => prev.filter(p => p.id !== id)); }, []); 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); unsubscribeCampaign = storage.subscribeDoc( getPath.campaign(activeCampaignId), (camp) => { setCampaignBackgroundUrl((camp && camp.playerDisplayBackgroundUrl) || ''); }, (err) => console.error("Error fetching campaign background:", err) ); unsubscribeEncounter = storage.subscribeDoc( getPath.encounter(activeCampaignId, activeEncounterId), (enc) => { if (enc) { setActiveEncounterData({ id: activeEncounterId, ...enc }); } 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; const hideNpcHp = activeDisplayData?.hideNpcHp ?? false; // 1-list model: displayParticipants IS the display order (participants[] order // plus temporary exiting cards). Do NOT re-sort by initiative. const participantsToRender = displayParticipants; 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 status = p.status || (p.currentHp === 0 ? 'dying' : 'conscious'); const isZeroHp = p.currentHp === 0; let participantBgColor = p.type === 'monster' || p.type === 'npc' ? '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 (

{isZeroHp && ☠️} {p.name} {isCurrentTurn && ( (Current) )} {status === 'dying' && (Dying)} {status === 'stable' && (p.conditions || []).includes('unconscious') && (Unconscious)} {status === 'dead' && (Dead)}

Init: {p.initiative}
{!(hidePlayerHp && p.type === 'character') && !(hideNpcHp && 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} ) : ( {cId} ); })}
)} {!p.isActive && !isZeroHp && (

(Inactive)

)}
); })}
); } // ============================================================================ // LOGS VIEW COMPONENT // ============================================================================ function LogsView() { const { showToast } = useUIFeedback(); const [page, setPage] = useState(0); const pageQuery = useMemo(() => [LOG_ORDER, limit(LOG_PAGE_SIZE), offset(page * LOG_PAGE_SIZE)], [page]); const { data: logs, isLoading } = useFirestoreCollection(getPath.logs(), pageQuery); const { data: campaignsData } = useFirestoreCollection(getPath.campaigns()); const [showClearConfirm, setShowClearConfirm] = useState(false); const [undoingId, setUndoingId] = useState(null); const [dlMenu, setDlMenu] = useState(false); const [cpMenu, setCpMenu] = useState(false); // Full typed-log set for download/copy dropdown lists. Page query (LOG_QUERY, // 100/page) only sees current page, so the encounter list was truncated. // Lazy-fetch on first menu open; reused after. Server getCollection with no // constraint returns all docs, so this is the full set (not page-limited). // Ref guard (not state): StrictMode double-mount sets state then unmounts // before fetch resolves; state-based loading flag dead-locks the remount. const [allLogs, setAllLogs] = useState(null); const allLogsFetched = useRef(false); useEffect(() => { if (!dlMenu && !cpMenu) return; if (allLogs || allLogsFetched.current) return; allLogsFetched.current = true; let cancelled = false; getStorage().getCollection(getPath.logs()) .then(docs => { if (!cancelled) setAllLogs(docs.filter(l => l.type)); }) .catch(err => { console.error('Error loading all logs:', err); allLogsFetched.current = false; }) .finally(() => { if (cancelled) allLogsFetched.current = false; }); return () => { cancelled = true; }; }, [dlMenu, cpMenu, allLogs]); // Total log count (separate cheap server count query, not full fetch). const [totalCount, setTotalCount] = useState(null); useEffect(() => { let cancelled = false; getStorage().countCollection(getPath.logs()) .then(n => { if (!cancelled) setTotalCount(n); }) .catch(() => { if (!cancelled) setTotalCount(null); }); return () => { cancelled = true; }; }, [logs]); const shown = (logs || []).length; const rangeStart = totalCount ? page * LOG_PAGE_SIZE + 1 : 0; const rangeEnd = totalCount ? Math.min((page + 1) * LOG_PAGE_SIZE, totalCount) : 0; const hasPrev = page > 0; const hasNext = totalCount === null ? shown === LOG_PAGE_SIZE : rangeEnd < totalCount; // Parse campaign id out of encounterPath (.../campaigns/{cid}/encounters/{eid}). const cidFromPath = (p) => { if (!p) return null; const m = String(p).match(/campaigns\/([^/]+)\/encounters/); return m ? m[1] : null; }; const campaignNameById = new Map( (campaignsData || []).map(c => [c.id, c.name || '(unnamed campaign)']) ); // Group typed logs by encounter. Each item shows campaign · encounter · // last-entry date · entry count. Sorted newest first by last-entry ts. // Legacy logs (no type) excluded — kept for human scroll only. const encounterGroups = (() => { const src = allLogs ?? (logs || []); const map = new Map(); // key: encounterId || encounterPath || 'none' for (const l of src) { if (!l.type) continue; const key = l.encounterId || l.encounterPath || 'none'; const cid = cidFromPath(l.encounterPath); const campName = cid ? (campaignNameById.get(cid) || '(unknown campaign)') : '(unknown campaign)'; const g = map.get(key) || { key, encounter: l.encounterName || '(unnamed encounter)', campaign: campName, count: 0, latest: 0, }; g.count++; g.latest = Math.max(g.latest, l.ts || l.timestamp || 0); map.set(key, g); } return [...map.values()].sort((a, b) => b.latest - a.latest); })(); const typedCount = encounterGroups.reduce((n, g) => n + g.count, 0); const fmtDate = (ts) => ts ? new Date(ts).toLocaleString() : '—'; // Filename-safe human date: 2026-07-04_15-30 const fileDate = (ts) => ts ? new Date(ts).toISOString().slice(0,16).replace('T', '_').replace(':', '-') : new Date().toISOString().slice(0,16).replace('T', '_').replace(':', '-'); const dlFileName = (g) => g ? `ttrpg-logs-${(g.campaign + '_' + g.encounter).replace(/[^a-z0-9]+/gi, '_')}-${fileDate(g.latest)}.json` : `ttrpg-logs-all-${fileDate(Date.now())}.json`; const handleDownloadFor = async (encKey) => { setDlMenu(false); try { const all = await storage.getCollection(getPath.logs()); const filtered = encKey ? all.filter(l => (l.encounterId || l.encounterPath || 'none') === encKey) : all; const json = shared.logEvent.serializeEvents(filtered); if (json === '[]') { showToast('No typed log entries for that selection.'); return; } const blob = new Blob([json], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; const grp = encKey ? encounterGroups.find(g => g.key === encKey) : null; a.download = dlFileName(grp); document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } catch (err) { console.error('Error downloading logs:', err); showToast('Failed to download logs.'); } }; const handleCopyFor = async (encKey) => { setCpMenu(false); try { const all = await storage.getCollection(getPath.logs()); const filtered = encKey ? all.filter(l => (l.encounterId || l.encounterPath || 'none') === encKey) : all; const json = shared.logEvent.serializeEvents(filtered); if (json === '[]') { showToast('No typed log entries for that selection.'); return; } await navigator.clipboard.writeText(json); showToast('Logs copied to clipboard.'); } catch (err) { console.error('Error copying logs:', err); showToast('Failed to copy logs.'); } }; 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) return; setUndoingId(entry.id); try { if (storage.undo) { const expanded = entry.undo ? expandUndo(entry, await storage.getDoc(entry.encounterPath)) : getUndo(entry); if (!expanded) { setUndoingId(null); return; } await storage.undo({ logPath: `${getPath.logs()}/${entry.id}`, undo: expanded }); } } catch (err) { console.error('Error undoing action:', err); showToast("Failed to roll back. The encounter may have changed or no longer exists."); } setUndoingId(null); }; return (

Combat Log

← Back to Tracker
{dlMenu && (
{encounterGroups.length > 0 && (
)} {encounterGroups.map(g => ( ))}
)}
{cpMenu && (
{encounterGroups.length > 0 && (
)} {encounterGroups.map(g => ( ))}
)}
{isLoading ? ( ) : logs.length === 0 ? (

No log entries yet.

) : ( <>

{totalCount === null ? `${shown} entries — newest first` : `Showing ${rangeStart}–${rangeEnd} of ${totalCount}`}

Page {page + 1}
{logs.map(entry => (
{fmtDate(entry.ts || entry.timestamp)} {entry.encounterName && ( [{entry.encounterName}] )} {entry.message} {entry.undone ? ( rolled back ) : getUndo(entry) ? ( ) : 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;