import React, { useState, useEffect, useRef, useMemo, createContext, useContext, useCallback } from 'react'; import { createPortal } from 'react-dom'; import * as shared from '@ttrpg/shared'; import { initializeApp, getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken, getFirestore, where, orderBy, limit, offset, getStorage, getStorageMode } from './storage'; import { isDevToolsEnabled } from './config/devTools'; 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, 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, markDead, 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 createPortal(

{title}

{children}
, document.body ); } 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 createPortal(

{title || "Confirm Action"}

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

{error && (

{error}

)}
, document.body ); } 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 [ruleset, setRuleset] = useState('5e'); const handleSubmit = (e) => { e.preventDefault(); if (name.trim()) { onCreate(name, backgroundUrl, ruleset); } }; 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, defaultRuleset = '5e' }) { const [name, setName] = useState(''); const [ruleset, setRuleset] = useState(defaultRuleset); const handleSubmit = (e) => { e.preventDefault(); if (name.trim()) { onCreate(name, ruleset); } }; 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 [hpFormula, setHpFormula] = useState(participant.hpFormula || ''); const [ac, setAc] = useState(participant.ac != null ? participant.ac : ''); const [asNpc, setAsNpc] = useState(participant.type === 'npc'); const [tempHp, setTempHp] = useState(participant.tempHp || 0); const handleSubmit = (e) => { e.preventDefault(); let finalMaxHp = parseInt(maxHp, 10); // formula rolls only if maxHp empty. maxHp wins if both. if (isNaN(finalMaxHp) && hpFormula.trim()) { const rolled = shared.rollHpFormula(hpFormula); if (rolled) finalMaxHp = rolled; } if (isNaN(finalMaxHp)) finalMaxHp = participant.maxHp; let finalCurrentHp = parseInt(currentHp, 10); if (isNaN(finalCurrentHp)) finalCurrentHp = finalMaxHp; onSave({ name: name.trim(), initiative: parseInt(initiative, 10), currentHp: finalCurrentHp, maxHp: finalMaxHp, hpFormula: (participant.type === 'monster' || participant.type === 'npc') ? (hpFormula.trim() || null) : null, ac: ac !== '' ? parseInt(ac, 10) : null, tempHp: Math.max(0, parseInt(tempHp, 10) || 0), 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" />
setAc(e.target.value)} placeholder="optional" 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" />
setTempHp(e.target.value)} min="0" placeholder="0" 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-cyan-600 focus:border-cyan-600 sm:text-sm text-white" />
{(participant.type === 'monster' || participant.type === 'npc') && (
setHpFormula(e.target.value)} placeholder="e.g. 2d6+9" className="flex-1 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, syncCharacters }) { const { showToast, showInfo } = useUIFeedback(); const [characterName, setCharacterName] = useState(''); const [defaultMaxHp, setDefaultMaxHp] = useState(DEFAULT_MAX_HP); const [defaultInitMod, setDefaultInitMod] = useState(DEFAULT_INIT_MOD); const [defaultAc, setDefaultAc] = useState(''); const [isCharacterNpc, setIsCharacterNpc] = useState(false); const [editingCharacter, setEditingCharacter] = useState(null); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [itemToDelete, setItemToDelete] = useState(null); const [draggedCharId, setDraggedCharId] = useState(null); const handleCharDragStart = (e, id) => { setDraggedCharId(id); e.dataTransfer.effectAllowed = 'move'; }; const handleCharDragOver = (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }; const handleCharDrop = async (e, targetId) => { e.preventDefault(); if (!campaignId || !draggedCharId || draggedCharId === targetId) { setDraggedCharId(null); return; } const reordered = [...campaignCharacters]; const fromIdx = reordered.findIndex(c => c.id === draggedCharId); const toIdx = reordered.findIndex(c => c.id === targetId); if (fromIdx === -1 || toIdx === -1) { setDraggedCharId(null); return; } const [moved] = reordered.splice(fromIdx, 1); reordered.splice(toIdx, 0, moved); try { await storage.updateDoc(getPath.campaign(campaignId), { players: reordered }); } catch (err) {} setDraggedCharId(null); }; const [isOpen, setIsOpen] = useState(() => { try { return localStorage.getItem('ttrpg.charactersCollapsed') !== 'true'; } catch { return true; } }); useEffect(() => { try { localStorage.setItem('ttrpg.charactersCollapsed', String(!isOpen)); } catch {} }, [isOpen]); 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, defaultAc: defaultAc !== '' ? parseInt(defaultAc, 10) : null, isNpc: isCharacterNpc || false, }; try { await storage.updateDoc(getPath.campaign(campaignId), { players: [...campaignCharacters, newCharacter] }); setCharacterName(''); setDefaultMaxHp(DEFAULT_MAX_HP); setDefaultInitMod(DEFAULT_INIT_MOD); setDefaultAc(''); setIsCharacterNpc(false); } catch (err) { console.error("Error adding character:", err); showToast("Failed to add character. Please try again."); } }; const handleUpdateCharacter = async (characterId, newName, newDefaultMaxHp, newDefaultInitMod, newDefaultAc, newDefaultCurrentHp, newIsNpc) => { 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, defaultAc: newDefaultAc !== undefined && newDefaultAc !== '' ? parseInt(newDefaultAc, 10) : null, defaultCurrentHp: newDefaultCurrentHp !== undefined && newDefaultCurrentHp !== '' ? parseInt(newDefaultCurrentHp, 10) : null, isNpc: newIsNpc != null ? newIsNpc : (c.isNpc || false) } : 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 ( <>
{isOpen && ( <>
{ e.preventDefault(); handleAddCharacter(); }} className="grid grid-cols-2 sm:grid-cols-12 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" />
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" />
setDefaultAc(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" />
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" />
{campaignCharacters.length === 0 && (

No characters added yet.

)}
    {campaignCharacters.map(character => (
  • handleCharDragStart(e, character.id)} onDragOver={handleCharDragOver} onDrop={(e) => handleCharDrop(e, character.id)} onDragEnd={() => setDraggedCharId(null)} className={`flex justify-between items-center p-3 bg-stone-800 rounded-md cursor-grab ${draggedCharId === character.id ? 'opacity-50 ring-2 ring-yellow-400' : ''}`} >
    {editingCharacter && editingCharacter.id === character.id ? (
    { e.preventDefault(); handleUpdateCharacter( character.id, editingCharacter.name, editingCharacter.defaultMaxHp, editingCharacter.defaultInitMod, editingCharacter.defaultAc, editingCharacter.defaultCurrentHp, editingCharacter.isNpc ); }} className="flex-grow flex flex-wrap gap-2 items-center" >
    setEditingCharacter({ ...editingCharacter, name: e.target.value })} className="w-full px-2 py-1 bg-stone-700 border border-stone-600 rounded-md text-white" />
    setEditingCharacter({ ...editingCharacter, defaultMaxHp: e.target.value })} className="w-20 px-2 py-1 bg-stone-700 border border-stone-600 rounded-md text-white" title="Default Max HP" />
    setEditingCharacter({ ...editingCharacter, defaultCurrentHp: e.target.value })} className="w-16 px-2 py-1 bg-stone-700 border border-stone-600 rounded-md text-white" title="Default Current HP" />
    setEditingCharacter({ ...editingCharacter, defaultInitMod: e.target.value })} className="w-20 px-2 py-1 bg-stone-700 border border-stone-600 rounded-md text-white" title="Default Init Mod" />
    setEditingCharacter({ ...editingCharacter, defaultAc: e.target.value })} className="w-16 px-2 py-1 bg-stone-700 border border-stone-600 rounded-md text-white" title="AC" />
    ) : ( <>
    {character.name} {character.isNpc && ( NPC )}
    HP {character.defaultMaxHp || '?'} {character.defaultCurrentHp != null && character.defaultCurrentHp !== character.defaultMaxHp && ( Current HP {character.defaultCurrentHp} )} {character.defaultAc != null && ( AC {character.defaultAc} )} Init {formatInitMod(character.defaultInitMod)}
    )}
  • ))}
)}
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(''); const [hpFormula, setHpFormula] = useState(''); const [manualInitiative, setManualInitiative] = useState(''); const [monsterAc, setMonsterAc] = useState(''); const [asNpc, setAsNpc] = useState(false); const combatActive = encounter.isStarted && !encounter.isPaused; const [addSectionOpen, setAddSectionOpen] = useState(!combatActive); 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 || []; const [editingField, setEditingField] = useState(null); const [editingId, setEditingId] = useState(null); const blurTimeoutRef = useRef(null); const handleFieldFocus = (id, label) => { if (blurTimeoutRef.current) { clearTimeout(blurTimeoutRef.current); blurTimeoutRef.current = null; } setEditingId(id); setEditingField(label); }; const handleFieldBlur = () => { blurTimeoutRef.current = setTimeout(() => { setEditingField(null); setEditingId(null); }, 150); }; useEffect(() => { if (participantType === 'character' && selectedCharacterId) { const selectedChar = campaignCharacters.find(c => c.id === selectedCharacterId); if (selectedChar && selectedChar.defaultMaxHp) { setMaxHp(selectedChar.defaultMaxHp); } else { setMaxHp(''); } setAsNpc(false); } else if (participantType === 'monster') { setMaxHp(''); 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; // maxHp wins if both set; else roll formula if (!maxHp && hpFormula.trim()) { const rolled = shared.rollHpFormula(hpFormula); if (rolled) currentMaxHp = rolled; } } 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, hpFormula: participantType === 'monster' ? (hpFormula.trim() || null) : null, ac: participantType === 'monster' && monsterAc !== '' ? parseInt(monsterAc, 10) : (participantType === 'character' ? (campaignCharacters.find(c => c.id === selectedCharacterId)?.defaultAc ?? null) : null), 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(''); setHpFormula(''); setSelectedCharacterId(''); setMonsterInitMod(MONSTER_DEFAULT_INIT_MOD); setMonsterAc(''); 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 handleInlineCurrentHp = async (participantId, value) => { let n = parseInt(value, 10); if (isNaN(n)) return; const p = participants.find(pp => pp.id === participantId); if (!p || n === p.currentHp) return; try { const isGeneric = (encounter.ruleset === 'generic'); let patch; if (!isGeneric && n < 0) n = 0; if (isGeneric) { patch = { currentHp: n, status: n > 0 ? 'conscious' : 'down' }; } else { patch = { currentHp: n, deathSaveSuccesses: 0, deathSaveFailures: 0 }; if (n > 0) { patch.status = 'conscious'; } else if (p.type === 'monster') { patch.status = 'dead'; patch.isActive = false; } else { patch.status = 'dying'; } } await updateParticipant(encounter, participantId, patch, buildCtx(encounterPath)); } catch (err) { showToast('Failed to update HP.'); } }; const handleInlineMaxHp = async (participantId, value) => { const n = parseInt(value, 10); if (isNaN(n)) return; const p = participants.find(pp => pp.id === participantId); if (!p || n === p.maxHp || n <= 0) return; try { await updateParticipant(encounter, participantId, { maxHp: n }, buildCtx(encounterPath)); } catch (err) { showToast('Failed to update Max HP.'); } }; const handleInlineAc = async (participantId, value) => { const n = parseInt(value, 10); if (isNaN(n)) return; const p = participants.find(pp => pp.id === participantId); if (!p || n === p.ac) return; try { await updateParticipant(encounter, participantId, { ac: n }, buildCtx(encounterPath)); } catch (err) { showToast('Failed to update AC.'); } }; const handleInlineTempHp = async (participantId, value) => { const n = Math.max(0, parseInt(value, 10) || 0); try { await updateParticipant(encounter, participantId, { tempHp: n }, buildCtx(encounterPath)); } catch (err) { showToast('Failed to update Temp HP.'); } }; 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 handleMarkDead = async (participantId) => { if (!db) return; try { await markDead(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 ( <>
{(() => { const open = addSectionOpen && !combatActive; return (
{ if (!combatActive) { e.preventDefault(); setAddSectionOpen(v => !v); } else { e.preventDefault(); } }} className="cursor-pointer text-lg font-medium text-amber-200 font-cinzel tracking-wide flex items-center gap-2 select-none" > {open ? : } Add Participants {combatActive && (pause to add)} {open && ( <>
{/* 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-12 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" />
setMonsterAc(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" />
setHpFormula(e.target.value)} placeholder="e.g. 2d6+9" 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" />
)}
)}
); })()} {participants.length === 0 &&

No participants added yet.

}
    {sortedParticipants.map((p) => { const isCurrentTurn = encounter.isStarted && p.id === encounter.currentTurnParticipantId; const isDraggable = (!encounter.isStarted || encounter.isPaused) && tiedInitiatives.includes(Number(p.initiative)); const participantDisplayType = p.type === 'npc' ? 'NPC' : (p.type === 'monster' ? 'Monster' : 'Character'); const isGeneric = (encounter.ruleset || '5e') === 'generic'; const hasDeathSaves = !isGeneric && (p.type === 'character' || p.type === 'npc'); let bgColor = p.type === 'character' ? 'bg-indigo-950' : 'bg-[#8e351c]'; if (isCurrentTurn && !encounter.isPaused) bgColor = 'bg-green-600'; const participantStatus = p.status || (isGeneric ? (p.currentHp <= 0 ? 'down' : 'conscious') : (p.currentHp === 0 ? (p.type === 'monster' ? 'dead' : 'dying') : 'conscious')); const isZeroHp = p.currentHp <= 0; const isDead = participantStatus === 'dead'; const isDown = participantStatus === 'down'; return (
  • handleDragStart(e, p.id) : undefined} onDragOver={isDraggable ? handleDragOver : undefined} onDrop={isDraggable ? (e) => handleDrop(e, p.id) : undefined} onDragEnd={() => setDraggedItemId(null)} className={`relative p-3 rounded-md flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 transition-all duration-300 ${bgColor} ${isCurrentTurn && !encounter.isPaused ? 'ring-2 ring-green-300 shadow-lg' : ''} ${!p.isActive ? 'opacity-35 grayscale' : (isDead ? 'opacity-90 brightness-75 saturate-125' : '')} ${isDraggable ? 'cursor-grab' : ''} ${draggedItemId === p.id ? 'opacity-50 ring-2 ring-yellow-400' : ''} ${editingId === p.id ? 'ring-2 ring-amber-400' : ''}`} > {editingId === p.id && ( ✎ Editing {editingField} )}
    {isDraggable && ( )}

    {isZeroHp && ☠️} {p.name} ({participantDisplayType}) {p.ac != null && ( AC { e.target.select(); handleFieldFocus(p.id, 'AC'); }} onBlur={(e) => { if (e.target.value !== String(p.ac)) handleInlineAc(p.id, e.target.value); handleFieldBlur(); }} onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur(); if (e.key === 'Escape') e.target.value = p.ac; }} className="w-6 bg-transparent text-sky-100 text-lg font-bold leading-none text-center focus:outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" aria-label={`AC for ${p.name}`} /> )} {isCurrentTurn && !encounter.isPaused && ( CURRENT )} {hasDeathSaves && participantStatus === 'dying' && (Dying)} {hasDeathSaves && participantStatus === 'stable' && (p.conditions || []).includes('unconscious') && (Unconscious)} {isDown && (Down)} {isDead && DEAD}

    { if (e.target.value.length > 2) e.target.value = e.target.value.slice(0, 2); }} onFocus={(e) => { e.target.select(); handleFieldFocus(p.id, 'Initiative'); }} onBlur={(e) => { if (e.target.value !== String(p.initiative)) handleInlineInitiative(p.id, e.target.value); handleFieldBlur(); }} onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur(); }} className="w-7 bg-transparent text-amber-300 text-lg font-bold text-center focus:outline-none disabled:text-amber-300 disabled:opacity-100 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" aria-label={`Initiative for ${p.name}`} /> { e.target.select(); handleFieldFocus(p.id, 'HP'); }} onBlur={(e) => { if (e.target.value !== String(p.currentHp)) handleInlineCurrentHp(p.id, e.target.value); handleFieldBlur(); }} onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur(); if (e.key === 'Escape') e.target.value = p.currentHp; }} className="w-10 bg-transparent text-white text-base text-center border-b border-transparent hover:border-stone-500 focus:border-red-500 focus:outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" aria-label={`Current HP for ${p.name}`} /> / { e.target.select(); handleFieldFocus(p.id, 'Max HP'); }} onBlur={(e) => { if (e.target.value !== String(p.maxHp)) handleInlineMaxHp(p.id, e.target.value); handleFieldBlur(); }} onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur(); if (e.key === 'Escape') e.target.value = p.maxHp; }} className="w-10 bg-transparent text-stone-200 text-base text-center border-b border-transparent hover:border-stone-400 focus:border-red-500 focus:outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" aria-label={`Max HP for ${p.name}`} /> + { e.target.select(); handleFieldFocus(p.id, 'Temp HP'); }} onBlur={(e) => { if (e.target.value !== String(p.tempHp || 0)) handleInlineTempHp(p.id, e.target.value); handleFieldBlur(); }} onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur(); if (e.key === 'Escape') e.target.value = p.tempHp || 0; }} className={`w-7 bg-transparent text-base text-center border-b focus:outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none ${(p.tempHp || 0) > 0 ? 'text-cyan-200 border-cyan-500' : 'text-stone-300 border-transparent hover:border-stone-400'}`} aria-label={`Temp HP for ${p.name}`} /> {participantStatus === 'dead' && ( )} {isGeneric && !isDead && ( )}
    {hasDeathSaves && participantStatus === 'stable' && (
    Stable — regains 1 HP after 1d4 hours
    )} {/* Death saves - D&D 5e status state machine */} {hasDeathSaves && encounter.isStarted && participantStatus !== 'conscious' && (
    {participantStatus === 'dying' && ( <>
    Saves: {[1, 2, 3].map(saveNum => ( = saveNum ? 'bg-emerald-600 border-emerald-500' : 'bg-stone-800 border-stone-600'}`} title={`Success pip ${saveNum}`} > {(p.deathSaveSuccesses || 0) >= saveNum && } ))}
    Fails: {[1, 2, 3].map(failNum => ( = failNum ? 'bg-red-600 border-red-500' : 'bg-stone-800 border-stone-600'}`} title={`Fail pip ${failNum}`} > {(p.deathSaveFailures || 0) >= failNum && } ))}
    )}
    )} {/* Active condition badges */} {(p.conditions || []).length > 0 && (
    {(p.conditions || []).map(cId => { const cond = allConditions.find(c => c.id === cId); return cond ? ( toggleCondition(p.id, cId)} > {cond.emoji} {cond.label} ) : ( toggleCondition(p.id, cId)} > {cId} ); })}
    )} {/* Expandable conditions picker */} {openConditionsId === p.id && (

    Toggle Conditions

    {allConditions.map(cond => { const active = (p.conditions || []).includes(cond.id); return ( ); })}
    {/* Add custom condition (per-campaign, freeform string) */}
    setCustomConditionInput(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addCustomCondition(customConditionInput, p.id); } }} placeholder="Add custom condition..." className="flex-grow px-2 py-1 text-xs rounded bg-stone-800 border border-stone-600 text-stone-200 placeholder-stone-500 focus:outline-none focus:border-amber-500" maxLength={40} />
    )}
    {hasDeathSaves && (participantStatus === 'dying' || participantStatus === 'stable') && encounter.isStarted && ( )} {encounter.isStarted && (
    setHpChangeValues(prev => ({ ...prev, [p.id]: e.target.value }))} className="w-16 p-1 text-sm bg-stone-700 border border-stone-600 rounded-md text-white focus:ring-amber-600 focus:border-amber-600" aria-label={`HP change for ${p.name}`} /> {!isDead && ( )}
    )}
  • ); })}
{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` }

)} {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 }); // Restore character writeback (end_encounter sync) const cw = undoTarget.undo?.characterWriteback; const wbId = encounter?.campaignId || campaignId; if (cw && wbId) { try { const campDoc = await storage.getDoc(getPath.campaign(wbId)); if (campDoc && Array.isArray(campDoc.players)) { const restored = campDoc.players.map(p => { const old = cw.find(c => c.id === p.id); return old ? { ...p, defaultMaxHp: old.defaultMaxHp, defaultAc: old.defaultAc, defaultCurrentHp: old.defaultCurrentHp } : p; }); await storage.updateDoc(getPath.campaign(wbId), { players: restored }); } } catch (e) { console.error('characterWriteback restore failed:', e); } } } } 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)); // Only claim player display if slot is empty or already showing this encounter. // Don't steal display from another live encounter. const displayEmpty = !activeDisplayData || !activeDisplayData.activeEncounterId; const displayIsOurs = activeDisplayData && activeDisplayData.activeCampaignId === campaignId && activeDisplayData.activeEncounterId === encounter.id; if (displayEmpty || displayIsOurs) { 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 { const ctx = { ...buildCtx(encounterPath), campaignId }; if (campaignId) { try { ctx.campaign = await storage.getDoc(getPath.campaign(campaignId)); } catch {} } await endEncounter(encounter, ctx); // Only clear display if THIS encounter is the one showing. const displayIsThis = activeDisplayData && activeDisplayData.activeCampaignId === campaignId && activeDisplayData.activeEncounterId === encounter.id; if (displayIsThis) { 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 */}
{/* 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, encounterStartedRef, encounterActiveRef }) { const { showToast } = useUIFeedback(); const { data: encountersData, isLoading: isLoadingEncounters } = useFirestoreCollection( campaignId ? getPath.encounters(campaignId) : null ); const { data: activeDisplayInfo } = useFirestoreDocument(getPath.activeDisplay()); const { data: campaignDoc } = useFirestoreDocument(campaignId ? getPath.campaign(campaignId) : null); const [encounters, setEncounters] = useState([]); const [selectedEncounterId, setSelectedEncounterId] = useState(() => { try { return localStorage.getItem(`ttrpg.selectedEncounter.${campaignId}`) || null; } catch { return null; } }); const [showCreateModal, setShowCreateModal] = useState(false); const [encounterFullscreen, setEncounterFullscreen] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [itemToDelete, setItemToDelete] = useState(null); const [draggedEncounterId, setDraggedEncounterId] = useState(null); const selectedEncounterIdRef = useRef(selectedEncounterId); // Restore scoped encounter selection when campaign changes. useEffect(() => { if (!campaignId) { setSelectedEncounterId(null); return; } try { const saved = localStorage.getItem(`ttrpg.selectedEncounter.${campaignId}`) || null; setSelectedEncounterId(saved); } catch { setSelectedEncounterId(null); } }, [campaignId]); useEffect(() => { if (encountersData) setEncounters(encountersData); }, [encountersData]); // Sort by order field (fallback createdAt). Drag reorder writes order. const sortedEncounters = [...(encounters || [])].sort((a, b) => { const ao = a.order ?? null, bo = b.order ?? null; if (ao !== null && bo !== null) return ao - bo; if (ao !== null) return -1; if (bo !== null) return 1; return (a.createdAt || '').localeCompare(b.createdAt || ''); }); const handleEncounterDragStart = (e, id) => { setDraggedEncounterId(id); e.dataTransfer.effectAllowed = 'move'; }; const handleEncounterDragOver = (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }; const handleEncounterDrop = async (e, targetId) => { e.preventDefault(); if (!db || !draggedEncounterId || draggedEncounterId === targetId) { setDraggedEncounterId(null); return; } const reordered = [...sortedEncounters]; const fromIdx = reordered.findIndex(x => x.id === draggedEncounterId); const toIdx = reordered.findIndex(x => x.id === targetId); if (fromIdx === -1 || toIdx === -1) { setDraggedEncounterId(null); return; } const [moved] = reordered.splice(fromIdx, 1); reordered.splice(toIdx, 0, moved); // assign order = index, batch update const ops = reordered.map((enc, i) => ({ type: 'update', path: `${getPath.encounters(campaignId)}/${enc.id}`, data: { order: i }, })); try { await storage.batchWrite(ops); } catch (err) {} setDraggedEncounterId(null); }; useEffect(() => { selectedEncounterIdRef.current = selectedEncounterId; try { if (selectedEncounterId) localStorage.setItem(`ttrpg.selectedEncounter.${campaignId}`, selectedEncounterId); else localStorage.removeItem(`ttrpg.selectedEncounter.${campaignId}`); } catch {} }, [selectedEncounterId, campaignId]); 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, ruleset = '5e') => { if (!db || !name.trim() || !campaignId) return; const newEncounterId = generateId(); try { await storage.setDoc(`${getPath.encounters(campaignId)}/${newEncounterId}`, { name: name.trim(), campaignId, createdAt: new Date().toISOString(), participants: [], round: 0, currentTurnParticipantId: null, isStarted: false, isPaused: false, ruleset: ruleset || '5e', order: (encounters || []).reduce((max, e) => Math.max(max, e.order ?? -1), -1) + 1, }); 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); useEffect(() => { if (encounterStartedRef) { encounterStartedRef.current = !!(selectedEncounter && selectedEncounter.isStarted && !selectedEncounter.isPaused); } if (encounterActiveRef) { encounterActiveRef.current = !!(selectedEncounter && selectedEncounter.isStarted); } }, [selectedEncounter, encounterStartedRef, encounterActiveRef]); if (isLoadingEncounters && campaignId) { return

Loading encounters...

; } return ( <>

Encounters

{(!encounters || encounters.length === 0) && (

No encounters yet.

)}
{sortedEncounters.map(encounter => { const isLive = activeDisplayInfo && activeDisplayInfo.activeCampaignId === campaignId && activeDisplayInfo.activeEncounterId === encounter.id; return (
handleEncounterDragStart(e, encounter.id)} onDragOver={handleEncounterDragOver} onDrop={(e) => handleEncounterDrop(e, encounter.id)} onDragEnd={() => setDraggedEncounterId(null)} className={`p-3 rounded-md shadow transition-all ${selectedEncounterId === encounter.id ? 'bg-amber-900 ring-2 ring-amber-500' : 'bg-stone-800 hover:bg-stone-700'} ${isLive ? 'ring-2 ring-green-500 shadow-md shadow-green-500/30' : ''} ${encounter.isStarted && !encounter.endedAt ? 'ring-2 ring-red-500' : ''} ${draggedEncounterId === encounter.id ? 'opacity-50 ring-2 ring-yellow-400' : ''} cursor-grab`} >
setSelectedEncounterId(encounter.id)} className="cursor-pointer flex-grow">

{encounter.name} {encounter.ruleset === 'generic' ? 'GEN' : '5e'}{encounter.isStarted && !encounter.endedAt && ⚔ IN PROGRESS}

{encounter.createdAt && `${new Date(encounter.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false })} · `}Participants: {encounter.participants?.length || 0}

{(encounter.startedAt || encounter.endedAt) && (

{encounter.startedAt && `⚔ Started: ${new Date(encounter.startedAt).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false })}`}{encounter.startedAt && encounter.endedAt && ' · '}{encounter.endedAt && `Ended: ${new Date(encounter.endedAt).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false })}`}

)} {isLive && ( LIVE ON PLAYER DISPLAY )}
); })}
{showCreateModal && ( setShowCreateModal(false)} title="Create New Encounter"> setShowCreateModal(false)} defaultRuleset={campaignDoc?.ruleset || '5e'} /> )} {selectedEncounter && (

Managing Encounter: {selectedEncounter.name}

{/* Combat Controls - always left */}
{/* 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 [isFullscreen, setIsFullscreen] = useState(false); const [wakeLockEnabled, setWakeLockEnabled] = useState(() => { try { return localStorage.getItem('ttrpg.wakeLock') === 'true'; } catch { return false; } }); const wakeLockRef = useRef(null); const toggleFullscreen = () => { if (!document.fullscreenElement) { document.documentElement.requestFullscreen(); } else { document.exitFullscreen(); } }; useEffect(() => { const onFsChange = () => setIsFullscreen(!!document.fullscreenElement); document.addEventListener('fullscreenchange', onFsChange); return () => document.removeEventListener('fullscreenchange', onFsChange); }, []); 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); showToast('Prevent Sleep failed. Requires HTTPS or Chrome flag: chrome://flags/#unsafely-treat-insecure-origin-as-secure'); } }; acquire(); const onVisChange = () => { if (document.visibilityState === 'visible') acquire(); }; const onFsChange = () => { if (document.fullscreenElement) acquire(); }; document.addEventListener('visibilitychange', onVisChange); document.addEventListener('fullscreenchange', onFsChange); return () => { document.removeEventListener('visibilitychange', onVisChange); document.removeEventListener('fullscreenchange', onFsChange); wakeLockRef.current?.release(); wakeLockRef.current = null; }; }, [wakeLockEnabled, showToast]); const [campaignsWithDetails, setCampaignsWithDetails] = useState([]); const [draggedCampaignId, setDraggedCampaignId] = useState(null); const [selectedCampaignId, setSelectedCampaignId] = useState(() => { try { return localStorage.getItem('ttrpg.selectedCampaign') || null; } catch { return null; } }); const encounterStartedRef = useRef(false); const encounterActiveRef = useRef(false); const manualSelectRef = useRef(false); const prevDisplayCampaignRef = useRef(null); const scrollRestoredRef = useRef(false); // Save scroll on unload + visibility change + interval. useEffect(() => { const onSave = () => { try { localStorage.setItem(`ttrpg.scrollY.${selectedCampaignId}`, String(window.scrollY)); } catch {} }; window.addEventListener('beforeunload', onSave); window.addEventListener('pagehide', onSave); document.addEventListener('visibilitychange', onSave); const interval = setInterval(onSave, 2000); return () => { window.removeEventListener('beforeunload', onSave); window.removeEventListener('pagehide', onSave); document.removeEventListener('visibilitychange', onSave); clearInterval(interval); }; }, [selectedCampaignId]); // Restore scroll once data loaded. useEffect(() => { if (scrollRestoredRef.current) return; if (campaignsWithDetails.length === 0) return; try { const y = parseInt(localStorage.getItem(`ttrpg.scrollY.${selectedCampaignId}`) || '0', 10); if (y > 0) { scrollRestoredRef.current = true; setTimeout(() => window.scrollTo(0, y), 300); } } catch {} }, [campaignsWithDetails, selectedCampaignId]); // Persist selections across reload. useEffect(() => { try { if (selectedCampaignId) localStorage.setItem('ttrpg.selectedCampaign', selectedCampaignId); else localStorage.removeItem('ttrpg.selectedCampaign'); } catch {} }, [selectedCampaignId]); // External display change (replay/other DM) = clear manual override, allow follow. useEffect(() => { const cur = initialActiveInfo?.activeCampaignId || null; if (cur !== prevDisplayCampaignRef.current) { manualSelectRef.current = false; prevDisplayCampaignRef.current = cur; } }, [initialActiveInfo]); 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 }; }) ); // sort by order field (fallback createdAt) detailedCampaigns.sort((a, b) => { const ao = a.order ?? null, bo = b.order ?? null; if (ao !== null && bo !== null) return ao - bo; if (ao !== null) return -1; if (bo !== null) return 1; 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 ao = a.order ?? null, bo = b.order ?? null; if (ao !== null && bo !== null) return ao - bo; if (ao !== null) return -1; if (bo !== null) return 1; 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(() => { // Skip follow only if user manually selected AND display hasn't changed. // (external display change clears manualSelectRef via prevDisplay effect) if (manualSelectRef.current && selectedCampaignId !== initialActiveInfo?.activeCampaignId) return; if ( initialActiveInfo && initialActiveInfo.activeCampaignId && campaignsWithDetails.length > 0 && !encounterStartedRef.current && !encounterActiveRef.current ) { 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, ruleset = '5e') => { 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: [], ruleset: ruleset || '5e', order: (campaignsWithDetails || []).reduce((max, c) => Math.max(max, c.order ?? -1), -1) + 1, }); 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-grab 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'} ${draggedCampaignId === campaign.id ? 'opacity-50 ring-2 ring-yellow-400' : ''}`; return (
{ setDraggedCampaignId(campaign.id); e.dataTransfer.effectAllowed = 'move'; }} onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }} onDrop={async (e) => { e.preventDefault(); if (!db || !draggedCampaignId || draggedCampaignId === campaign.id) { setDraggedCampaignId(null); return; } const reordered = [...campaignsWithDetails]; const fromIdx = reordered.findIndex(c => c.id === draggedCampaignId); const toIdx = reordered.findIndex(c => c.id === campaign.id); if (fromIdx === -1 || toIdx === -1) { setDraggedCampaignId(null); return; } const [moved] = reordered.splice(fromIdx, 1); reordered.splice(toIdx, 0, moved); const ops = reordered.map((c, i) => ({ type: 'update', path: getPath.campaign(c.id), data: { order: i } })); try { await storage.batchWrite(ops); } catch (err) {} setDraggedCampaignId(null); }} onDragEnd={() => setDraggedCampaignId(null)} onClick={() => { if (encounterStartedRef.current) { showToast('End or pause active encounter before switching campaigns.'); return; } manualSelectRef.current = true; 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 })}
)}
{campaign.ruleset === 'generic' ? 'GEN' : '5e'}
); })}
{isDevToolsEnabled() && campaignsWithDetails.length > 0 && (
)} )}
{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.`} /> 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(() => { try { return localStorage.getItem('ttrpg.wakeLock') === 'true'; } catch { return 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); // Re-acquire on fullscreen change (Android discards wakeLock on screen off) const onFsChange = () => { if (document.fullscreenElement) acquire(); }; document.addEventListener('fullscreenchange', onFsChange); return () => { document.removeEventListener('visibilitychange', onVisChange); document.removeEventListener('fullscreenchange', onFsChange); 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 isGeneric = (activeEncounterData.ruleset || '5e') === 'generic'; const status = p.status || (isGeneric ? (p.currentHp <= 0 ? 'down' : 'conscious') : (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 === 'down' && (Down)} {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); const isDisplay = queryParams.get('playerView') === 'true' || window.location.pathname === '/display'; if (isDisplay) { setIsPlayerViewOnlyMode(true); // swap manifest so Android home-screen installs launch /display const link = document.querySelector('link[rel="manifest"]'); if (link) link.href = `${process.env.PUBLIC_URL || ''}/display-manifest.json`; } 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;