import React, { useState, useEffect, useRef, useMemo } from 'react';
import { initializeApp } from './storage';
import { getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken } from './storage';
import { getFirestore, doc, setDoc, addDoc, collection, onSnapshot, updateDoc, deleteDoc, query, orderBy, limit, writeBatch, getStorage, getStorageMode } from './storage';
import {
PlusCircle, Users, Swords, Trash2, Eye, Edit3, Save, XCircle, ChevronsUpDown,
UserCheck, UserX, HeartCrack, HeartPulse, Zap, EyeOff, ExternalLink, AlertTriangle,
Play as PlayIcon, Pause as PauseIcon, SkipForward as SkipForwardIcon,
StopCircle as StopCircleIcon, Users2, Dices, ChevronUp, ChevronDown, ScrollText,
Maximize2, Minimize2, Moon, Coffee
} from 'lucide-react';
// Custom CSS for death animation (player view only)
const deathAnimationStyles = `
@keyframes death-dissolve {
0% {
opacity: 1;
transform: scale(1) translateY(0);
}
50% {
opacity: 0.5;
transform: scale(0.95) translateY(-5px);
filter: blur(2px);
}
100% {
opacity: 0;
transform: scale(0.8) translateY(-10px);
filter: blur(4px);
}
}
.animate-death-dissolve {
animation: death-dissolve 2s ease-in-out forwards;
}
`;
// Inject styles
if (typeof document !== 'undefined') {
const styleElement = document.createElement('style');
styleElement.innerHTML = deathAnimationStyles;
document.head.appendChild(styleElement);
}
// ============================================================================
// CONSTANTS
// ============================================================================
const APP_VERSION = 'v0.3';
const DEFAULT_MAX_HP = 10;
const DEFAULT_INIT_MOD = 0;
const MONSTER_DEFAULT_INIT_MOD = 2;
const ROLL_DISPLAY_DURATION = 5000;
const CONDITIONS = [
{ id: 'alchemist_fire', label: 'Alchemist Fire', emoji: 'π₯' },
{ id: 'bardic_inspiration', label: 'Bardic Inspiration', emoji: 'π΅' },
{ id: 'blinded', label: 'Blinded', emoji: 'π' },
{ id: 'charmed', label: 'Charmed', emoji: 'π' },
{ id: 'deafened', label: 'Deafened', emoji: 'π' },
{ id: 'exhaustion', label: 'Exhaustion', emoji: 'π΄' },
{ id: 'frightened', label: 'Frightened', emoji: 'π±' },
{ id: 'grappled', label: 'Grappled', emoji: 'π€' },
{ id: 'grazed', label: 'Grazed', emoji: 'π©Ή' },
{ id: 'incapacitated', label: 'Incapacitated', emoji: 'π«' },
{ id: 'invisible', label: 'Invisible', emoji: 'π»' },
{ id: 'paralyzed', label: 'Paralyzed', emoji: 'β‘' },
{ id: 'petrified', label: 'Petrified', emoji: 'πΏ' },
{ id: 'poisoned', label: 'Poisoned', emoji: 'π€’' },
{ id: 'prone', label: 'Prone', emoji: 'β¬οΈ' },
{ id: 'restrained', label: 'Restrained', emoji: 'πΈοΈ' },
{ id: 'sapped', label: 'Sapped', emoji: 'π¨' },
{ id: 'shield', label: 'Shield', emoji: 'π‘οΈ' },
{ id: 'slowed', label: 'Slowed', emoji: 'π' },
{ id: 'stunned', label: 'Stunned', emoji: 'π₯' },
{ id: 'unconscious', label: 'Unconscious', emoji: 'π€' },
{ id: 'vexed', label: 'Vexed', emoji: 'π―' },
];
// ============================================================================
// FIREBASE CONFIGURATION
// ============================================================================
const firebaseConfig = {
apiKey: process.env.REACT_APP_FIREBASE_API_KEY,
authDomain: process.env.REACT_APP_FIREBASE_AUTH_DOMAIN,
projectId: process.env.REACT_APP_FIREBASE_PROJECT_ID,
storageBucket: process.env.REACT_APP_FIREBASE_STORAGE_BUCKET,
messagingSenderId: process.env.REACT_APP_FIREBASE_MESSAGING_SENDER_ID,
appId: process.env.REACT_APP_FIREBASE_APP_ID
};
const APP_ID = process.env.REACT_APP_TRACKER_APP_ID || 'ttrpg-initiative-tracker-default';
const PUBLIC_DATA_PATH = `artifacts/${APP_ID}/public/data`;
let app;
let db;
let auth;
let storage;
const STORAGE_MODE = getStorageMode();
// Initialize storage backend. firebase mode = real SDK init.
// ws/memory mode = mock auth, no firebase.
const initializeStorage = () => {
if (STORAGE_MODE === 'firebase') {
const requiredKeys = ['apiKey', 'authDomain', 'projectId', 'appId'];
const missingKeys = requiredKeys.filter(key => !firebaseConfig[key]);
if (missingKeys.length > 0) {
console.error(`CRITICAL: Missing Firebase config values: ${missingKeys.join(', ')}`);
return false;
}
try {
app = initializeApp(firebaseConfig);
db = getFirestore(app);
auth = getAuth(app);
storage = getStorage();
return true;
} catch (error) {
console.error("Error initializing Firebase:", error);
return false;
}
}
// ws / memory mode: stub auth so App's anon-sign-in path works.
// 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
// ============================================================================
const generateId = () => crypto.randomUUID();
const rollD20 = () => Math.floor(Math.random() * 20) + 1;
const formatInitMod = (mod) => {
if (mod === undefined || mod === null) return 'N/A';
return mod >= 0 ? `+${mod}` : `${mod}`;
};
const sortParticipantsByInitiative = (participants, originalOrder) => {
return [...participants].sort((a, b) => {
if (a.initiative === b.initiative) {
const indexA = originalOrder.findIndex(p => p.id === a.id);
const indexB = originalOrder.findIndex(p => p.id === b.id);
return indexA - indexB;
}
return b.initiative - a.initiative;
});
};
const LOG_QUERY = [orderBy('timestamp', 'desc'), limit(500)];
const logAction = async (message, context = {}, undoData = null) => {
if (!db) return;
try {
const entry = { timestamp: Date.now(), message, ...context };
if (undoData) entry.undo = undoData;
await storage.addDoc(getPath.logs(), entry);
} catch (err) {
console.error('Error writing log:', err);
}
};
// Returns turnOrderIds/currentTurnParticipantId updates when a participant leaves active combat.
const computeTurnOrderAfterRemoval = (encounter, removedId, updatedParticipants) => {
if (!encounter.isStarted) return {};
const currentIds = encounter.turnOrderIds || [];
const newIds = currentIds.filter(id => id !== removedId);
const updates = { turnOrderIds: newIds };
if (encounter.currentTurnParticipantId === removedId) {
const removedPos = currentIds.indexOf(removedId);
const candidates = [...currentIds.slice(removedPos + 1), ...currentIds.slice(0, removedPos)];
const nextId = candidates.find(id => updatedParticipants.find(p => p.id === id && p.isActive)) ?? null;
updates.currentTurnParticipantId = nextId;
}
return updates;
};
// Returns turnOrderIds update when a participant re-enters active combat mid-encounter.
const computeTurnOrderAfterAddition = (encounter, addedId) => {
if (!encounter.isStarted) return {};
const currentIds = encounter.turnOrderIds || [];
if (currentIds.includes(addedId)) return {};
return { turnOrderIds: [...currentIds, addedId] };
};
// ============================================================================
// CUSTOM HOOKS
// ============================================================================
function useFirestoreDocument(docPath) {
const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
if (!docPath) {
setData(null);
setIsLoading(false);
setError("Document path not provided.");
return;
}
setIsLoading(true);
setError(null);
const storage = getStorage();
const unsubscribe = storage.subscribeDoc(docPath, (doc) => {
setData(doc);
setIsLoading(false);
});
return () => { if (typeof unsubscribe === 'function') unsubscribe(); };
}, [docPath]);
return { data, isLoading, error };
}
function useFirestoreCollection(collectionPath, queryConstraints = []) {
const [data, setData] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const queryString = useMemo(() => JSON.stringify(queryConstraints), [queryConstraints]);
useEffect(() => {
if (!collectionPath) {
setData([]);
setIsLoading(false);
setError("Collection path not provided.");
return;
}
setIsLoading(true);
setError(null);
const storage = getStorage();
const unsubscribe = storage.subscribeCollection(collectionPath, (items) => {
setData(items);
setIsLoading(false);
});
return () => { if (typeof unsubscribe === 'function') unsubscribe(); };
// queryString, not array ref
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [collectionPath, queryString]);
return { data, isLoading, error };
}
// ============================================================================
// REUSABLE COMPONENTS
// ============================================================================
function Modal({ onClose, title, children }) {
useEffect(() => {
const handleEsc = (event) => {
if (event.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleEsc);
return () => window.removeEventListener('keydown', handleEsc);
}, [onClose]);
return (
);
}
function ConfirmationModal({ isOpen, onClose, onConfirm, title, message }) {
if (!isOpen) return null;
return (
{title || "Confirm Action"}
{message || "Are you sure you want to proceed?"}
Cancel
Confirm
);
}
function LoadingSpinner({ message = "Loading..." }) {
return (
);
}
function ErrorDisplay({ message, critical = false }) {
return (
{critical ? 'Configuration Error' : 'Error'}
{message}
);
}
// ============================================================================
// FORM COMPONENTS
// ============================================================================
function CreateCampaignForm({ onCreate, onCancel }) {
const [name, setName] = useState('');
const [backgroundUrl, setBackgroundUrl] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
if (name.trim()) {
onCreate(name, backgroundUrl);
}
};
return (
);
}
function CreateEncounterForm({ onCreate, onCancel }) {
const [name, setName] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
if (name.trim()) {
onCreate(name);
}
};
return (
);
}
function EditParticipantModal({ participant, onClose, onSave }) {
const [name, setName] = useState(participant.name);
const [initiative, setInitiative] = useState(participant.initiative);
const [currentHp, setCurrentHp] = useState(participant.currentHp);
const [maxHp, setMaxHp] = useState(participant.maxHp);
const [isNpc, setIsNpc] = useState(participant.type === 'monster' ? (participant.isNpc || false) : false);
const handleSubmit = (e) => {
e.preventDefault();
onSave({
name: name.trim(),
initiative: parseInt(initiative, 10),
currentHp: parseInt(currentHp, 10),
maxHp: parseInt(maxHp, 10),
isNpc: participant.type === 'monster' ? isNpc : false,
});
};
return (
);
}
// ============================================================================
// CHARACTER MANAGER COMPONENT
// ============================================================================
function CharacterManager({ campaignId, campaignCharacters }) {
const [characterName, setCharacterName] = useState('');
const [defaultMaxHp, setDefaultMaxHp] = useState(DEFAULT_MAX_HP);
const [defaultInitMod, setDefaultInitMod] = useState(DEFAULT_INIT_MOD);
const [editingCharacter, setEditingCharacter] = useState(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [itemToDelete, setItemToDelete] = useState(null);
const [isOpen, setIsOpen] = useState(true);
const handleAddCharacter = async () => {
if (!db || !characterName.trim() || !campaignId) return;
const hp = parseInt(defaultMaxHp, 10);
const initMod = parseInt(defaultInitMod, 10);
if (isNaN(hp) || hp <= 0) {
alert("Please enter a valid positive number for Default Max HP.");
return;
}
if (isNaN(initMod)) {
alert("Please enter a valid number for Default Initiative Modifier.");
return;
}
const newCharacter = {
id: generateId(),
name: characterName.trim(),
defaultMaxHp: hp,
defaultInitMod: initMod
};
try {
await storage.updateDoc(getPath.campaign(campaignId), {
players: [...campaignCharacters, newCharacter]
});
setCharacterName('');
setDefaultMaxHp(DEFAULT_MAX_HP);
setDefaultInitMod(DEFAULT_INIT_MOD);
} catch (err) {
console.error("Error adding character:", err);
alert("Failed to add character. Please try again.");
}
};
const handleUpdateCharacter = async (characterId, newName, newDefaultMaxHp, newDefaultInitMod) => {
if (!db || !newName.trim() || !campaignId) return;
const hp = parseInt(newDefaultMaxHp, 10);
const initMod = parseInt(newDefaultInitMod, 10);
if (isNaN(hp) || hp <= 0) {
alert("Please enter a valid positive number for Default Max HP.");
setEditingCharacter(null);
return;
}
if (isNaN(initMod)) {
alert("Please enter a valid number for Default Initiative Modifier.");
setEditingCharacter(null);
return;
}
const updatedCharacters = campaignCharacters.map(c =>
c.id === characterId
? { ...c, name: newName.trim(), defaultMaxHp: hp, defaultInitMod: initMod }
: c
);
try {
await storage.updateDoc(getPath.campaign(campaignId), { players: updatedCharacters });
setEditingCharacter(null);
} catch (err) {
console.error("Error updating character:", err);
alert("Failed to update character. Please try again.");
}
};
const requestDeleteCharacter = (characterId, charName) => {
setItemToDelete({ id: characterId, name: charName });
setShowDeleteConfirm(true);
};
const confirmDeleteCharacter = async () => {
if (!db || !itemToDelete) return;
const updatedCharacters = campaignCharacters.filter(c => c.id !== itemToDelete.id);
try {
await storage.updateDoc(getPath.campaign(campaignId), { players: updatedCharacters });
} catch (err) {
console.error("Error deleting character:", err);
alert("Failed to delete character. Please try again.");
}
setShowDeleteConfirm(false);
setItemToDelete(null);
};
return (
<>
Campaign Characters
setIsOpen(!isOpen)}
className="p-1 text-stone-400 hover:text-stone-200"
aria-label={isOpen ? "Collapse" : "Expand"}
>
{isOpen ? : }
{isOpen && (
<>
{campaignCharacters.length === 0 && (
No characters added yet.
)}
{campaignCharacters.map(character => (
{editingCharacter && editingCharacter.id === character.id ? (
) : (
<>
{character.name}{' '}
(HP: {character.defaultMaxHp || 'N/A'}, Init Mod: {formatInitMod(character.defaultInitMod)})
setEditingCharacter({
id: character.id,
name: character.name,
defaultMaxHp: character.defaultMaxHp || DEFAULT_MAX_HP,
defaultInitMod: character.defaultInitMod || DEFAULT_INIT_MOD
})}
className="p-1 rounded transition-colors text-yellow-400 hover:text-yellow-300 bg-stone-700 hover:bg-stone-600"
aria-label="Edit character"
>
requestDeleteCharacter(character.id, character.name)}
className="p-1 rounded transition-colors text-red-400 hover:text-red-300 bg-stone-700 hover:bg-stone-600"
aria-label="Delete character"
>
>
)}
))}
>
)}
setShowDeleteConfirm(false)}
onConfirm={confirmDeleteCharacter}
title="Delete Character?"
message={`Are you sure you want to remove "${itemToDelete?.name}" from this campaign?`}
/>
>
);
}
// ============================================================================
// PARTICIPANT MANAGER COMPONENT
// ============================================================================
function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
const [participantName, setParticipantName] = useState('');
const [participantType, setParticipantType] = useState('monster');
const [selectedCharacterId, setSelectedCharacterId] = useState('');
const [monsterInitMod, setMonsterInitMod] = useState(MONSTER_DEFAULT_INIT_MOD);
const [maxHp, setMaxHp] = useState(DEFAULT_MAX_HP);
const [isNpc, setIsNpc] = useState(false);
const [editingParticipant, setEditingParticipant] = useState(null);
const [hpChangeValues, setHpChangeValues] = useState({});
const [draggedItemId, setDraggedItemId] = useState(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [itemToDelete, setItemToDelete] = useState(null);
const [lastRollDetails, setLastRollDetails] = useState(null);
const [openConditionsId, setOpenConditionsId] = useState(null);
const participants = encounter.participants || [];
useEffect(() => {
if (participantType === 'character' && selectedCharacterId) {
const selectedChar = campaignCharacters.find(c => c.id === selectedCharacterId);
if (selectedChar && selectedChar.defaultMaxHp) {
setMaxHp(selectedChar.defaultMaxHp);
} else {
setMaxHp(DEFAULT_MAX_HP);
}
setIsNpc(false);
} else if (participantType === 'monster') {
setMaxHp(DEFAULT_MAX_HP);
setMonsterInitMod(MONSTER_DEFAULT_INIT_MOD);
}
}, [selectedCharacterId, participantType, campaignCharacters]);
const handleAddParticipant = async () => {
if (!db) return;
if (participantType === 'monster' && !participantName.trim()) return;
if (participantType === 'character' && !selectedCharacterId) return;
let nameToAdd = participantName.trim();
const initiativeRoll = rollD20();
let modifier = 0;
let currentMaxHp = parseInt(maxHp, 10) || DEFAULT_MAX_HP;
let participantIsNpc = false;
if (participantType === 'character') {
const character = campaignCharacters.find(c => c.id === selectedCharacterId);
if (!character) {
console.error("Selected character not found");
return;
}
if (participants.some(p => p.type === 'character' && p.originalCharacterId === selectedCharacterId)) {
alert(`${character.name} is already in this encounter.`);
return;
}
nameToAdd = character.name;
currentMaxHp = character.defaultMaxHp || currentMaxHp;
modifier = character.defaultInitMod || 0;
} else {
modifier = parseInt(monsterInitMod, 10) || 0;
participantIsNpc = isNpc;
}
const finalInitiative = initiativeRoll + modifier;
const newParticipant = {
id: generateId(),
name: nameToAdd,
type: participantType,
originalCharacterId: participantType === 'character' ? selectedCharacterId : null,
initiative: finalInitiative,
maxHp: currentMaxHp,
currentHp: currentMaxHp,
isNpc: participantType === 'monster' ? participantIsNpc : false,
conditions: [],
isActive: true,
deathSaves: 0, // Track failed death saves (0-3)
isDying: false, // For death animation on player display
};
try {
await storage.updateDoc(encounterPath, {
participants: [...participants, newParticipant]
});
logAction(`${nameToAdd} added to encounter (Initiative: ${finalInitiative})`, { encounterName: encounter.name }, {
encounterPath,
updates: { participants: [...participants] },
});
setLastRollDetails({
name: nameToAdd,
roll: initiativeRoll,
mod: modifier,
total: finalInitiative,
type: participantIsNpc ? 'NPC' : participantType
});
setTimeout(() => setLastRollDetails(null), ROLL_DISPLAY_DURATION);
// Reset form
setParticipantName('');
setMaxHp(DEFAULT_MAX_HP);
setSelectedCharacterId('');
setMonsterInitMod(MONSTER_DEFAULT_INIT_MOD);
setIsNpc(false);
} catch (err) {
console.error("Error adding participant:", err);
alert("Failed to add participant. Please try again.");
}
};
const handleAddAllCampaignCharacters = async () => {
if (!db || !campaignCharacters || campaignCharacters.length === 0) return;
const existingCharacterIds = participants
.filter(p => p.type === 'character' && p.originalCharacterId)
.map(p => p.originalCharacterId);
const newParticipants = campaignCharacters
.filter(char => !existingCharacterIds.includes(char.id))
.map(char => {
const initiativeRoll = rollD20();
const modifier = char.defaultInitMod || 0;
const finalInitiative = initiativeRoll + modifier;
return {
id: generateId(),
name: char.name,
type: 'character',
originalCharacterId: char.id,
initiative: finalInitiative,
maxHp: char.defaultMaxHp || DEFAULT_MAX_HP,
currentHp: char.defaultMaxHp || DEFAULT_MAX_HP,
conditions: [],
isActive: true,
isNpc: false,
deathSaves: 0,
isDying: false,
};
});
if (newParticipants.length === 0) {
alert("All campaign characters are already in this encounter.");
return;
}
try {
await storage.updateDoc(encounterPath, {
participants: [...participants, ...newParticipants]
});
console.log(`Added ${newParticipants.length} characters to the encounter.`);
} catch (err) {
console.error("Error adding all campaign characters:", err);
alert("Failed to add all characters. Please try again.");
}
};
const handleUpdateParticipant = async (updatedData) => {
if (!db || !editingParticipant) return;
const updatedParticipants = participants.map(p =>
p.id === editingParticipant.id ? { ...p, ...updatedData } : p
);
try {
await storage.updateDoc(encounterPath, { participants: updatedParticipants });
setEditingParticipant(null);
} catch (err) {
console.error("Error updating participant:", err);
alert("Failed to update participant. Please try again.");
}
};
const requestDeleteParticipant = (participantId, participantName) => {
setItemToDelete({ id: participantId, name: participantName });
setShowDeleteConfirm(true);
};
const confirmDeleteParticipant = async () => {
if (!db || !itemToDelete) return;
const updatedParticipants = participants.filter(p => p.id !== itemToDelete.id);
const deleteUndoData = {
encounterPath,
updates: {
participants: [...participants],
...(encounter.isStarted ? {
currentTurnParticipantId: encounter.currentTurnParticipantId,
turnOrderIds: [...(encounter.turnOrderIds || [])],
} : {}),
},
};
try {
await storage.updateDoc(encounterPath, {
participants: updatedParticipants,
...computeTurnOrderAfterRemoval(encounter, itemToDelete.id, updatedParticipants)
});
logAction(`${itemToDelete.name} removed from encounter`, { encounterName: encounter.name }, deleteUndoData);
} catch (err) {
console.error("Error deleting participant:", err);
alert("Failed to delete participant. Please try again.");
}
setShowDeleteConfirm(false);
setItemToDelete(null);
};
const toggleParticipantActive = async (participantId) => {
if (!db) return;
const participant = participants.find(p => p.id === participantId);
if (!participant) return;
const newIsActive = !participant.isActive;
const updatedParticipants = participants.map(p =>
p.id === participantId ? { ...p, isActive: newIsActive } : p
);
const turnUpdates = newIsActive
? computeTurnOrderAfterAddition(encounter, participantId)
: computeTurnOrderAfterRemoval(encounter, participantId, updatedParticipants);
try {
await storage.updateDoc(encounterPath, { participants: updatedParticipants, ...turnUpdates });
logAction(`${participant.name} ${newIsActive ? 'reactivated' : 'deactivated'}`, { encounterName: encounter.name }, {
encounterPath,
updates: {
participants: [...participants],
...(encounter.isStarted ? {
currentTurnParticipantId: encounter.currentTurnParticipantId,
turnOrderIds: [...(encounter.turnOrderIds || [])],
} : {}),
},
});
} catch (err) {
console.error("Error toggling active state:", err);
}
};
const applyHpChange = async (participantId, changeType) => {
if (!db) return;
const amountStr = hpChangeValues[participantId];
if (!amountStr || amountStr.trim() === '') return;
const amount = parseInt(amountStr, 10);
if (isNaN(amount) || amount === 0) {
setHpChangeValues(prev => ({ ...prev, [participantId]: '' }));
return;
}
const participant = participants.find(p => p.id === participantId);
if (!participant) return;
let newHp = participant.currentHp;
if (changeType === 'damage') {
newHp = Math.max(0, participant.currentHp - amount);
} else if (changeType === 'heal') {
newHp = Math.min(participant.maxHp, participant.currentHp + amount);
}
// Determine if participant died or was resurrected
const wasDead = participant.currentHp === 0;
const isDead = newHp === 0;
const wasResurrected = wasDead && newHp > 0;
const updatedParticipants = participants.map(p => {
if (p.id === participantId) {
const updates = { ...p, currentHp: newHp };
// Handle death - deactivate and start death saves
if (isDead && !wasDead) {
updates.isActive = false;
updates.deathSaves = p.deathSaves || 0;
updates.isDying = false;
}
// Handle resurrection - reactivate and reset death saves
if (wasResurrected) {
updates.isActive = true;
updates.deathSaves = 0;
updates.isDying = false;
}
return updates;
}
return p;
});
const turnUpdates = (isDead && !wasDead)
? computeTurnOrderAfterRemoval(encounter, participantId, updatedParticipants)
: wasResurrected
? computeTurnOrderAfterAddition(encounter, participantId)
: {};
const hpUndoData = {
encounterPath,
updates: {
participants: [...participants],
...((isDead && !wasDead) || wasResurrected ? {
currentTurnParticipantId: encounter.currentTurnParticipantId,
turnOrderIds: [...(encounter.turnOrderIds || [])],
} : {}),
},
};
try {
await storage.updateDoc(encounterPath, { participants: updatedParticipants, ...turnUpdates });
setHpChangeValues(prev => ({ ...prev, [participantId]: '' }));
const hpLine = `${participant.currentHp} β ${newHp} HP`;
const deathSuffix = (isDead && !wasDead) ? (participant.type === 'character' ? ' β Unconscious' : ' β Defeated') : '';
const resurSuffix = wasResurrected ? ' β Revived' : '';
if (changeType === 'damage') {
logAction(`${participant.name} took ${amount} damage (${hpLine})${deathSuffix}`, { encounterName: encounter.name }, hpUndoData);
} else {
logAction(`${participant.name} healed for ${amount} (${hpLine})${resurSuffix}`, { encounterName: encounter.name }, hpUndoData);
}
} catch (err) {
console.error("Error applying HP change:", err);
}
};
const handleDeathSaveChange = async (participantId, saveNumber) => {
if (!db) return;
const participant = participants.find(p => p.id === participantId);
if (!participant) return;
const currentSaves = participant.deathSaves || 0;
const newSaves = currentSaves === saveNumber ? saveNumber - 1 : saveNumber;
// If clicking the third death save, mark as dying (for player view animation)
if (newSaves === 3) {
const updatedParticipants = participants.map(p =>
p.id === participantId ? { ...p, deathSaves: newSaves, isDying: true } : p
);
try {
await storage.updateDoc(encounterPath, { participants: updatedParticipants });
// Wait for animation to complete on player display (2 seconds) then remove participant
setTimeout(async () => {
const finalParticipants = participants.filter(p => p.id !== participantId);
try {
await storage.updateDoc(encounterPath, { participants: finalParticipants });
} catch (err) {
console.error("Error removing dead participant:", err);
}
}, 2000);
} catch (err) {
console.error("Error marking participant as dying:", err);
}
} else {
// Normal death save update
const updatedParticipants = participants.map(p =>
p.id === participantId ? { ...p, deathSaves: newSaves } : p
);
try {
await storage.updateDoc(encounterPath, { participants: updatedParticipants });
} catch (err) {
console.error("Error updating death saves:", err);
}
}
};
const toggleCondition = async (participantId, conditionId) => {
if (!db) return;
const participant = participants.find(p => p.id === participantId);
if (!participant) return;
const wasActive = (participant.conditions || []).includes(conditionId);
const updatedParticipants = participants.map(p => {
if (p.id !== participantId) return p;
const current = p.conditions || [];
const next = wasActive
? current.filter(c => c !== conditionId)
: [...current, conditionId];
return { ...p, conditions: next };
});
try {
await storage.updateDoc(encounterPath, { participants: updatedParticipants });
const cond = CONDITIONS.find(c => c.id === conditionId);
const condLabel = cond ? `${cond.label} ${cond.emoji}` : conditionId;
logAction(`${participant.name} ${wasActive ? 'lost' : 'gained'} ${condLabel}`, { encounterName: encounter.name }, {
encounterPath,
updates: { participants: [...participants] },
});
} catch (err) {
console.error("Error updating conditions:", err);
}
};
const handleDragStart = (e, id) => {
setDraggedItemId(id);
e.dataTransfer.effectAllowed = 'move';
};
const handleDragOver = (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
};
const handleDrop = async (e, targetId) => {
e.preventDefault();
if (!db || draggedItemId === null || draggedItemId === targetId) {
setDraggedItemId(null);
return;
}
const currentParticipants = [...participants];
const draggedIndex = currentParticipants.findIndex(p => p.id === draggedItemId);
const targetIndex = currentParticipants.findIndex(p => p.id === targetId);
if (draggedIndex === -1 || targetIndex === -1) {
console.error("Dragged or target item not found.");
setDraggedItemId(null);
return;
}
const draggedItem = currentParticipants[draggedIndex];
const targetItem = currentParticipants[targetIndex];
if (draggedItem.initiative !== targetItem.initiative) {
console.log("Drag-drop only allowed for participants with same initiative.");
setDraggedItemId(null);
return;
}
const [removedItem] = currentParticipants.splice(draggedIndex, 1);
currentParticipants.splice(targetIndex, 0, removedItem);
try {
await storage.updateDoc(encounterPath, { participants: currentParticipants });
} catch (err) {
console.error("Error reordering participants:", err);
}
setDraggedItemId(null);
};
const sortedParticipants = sortParticipantsByInitiative(participants, participants);
const initiativeGroups = participants.reduce((acc, p) => {
acc[p.initiative] = (acc[p.initiative] || 0) + 1;
return acc;
}, {});
const tiedInitiatives = Object.keys(initiativeGroups)
.filter(init => initiativeGroups[init] > 1)
.map(Number);
return (
<>
Add Participants
Add All (Roll Init)
{/* 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.
)}
{lastRollDetails && (
{lastRollDetails.name} ({lastRollDetails.type === 'character' ? 'Character' : lastRollDetails.type === 'monster' ? 'Monster' : lastRollDetails.type})
: Rolled d20 ({lastRollDetails.roll}) {formatInitMod(lastRollDetails.mod)} = {lastRollDetails.total} Initiative
)}
{participants.length === 0 &&
No participants added yet.
}
{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 === 'monster' ? (p.isNpc ? 'NPC' : 'Monster') : 'Character';
let bgColor = p.type === 'character' ? 'bg-indigo-950' : (p.isNpc ? 'bg-stone-700' : 'bg-[#8e351c]');
if (isCurrentTurn && !encounter.isPaused) bgColor = 'bg-green-600';
const isDead = p.currentHp === 0;
return (
handleDragStart(e, p.id) : undefined}
onDragOver={isDraggable ? handleDragOver : undefined}
onDrop={isDraggable ? (e) => handleDrop(e, p.id) : undefined}
onDragEnd={() => setDraggedItemId(null)}
className={`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 && !isDead ? 'opacity-50' : ''} ${isDraggable ? 'cursor-grab' : ''} ${draggedItemId === p.id ? 'opacity-50 ring-2 ring-yellow-400' : ''}`}
>
{isDraggable && (
)}
{isDead && β οΈ }
{p.name} ({participantDisplayType})
{isCurrentTurn && !encounter.isPaused && (
CURRENT
)}
{isDead && {p.type === 'character' ? '(Unconscious)' : '(Dead)'} }
Init: {p.initiative} | HP: {p.currentHp}/{p.maxHp}
{/* Death Saves - only player characters make death saving throws */}
{isDead && encounter.isStarted && p.type === 'character' && (
Death Saves:
{[1, 2, 3].map(saveNum => (
handleDeathSaveChange(p.id, saveNum)}
className={`w-6 h-6 rounded border-2 transition-all ${(p.deathSaves || 0) >= saveNum ? 'bg-red-600 border-red-500' : 'bg-stone-800 border-stone-600 hover:border-red-400'} ${saveNum === 3 && (p.deathSaves || 0) === 3 ? 'animate-pulse' : ''}`}
title={`Death save ${saveNum}`}
>
{(p.deathSaves || 0) >= saveNum && β }
))}
)}
{/* Active condition badges */}
{(p.conditions || []).length > 0 && (
{(p.conditions || []).map(cId => {
const cond = CONDITIONS.find(c => c.id === cId);
return cond ? (
toggleCondition(p.id, cId)}
>
{cond.emoji} {cond.label}
) : null;
})}
)}
{/* Expandable conditions picker */}
{openConditionsId === p.id && (
Toggle Conditions
{CONDITIONS.map(cond => {
const active = (p.conditions || []).includes(cond.id);
return (
toggleCondition(p.id, cond.id)}
className={`px-2 py-1 rounded text-xs transition-colors ${active ? 'bg-purple-700 border border-purple-400 text-white' : 'bg-stone-700 border border-stone-500 text-stone-300 hover:bg-stone-600'}`}
title={cond.label}
>
{cond.emoji} {cond.label}
);
})}
)}
{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 && (
applyHpChange(p.id, 'damage')}
className="p-1 bg-red-500 hover:bg-red-600 text-white rounded-md text-xs"
title="Damage"
>
)}
applyHpChange(p.id, 'heal')}
className="p-1 bg-emerald-600 hover:bg-emerald-700 text-white rounded-md text-xs"
title={isDead ? "Heal / Revive" : "Heal"}
>
)}
{!isDead && (
toggleParticipantActive(p.id)}
className={`p-1 rounded transition-colors ${p.isActive ? 'text-yellow-400 hover:text-yellow-300' : 'text-stone-400 hover:text-stone-300'} bg-stone-700 hover:bg-stone-600`}
title={p.isActive ? "Mark Inactive" : "Mark Active"}
>
{p.isActive ? : }
)}
setOpenConditionsId(openConditionsId === p.id ? null : p.id)}
className={`p-1 rounded transition-colors bg-stone-700 hover:bg-stone-600 ${openConditionsId === p.id || (p.conditions || []).length > 0 ? 'text-purple-400 hover:text-purple-300' : 'text-stone-400 hover:text-stone-300'}`}
title="Conditions"
>
β¨
setEditingParticipant(p)}
className="p-1 rounded transition-colors text-yellow-400 hover:text-yellow-300 bg-stone-700 hover:bg-stone-600"
title="Edit"
>
requestDeleteParticipant(p.id, p.name)}
className="p-1 rounded transition-colors text-red-400 hover:text-red-300 bg-stone-700 hover:bg-stone-600"
title="Remove"
>
);
})}
{editingParticipant && (
setEditingParticipant(null)}
onSave={handleUpdateParticipant}
/>
)}
setShowDeleteConfirm(false)}
onConfirm={confirmDeleteParticipant}
title="Delete Participant?"
message={`Are you sure you want to remove "${itemToDelete?.name}" from this encounter?`}
/>
>
);
}
// ============================================================================
// INITIATIVE CONTROLS COMPONENT
// ============================================================================
function InitiativeControls({ campaignId, encounter, encounterPath }) {
const [showEndConfirm, setShowEndConfirm] = useState(false);
const { data: activeDisplayData } = useFirestoreDocument(getPath.activeDisplay());
const hidePlayerHp = activeDisplayData?.hidePlayerHp ?? true;
const handleToggleHidePlayerHp = async () => {
if (!db) return;
try {
await storage.setDoc(getPath.activeDisplay(), { hidePlayerHp: !hidePlayerHp }, { merge: true });
} catch (err) {
console.error("Error toggling hidePlayerHp:", err);
}
};
const handleStartEncounter = async () => {
if (!db || !encounter.participants || encounter.participants.length === 0) {
alert("Add participants first.");
return;
}
const activeParticipants = encounter.participants.filter(p => p.isActive);
if (activeParticipants.length === 0) {
alert("No active participants.");
return;
}
const sortedParticipants = sortParticipantsByInitiative(activeParticipants, encounter.participants);
try {
await storage.updateDoc(encounterPath, {
isStarted: true,
isPaused: false,
round: 1,
currentTurnParticipantId: sortedParticipants[0].id,
turnOrderIds: sortedParticipants.map(p => p.id)
});
await storage.setDoc(getPath.activeDisplay(), {
activeCampaignId: campaignId,
activeEncounterId: encounter.id
}, { merge: true });
logAction(`Combat started: "${encounter.name}" β ${sortedParticipants[0].name}'s turn (Round 1)`, { encounterName: encounter.name }, {
encounterPath,
updates: {
isStarted: encounter.isStarted ?? false,
isPaused: encounter.isPaused ?? false,
round: encounter.round ?? 0,
currentTurnParticipantId: encounter.currentTurnParticipantId ?? null,
turnOrderIds: [...(encounter.turnOrderIds || [])],
},
});
console.log("Encounter started and set as active display.");
} catch (err) {
console.error("Error starting encounter:", err);
alert("Failed to start encounter. Please try again.");
}
};
const handleTogglePause = async () => {
if (!db || !encounter || !encounter.isStarted) return;
const newPausedState = !encounter.isPaused;
let newTurnOrderIds = encounter.turnOrderIds;
if (!newPausedState && encounter.isPaused) {
const activeParticipants = encounter.participants.filter(p => p.isActive);
const sortedParticipants = sortParticipantsByInitiative(activeParticipants, encounter.participants);
newTurnOrderIds = sortedParticipants.map(p => p.id);
}
try {
await storage.updateDoc(encounterPath, {
isPaused: newPausedState,
turnOrderIds: newTurnOrderIds
});
logAction(`Combat ${newPausedState ? 'paused' : 'resumed'}: "${encounter.name}"`, { encounterName: encounter.name }, {
encounterPath,
updates: {
isPaused: encounter.isPaused ?? false,
turnOrderIds: [...(encounter.turnOrderIds || [])],
},
});
} catch (err) {
console.error("Error toggling pause state:", err);
}
};
const handleNextTurn = async () => {
if (!db || !encounter.isStarted || encounter.isPaused) return;
if (!encounter.currentTurnParticipantId || !encounter.turnOrderIds || encounter.turnOrderIds.length === 0) return;
const activePsInOrder = encounter.turnOrderIds
.map(id => encounter.participants.find(p => p.id === id && p.isActive))
.filter(Boolean);
if (activePsInOrder.length === 0) {
alert("No active participants left.");
await storage.updateDoc(encounterPath, {
isStarted: false,
isPaused: false,
currentTurnParticipantId: null,
round: encounter.round
});
return;
}
let currentIndex = activePsInOrder.findIndex(p => p.id === encounter.currentTurnParticipantId);
let nextRound = encounter.round;
// Current participant was removed; find the next one after their old position in turnOrderIds
if (currentIndex === -1) {
const rawPos = (encounter.turnOrderIds || []).indexOf(encounter.currentTurnParticipantId);
const candidateIds = [...(encounter.turnOrderIds || []).slice(rawPos + 1), ...(encounter.turnOrderIds || []).slice(0, rawPos)];
const nextP = candidateIds.map(id => activePsInOrder.find(p => p.id === id)).find(Boolean);
currentIndex = nextP ? activePsInOrder.findIndex(p => p.id === nextP.id) - 1 : -1;
}
let nextIndex = (currentIndex + 1) % activePsInOrder.length;
let newTurnOrderIds = encounter.turnOrderIds;
if (nextIndex === 0 && currentIndex !== -1) {
nextRound += 1;
// Rebuild turn order by initiative at the start of each new round so that participants
// activated mid-round (appended to the end) slot into proper initiative position next round.
const activePs = encounter.participants.filter(p => p.isActive);
const sorted = sortParticipantsByInitiative(activePs, encounter.participants);
newTurnOrderIds = sorted.map(p => p.id);
}
// When wrapping to a new round the next participant is first in the rebuilt order
const nextParticipant = (nextIndex === 0 && currentIndex !== -1)
? encounter.participants.find(p => p.id === newTurnOrderIds[0])
: activePsInOrder[nextIndex];
if (!nextParticipant) return;
try {
await storage.updateDoc(encounterPath, {
currentTurnParticipantId: nextParticipant.id,
round: nextRound,
turnOrderIds: newTurnOrderIds,
});
logAction(`${nextParticipant.name}'s turn (Round ${nextRound})`, { encounterName: encounter.name }, {
encounterPath,
updates: {
currentTurnParticipantId: encounter.currentTurnParticipantId,
round: encounter.round,
turnOrderIds: [...encounter.turnOrderIds],
},
});
} catch (err) {
console.error("Error advancing turn:", err);
}
};
const confirmEndEncounter = async () => {
if (!db) return;
try {
await storage.updateDoc(encounterPath, {
isStarted: false,
isPaused: false,
currentTurnParticipantId: null,
round: 0,
turnOrderIds: []
});
await storage.setDoc(getPath.activeDisplay(), {
activeCampaignId: null,
activeEncounterId: null
}, { merge: true });
logAction(`Combat ended: "${encounter.name}"`, { encounterName: encounter.name }, {
encounterPath,
updates: {
isStarted: encounter.isStarted ?? false,
isPaused: encounter.isPaused ?? false,
round: encounter.round ?? 0,
currentTurnParticipantId: encounter.currentTurnParticipantId ?? null,
turnOrderIds: [...(encounter.turnOrderIds || [])],
},
});
console.log("Encounter ended and deactivated from Player Display.");
} catch (err) {
console.error("Error ending encounter:", err);
}
setShowEndConfirm(false);
};
if (!encounter || !encounter.participants) return null;
return (
<>
Combat Controls
{!encounter.isStarted ? (
p.isActive).length === 0}
>
Start Combat
) : (
<>
{encounter.isPaused ? : }
{encounter.isPaused ? 'Resume Combat' : 'Pause Combat'}
Next Turn
setShowEndConfirm(true)}
className="w-full px-4 py-3 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-md transition-colors flex items-center justify-center"
>
End Combat
{/* Round Counter */}
Round: {encounter.round}
{encounter.isPaused && (
(Paused)
)}
>
)}
{/* Display Settings */}
Player Display
Hide player HP
setShowEndConfirm(false)}
onConfirm={confirmEndEncounter}
title="End Encounter?"
message="Are you sure you want to end this encounter? Initiative order will be reset and it will be removed from the Player Display."
/>
>
);
}
// ============================================================================
// ENCOUNTER MANAGER COMPONENT
// ============================================================================
function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharacters }) {
const { data: encountersData, isLoading: isLoadingEncounters } = useFirestoreCollection(
campaignId ? getPath.encounters(campaignId) : null
);
const { data: activeDisplayInfo } = useFirestoreDocument(getPath.activeDisplay());
const [encounters, setEncounters] = useState([]);
const [selectedEncounterId, setSelectedEncounterId] = useState(null);
const [showCreateModal, setShowCreateModal] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [itemToDelete, setItemToDelete] = useState(null);
const selectedEncounterIdRef = useRef(selectedEncounterId);
useEffect(() => {
if (encountersData) setEncounters(encountersData);
}, [encountersData]);
useEffect(() => {
selectedEncounterIdRef.current = selectedEncounterId;
}, [selectedEncounterId]);
useEffect(() => {
if (!campaignId) {
setSelectedEncounterId(null);
return;
}
if (encounters && encounters.length > 0) {
const currentSelection = selectedEncounterIdRef.current;
if (currentSelection === null || !encounters.some(e => e.id === currentSelection)) {
if (initialActiveEncounterId && encounters.some(e => e.id === initialActiveEncounterId)) {
setSelectedEncounterId(initialActiveEncounterId);
} else if (
activeDisplayInfo &&
activeDisplayInfo.activeCampaignId === campaignId &&
encounters.some(e => e.id === activeDisplayInfo.activeEncounterId)
) {
setSelectedEncounterId(activeDisplayInfo.activeEncounterId);
}
}
} else if (encounters && encounters.length === 0) {
setSelectedEncounterId(null);
}
}, [campaignId, initialActiveEncounterId, activeDisplayInfo, encounters]);
const handleCreateEncounter = async (name) => {
if (!db || !name.trim() || !campaignId) return;
const newEncounterId = generateId();
try {
await storage.setDoc(`${getPath.encounters(campaignId)}/${newEncounterId}`, {
name: name.trim(),
createdAt: new Date().toISOString(),
participants: [],
round: 0,
currentTurnParticipantId: null,
isStarted: false,
isPaused: false
});
setShowCreateModal(false);
setSelectedEncounterId(newEncounterId);
} catch (err) {
console.error("Error creating encounter:", err);
alert("Failed to create encounter. Please try again.");
}
};
const requestDeleteEncounter = (encounterId, encounterName) => {
setItemToDelete({ id: encounterId, name: encounterName });
setShowDeleteConfirm(true);
};
const confirmDeleteEncounter = async () => {
if (!db || !itemToDelete) return;
const encounterId = itemToDelete.id;
try {
await storage.deleteDoc(getPath.encounter(campaignId, encounterId));
if (selectedEncounterId === encounterId) {
setSelectedEncounterId(null);
}
if (activeDisplayInfo && activeDisplayInfo.activeEncounterId === encounterId) {
await storage.updateDoc(getPath.activeDisplay(), {
activeCampaignId: null,
activeEncounterId: null
});
}
} catch (err) {
console.error("Error deleting encounter:", err);
alert("Failed to delete encounter. Please try again.");
}
setShowDeleteConfirm(false);
setItemToDelete(null);
};
const handleTogglePlayerDisplay = async (encounterId) => {
if (!db) return;
try {
const currentActiveCampaign = activeDisplayInfo?.activeCampaignId;
const currentActiveEncounter = activeDisplayInfo?.activeEncounterId;
if (currentActiveCampaign === campaignId && currentActiveEncounter === encounterId) {
await storage.setDoc(getPath.activeDisplay(), {
activeCampaignId: null,
activeEncounterId: null,
}, { merge: true });
} else {
await storage.setDoc(getPath.activeDisplay(), {
activeCampaignId: campaignId,
activeEncounterId: encounterId,
}, { merge: true });
}
} catch (err) {
console.error("Error toggling Player Display:", err);
}
};
const selectedEncounter = encounters?.find(e => e.id === selectedEncounterId);
if (isLoadingEncounters && campaignId) {
return Loading encounters...
;
}
return (
<>
Encounters
setShowCreateModal(true)}
className="px-4 py-2 text-sm font-medium text-white bg-amber-700 hover:bg-amber-800 rounded-md transition-colors flex items-center"
>
Create Encounter
{(!encounters || encounters.length === 0) && (
No encounters yet.
)}
{encounters?.map(encounter => {
const isLive = activeDisplayInfo &&
activeDisplayInfo.activeCampaignId === campaignId &&
activeDisplayInfo.activeEncounterId === encounter.id;
return (
setSelectedEncounterId(encounter.id)} className="cursor-pointer flex-grow">
{encounter.name}
Participants: {encounter.participants?.length || 0}
{isLive && (
LIVE ON PLAYER DISPLAY
)}
handleTogglePlayerDisplay(encounter.id)}
className={`p-1 rounded transition-colors ${isLive ? 'bg-red-500 hover:bg-red-600 text-white' : 'text-amber-400 hover:text-amber-300 bg-stone-700 hover:bg-stone-600'}`}
title={isLive ? "Deactivate for Player Display" : "Activate for Player Display"}
>
{isLive ? : }
{
e.stopPropagation();
requestDeleteEncounter(encounter.id, encounter.name);
}}
className="p-1 rounded transition-colors text-red-400 hover:text-red-300 bg-stone-700 hover:bg-stone-600"
title="Delete Encounter"
>
);
})}
{showCreateModal && (
setShowCreateModal(false)} title="Create New Encounter">
setShowCreateModal(false)}
/>
)}
{selectedEncounter && (
Managing Encounter: {selectedEncounter.name}
{/* Combat Controls - Left Side (Sticky on large screens) */}
{/* Participant Manager - Right Side */}
)}
setShowDeleteConfirm(false)}
onConfirm={confirmDeleteEncounter}
title="Delete Encounter?"
message={`Are you sure you want to delete the encounter "${itemToDelete?.name}"? This action cannot be undone.`}
/>
>
);
}
// ============================================================================
// ADMIN VIEW COMPONENT
// ============================================================================
function AdminView({ userId }) {
const { data: campaignsData, isLoading: isLoadingCampaigns, error: campaignsError } = useFirestoreCollection(
getPath.campaigns()
);
const { data: initialActiveInfo } = useFirestoreDocument(getPath.activeDisplay());
const [campaignsWithDetails, setCampaignsWithDetails] = useState([]);
const [selectedCampaignId, setSelectedCampaignId] = useState(null);
const [showCreateModal, setShowCreateModal] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [itemToDelete, setItemToDelete] = useState(null);
useEffect(() => {
if (campaignsData && db) {
const fetchDetails = async () => {
const detailedCampaigns = await Promise.all(
campaignsData.map(async (campaign) => {
const characters = campaign.players || [];
let encounterCount = 0;
try {
const encounters = await storage.getCollection(getPath.encounters(campaign.id));
encounterCount = encounters.length;
} catch (err) {
console.error(`Failed to fetch encounters for campaign ${campaign.id}:`, err);
}
return { ...campaign, characters, encounterCount };
})
);
setCampaignsWithDetails(detailedCampaigns);
};
fetchDetails();
} else if (campaignsData) {
setCampaignsWithDetails(
campaignsData.map(c => ({ ...c, characters: c.players || [], encounterCount: 0 }))
);
}
}, [campaignsData]);
useEffect(() => {
if (
initialActiveInfo &&
initialActiveInfo.activeCampaignId &&
campaignsWithDetails.length > 0 &&
!selectedCampaignId
) {
const campaignExists = campaignsWithDetails.some(c => c.id === initialActiveInfo.activeCampaignId);
if (campaignExists) {
setSelectedCampaignId(initialActiveInfo.activeCampaignId);
}
}
}, [initialActiveInfo, campaignsWithDetails, selectedCampaignId]);
const handleCreateCampaign = async (name, backgroundUrl) => {
if (!db || !name.trim()) return;
const newCampaignId = generateId();
try {
await storage.setDoc(getPath.campaign(newCampaignId), {
name: name.trim(),
playerDisplayBackgroundUrl: backgroundUrl.trim() || '',
ownerId: userId,
createdAt: new Date().toISOString(),
players: [],
});
setShowCreateModal(false);
setSelectedCampaignId(newCampaignId);
} catch (err) {
console.error("Error creating campaign:", err);
alert("Failed to create campaign. Please try again.");
}
};
const requestDeleteCampaign = (campaignId, campaignName) => {
setItemToDelete({ id: campaignId, name: campaignName });
setShowDeleteConfirm(true);
};
const confirmDeleteCampaign = async () => {
if (!db || !itemToDelete) return;
const campaignId = itemToDelete.id;
try {
const encountersPath = getPath.encounters(campaignId);
const encounters = await storage.getCollection(encountersPath);
const deleteOps = encounters.map(e => {
const id = e.id || e.path?.split('/').pop();
return { type: 'delete', path: `${encountersPath}/${id}` };
});
if (deleteOps.length > 0) await storage.batchWrite(deleteOps);
await storage.deleteDoc(getPath.campaign(campaignId));
if (selectedCampaignId === campaignId) {
setSelectedCampaignId(null);
}
const activeDisplay = await storage.getDoc(getPath.activeDisplay());
if (activeDisplay && activeDisplay.activeCampaignId === campaignId) {
await storage.updateDoc(getPath.activeDisplay(), {
activeCampaignId: null,
activeEncounterId: null
});
}
} catch (err) {
console.error("Error deleting campaign:", err);
alert("Failed to delete campaign. Please try again.");
}
setShowDeleteConfirm(false);
setItemToDelete(null);
};
const selectedCampaign = campaignsWithDetails.find(c => c.id === selectedCampaignId);
if (isLoadingCampaigns) {
return Loading campaigns...
;
}
if (campaignsError) {
return (
Error loading campaigns: {campaignsError.message || String(campaignsError)}
);
}
return (
<>
Campaigns
setShowCreateModal(true)}
className="bg-red-700 hover:bg-red-800 text-white font-bold py-2 px-4 rounded-lg flex items-center transition-colors"
>
Create Campaign
{campaignsWithDetails.length === 0 && !isLoadingCampaigns && (
No campaigns yet. Create one to get started!
)}
{campaignsWithDetails.map(campaign => {
const cardStyle = campaign.playerDisplayBackgroundUrl
? { backgroundImage: `url(${campaign.playerDisplayBackgroundUrl})` }
: {};
const cardClasses = `h-40 flex flex-col justify-between rounded-lg shadow-md cursor-pointer transition-all relative overflow-hidden bg-cover bg-center ${selectedCampaignId === campaign.id ? 'ring-4 ring-amber-500' : ''} ${!campaign.playerDisplayBackgroundUrl ? 'bg-stone-800 hover:bg-stone-700' : 'hover:shadow-xl'}`;
return (
setSelectedCampaignId(campaign.id)}
className={cardClasses}
style={cardStyle}
>
{campaign.name}
{campaign.characters?.length || 0} Characters
{campaign.encounterCount === undefined ? '...' : campaign.encounterCount} Encounters
{
e.stopPropagation();
requestDeleteCampaign(campaign.id, campaign.name);
}}
className="mt-auto text-red-300 hover:text-red-100 text-xs flex items-center self-start bg-black bg-opacity-50 hover:bg-opacity-70 px-2 py-1 rounded"
>
Delete
);
})}
{showCreateModal && (
setShowCreateModal(false)} title="Create New Campaign">
setShowCreateModal(false)}
/>
)}
{selectedCampaign && (
Managing: {selectedCampaign.name}
)}
setShowDeleteConfirm(false)}
onConfirm={confirmDeleteCampaign}
title="Delete Campaign?"
message={`Are you sure you want to delete the campaign "${itemToDelete?.name}" and all its encounters? This action cannot be undone.`}
/>
>
);
}
// ============================================================================
// DISPLAY VIEW COMPONENT (Player View)
// ============================================================================
function DisplayView() {
const { data: activeDisplayData, isLoading: isLoadingActiveDisplay, error: activeDisplayError } = useFirestoreDocument(
getPath.activeDisplay()
);
const [activeEncounterData, setActiveEncounterData] = useState(null);
const [isLoadingEncounter, setIsLoadingEncounter] = useState(true);
const [encounterError, setEncounterError] = useState(null);
const [campaignBackgroundUrl, setCampaignBackgroundUrl] = useState('');
const [isPlayerDisplayActive, setIsPlayerDisplayActive] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [wakeLockEnabled, setWakeLockEnabled] = useState(false);
const wakeLockRef = useRef(null);
const currentParticipantRef = useRef(null);
useEffect(() => {
const onFsChange = () => setIsFullscreen(!!document.fullscreenElement);
document.addEventListener('fullscreenchange', onFsChange);
return () => document.removeEventListener('fullscreenchange', onFsChange);
}, []);
const toggleFullscreen = () => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen();
} else {
document.exitFullscreen();
}
};
useEffect(() => {
if (!wakeLockEnabled) {
wakeLockRef.current?.release();
wakeLockRef.current = null;
return;
}
const acquire = async () => {
try {
wakeLockRef.current = await navigator.wakeLock.request('screen');
} catch (e) {
console.error('Wake lock failed:', e);
}
};
acquire();
// Re-acquire after tab becomes visible again (browser auto-releases on hide)
const onVisChange = () => { if (document.visibilityState === 'visible') acquire(); };
document.addEventListener('visibilitychange', onVisChange);
return () => {
document.removeEventListener('visibilitychange', onVisChange);
wakeLockRef.current?.release();
wakeLockRef.current = null;
};
}, [wakeLockEnabled]);
useEffect(() => {
if (!db) {
setEncounterError("Firestore not available.");
setIsLoadingEncounter(false);
setIsPlayerDisplayActive(false);
return;
}
let unsubscribeEncounter;
let unsubscribeCampaign;
if (activeDisplayData) {
const { activeCampaignId, activeEncounterId } = activeDisplayData;
if (activeCampaignId && activeEncounterId) {
setIsPlayerDisplayActive(true);
setIsLoadingEncounter(true);
setEncounterError(null);
const campaignDocRef = doc(db, getPath.campaign(activeCampaignId));
unsubscribeCampaign = onSnapshot(
campaignDocRef,
(campSnap) => {
if (campSnap.exists()) {
setCampaignBackgroundUrl(campSnap.data().playerDisplayBackgroundUrl || '');
} else {
setCampaignBackgroundUrl('');
}
},
(err) => console.error("Error fetching campaign background:", err)
);
const encounterPath = getPath.encounter(activeCampaignId, activeEncounterId);
unsubscribeEncounter = onSnapshot(
doc(db, encounterPath),
(encDocSnap) => {
if (encDocSnap.exists()) {
setActiveEncounterData({ id: encDocSnap.id, ...encDocSnap.data() });
} else {
setActiveEncounterData(null);
setEncounterError("Active encounter data not found.");
}
setIsLoadingEncounter(false);
},
(err) => {
console.error("Error fetching active encounter details:", err);
setEncounterError("Error loading active encounter data.");
setIsLoadingEncounter(false);
}
);
} else {
setActiveEncounterData(null);
setCampaignBackgroundUrl('');
setIsPlayerDisplayActive(false);
setIsLoadingEncounter(false);
}
} else if (!isLoadingActiveDisplay) {
setActiveEncounterData(null);
setCampaignBackgroundUrl('');
setIsPlayerDisplayActive(false);
setIsLoadingEncounter(false);
}
return () => {
if (unsubscribeEncounter) unsubscribeEncounter();
if (unsubscribeCampaign) unsubscribeCampaign();
};
}, [activeDisplayData, isLoadingActiveDisplay]);
// Auto-scroll current participant into view
useEffect(() => {
if (currentParticipantRef.current && activeEncounterData?.isStarted && !activeEncounterData?.isPaused) {
currentParticipantRef.current.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'nearest'
});
}
}, [activeEncounterData?.currentTurnParticipantId, activeEncounterData?.isStarted, activeEncounterData?.isPaused]);
if (isLoadingActiveDisplay || (isPlayerDisplayActive && isLoadingEncounter)) {
return Loading Player Display...
;
}
if (activeDisplayError || (isPlayerDisplayActive && encounterError)) {
return {activeDisplayError || encounterError}
;
}
if (!isPlayerDisplayActive || !activeEncounterData) {
return (
Game Session Paused
The Dungeon Master has not activated an encounter for display.
);
}
const { name, participants, round, currentTurnParticipantId, isStarted, isPaused } = activeEncounterData;
const hidePlayerHp = activeDisplayData?.hidePlayerHp ?? true;
let participantsToRender = [];
if (participants) {
// Hide inactive monsters (pre-staged/summoned reserves) from the player view
const visibleParticipants = participants.filter(p => p.isActive || p.type !== 'monster');
participantsToRender = sortParticipantsByInitiative(visibleParticipants, visibleParticipants);
}
const displayStyles = campaignBackgroundUrl
? {
backgroundImage: `url(${campaignBackgroundUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center center',
backgroundRepeat: 'no-repeat',
minHeight: '100vh'
}
: { minHeight: '100vh' };
return (
setWakeLockEnabled(v => !v)}
title={wakeLockEnabled ? 'Allow sleep' : 'Prevent sleep'}
className={`p-2 rounded-lg transition-all ${wakeLockEnabled ? 'bg-amber-600 hover:bg-amber-700 text-white' : 'bg-stone-800 bg-opacity-80 hover:bg-opacity-100 text-stone-300 hover:text-white'}`}
>
{wakeLockEnabled ? : }
{isFullscreen ? : }
{name}
{isStarted &&
Round: {round}
}
{isStarted && isPaused && (
(Combat Paused)
)}
{!isStarted && participants?.length > 0 && (
Awaiting Start
)}
{!isStarted && (!participants || participants.length === 0) && (
No participants.
)}
{participantsToRender.length === 0 && isStarted && (
No active participants.
)}
{participantsToRender.map(p => {
const isDead = p.currentHp === 0;
const isDying = p.isDying || false;
let participantBgColor = p.type === 'monster'
? (p.isNpc ? 'bg-stone-800' : 'bg-[#8e351c]')
: 'bg-indigo-950';
const isCurrentTurn = p.id === currentTurnParticipantId && isStarted && !isPaused;
if (isCurrentTurn) {
participantBgColor = 'bg-green-700 ring-4 ring-green-400 scale-105';
} else if (isPaused && p.id === currentTurnParticipantId) {
participantBgColor += ' ring-2 ring-yellow-400';
}
return (
{isDead && β οΈ }
{p.name}
{isCurrentTurn && (
(Current)
)}
{isDead && {p.type === 'character' ? '(Unconscious)' : '(Dead)'} }
Init: {p.initiative}
{!(hidePlayerHp && p.type === 'character') && (
{p.type !== 'monster' && (
HP: {p.currentHp} / {p.maxHp}
)}
)}
{p.conditions?.length > 0 && (
{p.conditions.map(cId => {
const cond = CONDITIONS.find(c => c.id === cId);
return cond ? (
{cond.emoji} {cond.label}
) : null;
})}
)}
{!p.isActive && !isDead && (
(Inactive)
)}
);
})}
);
}
// ============================================================================
// LOGS VIEW COMPONENT
// ============================================================================
function LogsView() {
const { data: logs, isLoading } = useFirestoreCollection(getPath.logs(), LOG_QUERY);
const [showClearConfirm, setShowClearConfirm] = useState(false);
const [undoingId, setUndoingId] = useState(null);
const handleClearLogs = async () => {
try {
const logs = await storage.getCollection(getPath.logs());
if (logs.length > 0) {
const ops = logs.map(l => {
const id = l.id || l.path?.split('/').pop();
return { type: 'delete', path: `${getPath.logs()}/${id}` };
});
await storage.batchWrite(ops);
}
} catch (err) {
console.error('Error clearing logs:', err);
}
setShowClearConfirm(false);
};
const handleUndo = async (entry) => {
if (!db || !entry.undo) return;
setUndoingId(entry.id);
try {
await storage.updateDoc(entry.undo.encounterPath, entry.undo.updates);
await storage.updateDoc(`${getPath.logs()}/${entry.id}`, { undone: true });
} catch (err) {
console.error('Error undoing action:', err);
alert('Failed to roll back. The encounter may have changed or no longer exists.');
}
setUndoingId(null);
};
return (
Combat Log
β Back to Tracker
setShowClearConfirm(true)}
disabled={isLoading || logs.length === 0}
className="px-4 py-2 rounded-md text-sm font-medium bg-red-800 hover:bg-red-700 text-white transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
Clear Log
{isLoading ? (
) : logs.length === 0 ? (
No log entries yet.
) : (
<>
{logs.length} entries β newest first
{logs.map(entry => (
{new Date(entry.timestamp).toLocaleString()}
{entry.encounterName && (
[{entry.encounterName}]
)}
{entry.message}
{entry.undone ? (
rolled back
) : entry.undo ? (
handleUndo(entry)}
disabled={undoingId === entry.id}
className="shrink-0 px-2 py-0.5 text-xs rounded bg-stone-700 hover:bg-amber-800 text-stone-300 hover:text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Roll back this action"
>
{undoingId === entry.id ? 'β¦' : 'β© Undo'}
) : null}
))}
>
)}
setShowClearConfirm(false)}
onConfirm={handleClearLogs}
title="Clear All Logs?"
message="This will permanently delete all log entries and cannot be undone."
/>
);
}
// ============================================================================
// MAIN APP COMPONENT
// ============================================================================
function App() {
const [userId, setUserId] = useState(null);
const [isAuthReady, setIsAuthReady] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const [isPlayerViewOnlyMode, setIsPlayerViewOnlyMode] = useState(false);
const [isLogsMode, setIsLogsMode] = useState(false);
useEffect(() => {
const queryParams = new URLSearchParams(window.location.search);
if (queryParams.get('playerView') === 'true' || window.location.pathname === '/display') {
setIsPlayerViewOnlyMode(true);
}
if (window.location.pathname === '/logs') {
setIsLogsMode(true);
}
if (!auth) {
setError("Auth not initialized.");
setIsLoading(false);
setIsAuthReady(false);
return;
}
// ws/memory mode: stub auth, no SDK sign-in. Unblock UI immediately.
if (STORAGE_MODE !== 'firebase') {
setUserId(auth.currentUser?.uid || 'local-user');
setIsAuthReady(true);
setIsLoading(false);
return;
}
const initAuth = async () => {
try {
const token = window.__initial_auth_token;
if (token) {
await signInWithCustomToken(auth, token);
} else {
await signInAnonymously(auth);
}
} catch (err) {
console.error("Authentication error:", err);
setError("Failed to authenticate. Please try again later.");
// Auth failed and onAuthStateChanged won't fire with a user, so unblock the UI here
setIsAuthReady(true);
setIsLoading(false);
}
};
const unsubscribe = onAuthStateChanged(auth, (user) => {
setUserId(user ? user.uid : null);
// Only mark auth ready once we have an actual authenticated user.
// onAuthStateChanged fires with null before signInAnonymously completes,
// which would cause Firestore queries to run unauthenticated.
if (user) {
setIsAuthReady(true);
setIsLoading(false);
}
});
initAuth();
return () => unsubscribe();
}, []);
if (!isInitialized || !auth) {
return (
);
}
if (isLoading || !isAuthReady) {
return ;
}
if (error) {
return ;
}
const openPlayerWindow = () => {
const playerViewUrl = window.location.origin + '/display';
window.open(playerViewUrl, '_blank', 'noopener,noreferrer,width=1024,height=768');
};
if (isPlayerViewOnlyMode) {
return (
{isAuthReady &&
}
{!isAuthReady && !error &&
Authenticating for Player Display...
}
);
}
if (isLogsMode) {
return isAuthReady ? : ;
}
return (
{isAuthReady && userId && }
{!isAuthReady && !error && Authenticating...
}
TTRPG Initiative Tracker {APP_VERSION}
);
}
export default App;