2025-05-26 21:17:42 -04:00
import React , { useState , useEffect , useRef , useMemo } from 'react' ;
2026-06-28 17:51:39 -04:00
import { initializeApp } from './storage' ;
import { getAuth , signInAnonymously , onAuthStateChanged , signInWithCustomToken } from './storage' ;
2026-06-28 21:11:56 -04:00
import { getFirestore , doc , setDoc , addDoc , collection , onSnapshot , updateDoc , deleteDoc , query , orderBy , limit , writeBatch , getStorage , getStorageMode } from './storage' ;
2026-05-16 10:25:17 -04:00
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 ,
2026-06-27 11:53:56 -04:00
StopCircle as StopCircleIcon , Users2 , Dices , ChevronUp , ChevronDown , ScrollText ,
2026-06-27 11:56:11 -04:00
Maximize2 , Minimize2 , Moon , Coffee
2025-12-13 19:15:26 -05:00
} 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
// ============================================================================
2026-06-27 20:15:20 -04:00
const APP_VERSION = 'v0.3' ;
2025-12-13 19:15:26 -05:00
const DEFAULT_MAX_HP = 10 ;
const DEFAULT_INIT_MOD = 0 ;
const MONSTER_DEFAULT_INIT_MOD = 2 ;
const ROLL_DISPLAY_DURATION = 5000 ;
2026-04-26 10:37:25 -04:00
const CONDITIONS = [
2026-05-16 09:37:11 -04:00
{ 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 : '🔨' },
2026-05-16 15:29:27 -04:00
{ id : 'shield' , label : 'Shield' , emoji : '🛡️' },
2026-05-16 09:37:11 -04:00
{ id : 'slowed' , label : 'Slowed' , emoji : '🐌' },
{ id : 'stunned' , label : 'Stunned' , emoji : '💥' },
{ id : 'unconscious' , label : 'Unconscious' , emoji : '💤' },
{ id : 'vexed' , label : 'Vexed' , emoji : '🎯' },
2026-04-26 10:37:25 -04:00
];
2025-12-13 19:15:26 -05:00
// ============================================================================
// FIREBASE CONFIGURATION
// ============================================================================
2025-05-25 21:19:22 -04:00
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
};
2025-12-13 19:15:26 -05:00
const APP_ID = process . env . REACT_APP_TRACKER_APP_ID || 'ttrpg-initiative-tracker-default' ;
const PUBLIC_DATA_PATH = `artifacts/ ${ APP_ID } /public/data` ;
2025-05-25 22:21:45 -04:00
let app ;
let db ;
let auth ;
2026-06-28 21:05:39 -04:00
let storage ;
2025-05-25 22:21:45 -04:00
2026-06-28 21:11:56 -04:00
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 ;
}
2025-12-13 19:15:26 -05:00
}
2025-05-25 21:19:22 -04:00
2026-06-28 21:11:56 -04:00
// ws / memory mode: stub auth so App's anon-sign-in path works.
const FAKE_USER = { uid : 'local-user' , isAnonymous : true };
auth = {
currentUser : FAKE_USER ,
};
storage = getStorage ();
return true ;
2025-12-13 19:15:26 -05:00
};
2025-05-25 21:19:22 -04:00
2026-06-28 21:11:56 -04:00
const isInitialized = initializeStorage ();
2025-05-26 08:33:39 -04:00
2025-12-13 19:15:26 -05:00
// ============================================================================
// FIRESTORE PATH HELPERS
// ============================================================================
2025-05-26 08:33:39 -04:00
2025-12-13 19:15:26 -05:00
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 } ` ,
2026-05-16 10:25:17 -04:00
activeDisplay : () => ` ${ PUBLIC_DATA_PATH } /activeDisplay/status` ,
logs : () => ` ${ PUBLIC_DATA_PATH } /logs`
2025-12-13 19:15:26 -05:00
};
// ============================================================================
// UTILITY FUNCTIONS
// ============================================================================
2025-05-25 21:19:22 -04:00
const generateId = () => crypto . randomUUID ();
2025-05-26 22:31:43 -04:00
const rollD20 = () => Math . floor ( Math . random () * 20 ) + 1 ;
2025-05-25 21:19:22 -04:00
2025-12-13 19:15:26 -05:00
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 ;
});
};
2026-05-16 10:25:17 -04:00
const LOG_QUERY = [ orderBy ( 'timestamp' , 'desc' ), limit ( 500 )];
2026-06-27 16:45:07 -04:00
const logAction = async ( message , context = {}, undoData = null ) => {
2026-05-16 10:25:17 -04:00
if ( ! db ) return ;
try {
2026-06-27 16:45:07 -04:00
const entry = { timestamp : Date . now (), message , ... context };
if ( undoData ) entry . undo = undoData ;
2026-06-28 21:05:39 -04:00
await storage . addDoc ( getPath . logs (), entry );
2026-05-16 10:25:17 -04:00
} catch ( err ) {
console . error ( 'Error writing log:' , err );
}
};
2026-05-16 10:00:17 -04:00
// 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 ] };
};
2025-12-13 19:15:26 -05:00
// ============================================================================
// CUSTOM HOOKS
// ============================================================================
2025-05-26 08:33:39 -04:00
function useFirestoreDocument ( docPath ) {
const [ data , setData ] = useState ( null );
const [ isLoading , setIsLoading ] = useState ( true );
const [ error , setError ] = useState ( null );
useEffect (() => {
2026-06-28 19:03:44 -04:00
if ( ! docPath ) {
2025-05-26 08:33:39 -04:00
setData ( null );
setIsLoading ( false );
2026-06-28 19:03:44 -04:00
setError ( "Document path not provided." );
2025-05-26 08:33:39 -04:00
return ;
}
2025-12-13 19:15:26 -05:00
2025-05-26 08:33:39 -04:00
setIsLoading ( true );
setError ( null );
2025-12-13 19:15:26 -05:00
2026-06-28 19:03:44 -04:00
const storage = getStorage ();
const unsubscribe = storage . subscribeDoc ( docPath , ( doc ) => {
setData ( doc );
setIsLoading ( false );
});
return () => { if ( typeof unsubscribe === 'function' ) unsubscribe (); };
2025-05-26 08:33:39 -04:00
}, [ docPath ]);
2025-12-13 19:15:26 -05:00
2025-05-26 08:33:39 -04:00
return { data , isLoading , error };
}
function useFirestoreCollection ( collectionPath , queryConstraints = []) {
const [ data , setData ] = useState ([]);
const [ isLoading , setIsLoading ] = useState ( true );
const [ error , setError ] = useState ( null );
2025-05-26 21:17:42 -04:00
const queryString = useMemo (() => JSON . stringify ( queryConstraints ), [ queryConstraints ]);
2025-05-26 08:33:39 -04:00
useEffect (() => {
2026-06-28 19:03:44 -04:00
if ( ! collectionPath ) {
2025-05-26 08:33:39 -04:00
setData ([]);
setIsLoading ( false );
2026-06-28 19:03:44 -04:00
setError ( "Collection path not provided." );
2025-05-26 08:33:39 -04:00
return ;
}
2025-12-13 19:15:26 -05:00
2025-05-26 08:33:39 -04:00
setIsLoading ( true );
setError ( null );
2025-12-13 19:15:26 -05:00
2026-06-28 19:03:44 -04:00
const storage = getStorage ();
const unsubscribe = storage . subscribeCollection ( collectionPath , ( items ) => {
setData ( items );
setIsLoading ( false );
});
return () => { if ( typeof unsubscribe === 'function' ) unsubscribe (); };
// queryString, not array ref
2025-12-13 19:15:26 -05:00
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ collectionPath , queryString ]);
2025-05-26 08:33:39 -04:00
return { data , isLoading , error };
}
2025-12-13 19:15:26 -05:00
// ============================================================================
// REUSABLE COMPONENTS
// ============================================================================
2025-05-26 08:33:39 -04:00
2025-12-13 19:15:26 -05:00
function Modal ({ onClose , title , children }) {
2025-05-25 21:19:22 -04:00
useEffect (() => {
2025-12-13 19:15:26 -05:00
const handleEsc = ( event ) => {
if ( event . key === 'Escape' ) onClose ();
2025-05-25 21:19:22 -04:00
};
2025-12-13 19:15:26 -05:00
window . addEventListener ( 'keydown' , handleEsc );
return () => window . removeEventListener ( 'keydown' , handleEsc );
}, [ onClose ]);
2025-05-25 21:19:22 -04:00
2025-12-13 19:15:26 -05:00
return (
< div className = "fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center p-4 z-50" >
2026-04-25 20:25:34 -04:00
< div className = "bg-stone-900 p-6 rounded-lg shadow-xl w-full max-w-md" >
2025-12-13 19:15:26 -05:00
< div className = "flex justify-between items-center mb-4" >
2026-04-25 18:37:55 -04:00
< h2 className = "text-xl font-semibold text-amber-300 font-cinzel tracking-wide" > { title } < /h2>
2026-04-25 20:25:34 -04:00
< button onClick = { onClose } className = "text-stone-400 hover:text-stone-200" >
2025-12-13 19:15:26 -05:00
< XCircle size = { 24 } />
< /button>
2025-05-25 22:21:45 -04:00
< /div>
2025-12-13 19:15:26 -05:00
{ children }
2025-05-26 07:59:05 -04:00
< /div>
2025-05-25 21:19:22 -04:00
< /div>
);
}
2025-05-26 09:41:50 -04:00
function ConfirmationModal ({ isOpen , onClose , onConfirm , title , message }) {
if ( ! isOpen ) return null ;
2025-12-13 19:15:26 -05:00
2025-05-26 09:41:50 -04:00
return (
2025-12-13 19:15:26 -05:00
< div className = "fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center p-4 z-50" >
2026-04-25 20:25:34 -04:00
< div className = "bg-stone-900 p-6 rounded-lg shadow-xl w-full max-w-md" >
2025-05-26 09:41:50 -04:00
< div className = "flex items-center mb-4" >
< AlertTriangle size = { 24 } className = "text-yellow-400 mr-3 flex-shrink-0" />
< h2 className = "text-xl font-semibold text-yellow-300" > { title || "Confirm Action" } < /h2>
< /div>
2026-04-25 20:25:34 -04:00
< p className = "text-stone-300 mb-6" > { message || "Are you sure you want to proceed?" } < /p>
2025-05-26 09:41:50 -04:00
< div className = "flex justify-end space-x-3" >
2025-12-13 19:15:26 -05:00
< button
onClick = { onClose }
2026-04-25 20:25:34 -04:00
className = "px-4 py-2 text-sm font-medium text-stone-300 bg-stone-700 hover:bg-stone-600 rounded-md transition-colors"
2025-12-13 19:15:26 -05:00
>
Cancel
< /button>
< button
onClick = { onConfirm }
className = "px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-md transition-colors"
>
Confirm
< /button>
2025-05-26 09:41:50 -04:00
< /div>
< /div>
< /div>
);
}
2025-12-13 19:15:26 -05:00
function LoadingSpinner ({ message = "Loading..." }) {
return (
2026-04-25 20:25:34 -04:00
< div className = "min-h-screen bg-stone-950 text-white flex flex-col items-center justify-center p-4" >
2026-04-25 18:37:55 -04:00
< div className = "animate-spin rounded-full h-16 w-16 border-t-4 border-amber-500 border-solid" >< /div>
2025-12-13 19:15:26 -05:00
< p className = "mt-4 text-xl" > { message } < /p>
< /div>
);
}
2025-05-26 08:33:39 -04:00
2025-12-13 19:15:26 -05:00
function ErrorDisplay ({ message , critical = false }) {
2025-05-25 21:19:22 -04:00
return (
2026-04-25 20:25:34 -04:00
< div className = { `min-h-screen ${ critical ? 'bg-red-900' : 'bg-stone-950' } text-white flex flex-col items-center justify-center p-4` } >
2025-12-13 19:15:26 -05:00
< h1 className = "text-3xl font-bold mb-4" >
{ critical ? 'Configuration Error' : 'Error' }
< /h1>
< p className = "text-xl text-center max-w-2xl" > { message } < /p>
< /div>
2025-05-25 21:19:22 -04:00
);
}
2025-12-13 19:15:26 -05:00
// ============================================================================
// FORM COMPONENTS
// ============================================================================
2025-05-25 21:19:22 -04:00
function CreateCampaignForm ({ onCreate , onCancel }) {
const [ name , setName ] = useState ( '' );
2025-12-13 19:15:26 -05:00
const [ backgroundUrl , setBackgroundUrl ] = useState ( '' );
const handleSubmit = ( e ) => {
e . preventDefault ();
if ( name . trim ()) {
onCreate ( name , backgroundUrl );
}
};
2025-05-25 21:19:22 -04:00
return (
2025-05-25 23:28:36 -04:00
< form onSubmit = { handleSubmit } className = "space-y-4" >
2025-05-25 21:19:22 -04:00
< div >
2026-04-25 20:25:34 -04:00
< label htmlFor = "campaignName" className = "block text-sm font-medium text-stone-300" >
2025-12-13 19:15:26 -05:00
Campaign Name
< /label>
< input
type = "text"
id = "campaignName"
value = { name }
onChange = {( e ) => setName ( e . target . value )}
2026-04-25 20:25:34 -04:00
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"
2025-12-13 19:15:26 -05:00
required
/>
2025-05-25 21:19:22 -04:00
< /div>
2025-05-25 23:28:36 -04:00
< div >
2026-04-25 20:25:34 -04:00
< label htmlFor = "backgroundUrl" className = "block text-sm font-medium text-stone-300" >
2025-12-13 19:15:26 -05:00
Player Display Background URL ( Optional )
< /label>
< input
type = "url"
id = "backgroundUrl"
value = { backgroundUrl }
onChange = {( e ) => setBackgroundUrl ( e . target . value )}
placeholder = "https://example.com/image.jpg"
2026-04-25 20:25:34 -04:00
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"
2025-12-13 19:15:26 -05:00
/>
2025-05-25 23:28:36 -04:00
< /div>
2025-05-25 21:19:22 -04:00
< div className = "flex justify-end space-x-3" >
2025-12-13 19:15:26 -05:00
< button
type = "button"
onClick = { onCancel }
2026-04-25 20:25:34 -04:00
className = "px-4 py-2 text-sm font-medium text-stone-300 bg-stone-700 hover:bg-stone-600 rounded-md transition-colors"
2025-12-13 19:15:26 -05:00
>
Cancel
< /button>
< button
type = "submit"
2026-04-25 20:25:34 -04:00
className = "px-4 py-2 text-sm font-medium text-white bg-red-700 hover:bg-red-800 rounded-md transition-colors"
2025-12-13 19:15:26 -05:00
>
Create
< /button>
2025-05-25 21:19:22 -04:00
< /div>
< /form>
);
}
2025-12-13 19:15:26 -05:00
function CreateEncounterForm ({ onCreate , onCancel }) {
const [ name , setName ] = useState ( '' );
2025-05-28 12:23:10 -04:00
2025-12-13 19:15:26 -05:00
const handleSubmit = ( e ) => {
e . preventDefault ();
if ( name . trim ()) {
onCreate ( name );
}
2025-05-25 21:19:22 -04:00
};
2025-12-13 19:15:26 -05:00
2025-05-25 21:19:22 -04:00
return (
2025-12-13 19:15:26 -05:00
< form onSubmit = { handleSubmit } className = "space-y-4" >
< div >
2026-04-25 20:25:34 -04:00
< label htmlFor = "encounterName" className = "block text-sm font-medium text-stone-300" >
2025-12-13 19:15:26 -05:00
Encounter Name
< /label>
< input
type = "text"
id = "encounterName"
value = { name }
onChange = {( e ) => setName ( e . target . value )}
2026-04-25 20:25:34 -04:00
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"
2025-12-13 19:15:26 -05:00
required
/>
< /div>
< div className = "flex justify-end space-x-3" >
< button
type = "button"
onClick = { onCancel }
2026-04-25 20:25:34 -04:00
className = "px-4 py-2 text-sm font-medium text-stone-300 bg-stone-700 hover:bg-stone-600 rounded-md transition-colors"
2025-12-13 19:15:26 -05:00
>
Cancel
< /button>
< button
type = "submit"
2026-04-25 20:25:34 -04:00
className = "px-4 py-2 text-sm font-medium text-white bg-amber-700 hover:bg-amber-800 rounded-md transition-colors"
2025-05-28 14:58:54 -04:00
>
2025-12-13 19:15:26 -05:00
Create
2025-05-28 14:58:54 -04:00
< /button>
< /div>
2025-12-13 19:15:26 -05:00
< /form>
);
}
2025-05-28 14:58:54 -04:00
2025-12-13 19:15:26 -05:00
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 (
< Modal onClose = { onClose } title = { `Edit ${ participant . name } ` } >
< form onSubmit = { handleSubmit } className = "space-y-4" >
< div >
2026-04-25 20:25:34 -04:00
< label className = "block text-sm font-medium text-stone-300" > Name < /label>
2025-12-13 19:15:26 -05:00
< input
type = "text"
value = { name }
onChange = {( e ) => setName ( e . target . value )}
2026-04-25 20:25:34 -04:00
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"
2025-12-13 19:15:26 -05:00
/>
< /div>
< div >
2026-04-25 20:25:34 -04:00
< label className = "block text-sm font-medium text-stone-300" > Initiative < /label>
2025-12-13 19:15:26 -05:00
< input
type = "number"
value = { initiative }
onChange = {( e ) => setInitiative ( e . target . value )}
2026-04-25 20:25:34 -04:00
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"
2025-12-13 19:15:26 -05:00
/>
< /div>
< div className = "flex gap-4" >
< div className = "flex-1" >
2026-04-25 20:25:34 -04:00
< label className = "block text-sm font-medium text-stone-300" > Current HP < /label>
2025-12-13 19:15:26 -05:00
< input
type = "number"
value = { currentHp }
onChange = {( e ) => setCurrentHp ( e . target . value )}
2026-04-25 20:25:34 -04:00
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"
2025-12-13 19:15:26 -05:00
/>
< /div>
< div className = "flex-1" >
2026-04-25 20:25:34 -04:00
< label className = "block text-sm font-medium text-stone-300" > Max HP < /label>
2025-12-13 19:15:26 -05:00
< input
type = "number"
value = { maxHp }
onChange = {( e ) => setMaxHp ( e . target . value )}
2026-04-25 20:25:34 -04:00
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"
2025-12-13 19:15:26 -05:00
/>
< /div>
< /div>
{ participant . type === 'monster' && (
< div className = "flex items-center" >
< input
type = "checkbox"
id = "editIsNpc"
checked = { isNpc }
onChange = {( e ) => setIsNpc ( e . target . checked )}
2026-04-25 20:25:34 -04:00
className = "h-4 w-4 text-violet-600 border-stone-400 rounded focus:ring-violet-500"
2025-12-13 19:15:26 -05:00
/>
2026-04-25 20:25:34 -04:00
< label htmlFor = "editIsNpc" className = "ml-2 block text-sm text-stone-300" >
2025-12-13 19:15:26 -05:00
Is NPC ?
< /label>
< /div>
)}
< div className = "flex justify-end space-x-3 pt-2" >
< button
type = "button"
onClick = { onClose }
2026-04-25 20:25:34 -04:00
className = "px-4 py-2 text-sm font-medium text-stone-300 bg-stone-700 hover:bg-stone-600 rounded-md transition-colors"
2025-12-13 19:15:26 -05:00
>
Cancel
< /button>
< button
type = "submit"
2026-04-25 18:37:55 -04:00
className = "px-4 py-2 text-sm font-medium text-white bg-amber-700 hover:bg-amber-800 rounded-md transition-colors"
2025-12-13 19:15:26 -05:00
>
< Save size = { 18 } className = "mr-1 inline-block" /> Save
< /button>
< /div>
< /form>
< /Modal>
2025-05-25 21:19:22 -04:00
);
}
2025-12-13 19:15:26 -05:00
// ============================================================================
// CHARACTER MANAGER COMPONENT
// ============================================================================
2025-05-28 14:58:54 -04:00
2025-12-13 19:15:26 -05:00
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 {
2026-06-28 21:05:39 -04:00
await storage . updateDoc ( getPath . campaign ( campaignId ), {
2025-12-13 19:15:26 -05:00
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." );
}
2025-05-25 21:19:22 -04:00
};
2025-12-13 19:15:26 -05:00
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 {
2026-06-28 21:05:39 -04:00
await storage . updateDoc ( getPath . campaign ( campaignId ), { players : updatedCharacters });
2025-12-13 19:15:26 -05:00
setEditingCharacter ( null );
} catch ( err ) {
console . error ( "Error updating character:" , err );
alert ( "Failed to update character. Please try again." );
}
2025-05-25 21:19:22 -04:00
};
2025-12-13 19:15:26 -05:00
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 );
2025-05-25 21:19:22 -04:00
try {
2026-06-28 21:05:39 -04:00
await storage . updateDoc ( getPath . campaign ( campaignId ), { players : updatedCharacters });
2025-12-13 19:15:26 -05:00
} catch ( err ) {
console . error ( "Error deleting character:" , err );
alert ( "Failed to delete character. Please try again." );
}
setShowDeleteConfirm ( false );
setItemToDelete ( null );
2025-05-25 21:19:22 -04:00
};
2025-12-13 19:15:26 -05:00
2025-05-25 21:19:22 -04:00
return (
2025-05-26 09:41:50 -04:00
<>
2026-04-25 20:25:34 -04:00
< div className = "p-4 bg-stone-900 rounded-lg shadow" >
2025-12-13 19:15:26 -05:00
< div className = "flex justify-between items-center mb-3" >
2026-04-25 18:37:55 -04:00
< h3 className = "text-xl font-semibold text-amber-300 font-cinzel tracking-wide flex items-center" >
2025-12-13 19:15:26 -05:00
< Users size = { 24 } className = "mr-2" /> Campaign Characters
< /h3>
< button
onClick = {() => setIsOpen ( ! isOpen )}
2026-04-25 20:25:34 -04:00
className = "p-1 text-stone-400 hover:text-stone-200"
2025-12-13 19:15:26 -05:00
aria - label = { isOpen ? "Collapse" : "Expand" }
>
{ isOpen ? < ChevronUp size = { 20 } /> : < ChevronDown size = { 20 } /> }
< /button>
< /div>
{ isOpen && (
<>
< form onSubmit = {( e ) => { e . preventDefault (); handleAddCharacter (); }} className = "grid grid-cols-1 sm:grid-cols-3 gap-2 mb-4 items-end" >
< div className = "sm:col-span-1" >
2026-04-25 20:25:34 -04:00
< label htmlFor = "characterName" className = "block text-xs font-medium text-stone-400" >
2025-12-13 19:15:26 -05:00
Name
< /label>
< input
type = "text"
id = "characterName"
value = { characterName }
onChange = {( e ) => setCharacterName ( e . target . value )}
placeholder = "Character name"
2026-04-25 20:25:34 -04:00
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"
2025-12-13 19:15:26 -05:00
/>
2025-05-25 21:19:22 -04:00
< /div>
2025-12-13 19:15:26 -05:00
< div className = "w-full sm:w-auto" >
2026-04-25 20:25:34 -04:00
< label htmlFor = "defaultMaxHp" className = "block text-xs font-medium text-stone-400" >
2025-12-13 19:15:26 -05:00
Default HP
< /label>
< input
type = "number"
id = "defaultMaxHp"
value = { defaultMaxHp }
onChange = {( e ) => setDefaultMaxHp ( e . target . value )}
2026-04-25 20:25:34 -04:00
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"
2025-12-13 19:15:26 -05:00
/>
< /div>
< div className = "w-full sm:w-auto" >
2026-04-25 20:25:34 -04:00
< label htmlFor = "defaultInitMod" className = "block text-xs font-medium text-stone-400" >
2025-12-13 19:15:26 -05:00
Init Mod
< /label>
< input
type = "number"
id = "defaultInitMod"
value = { defaultInitMod }
onChange = {( e ) => setDefaultInitMod ( e . target . value )}
2026-04-25 20:25:34 -04:00
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"
2025-12-13 19:15:26 -05:00
/>
< /div>
< button
type = "submit"
2026-04-25 18:37:55 -04:00
className = "sm:col-span-3 sm:w-auto sm:justify-self-end px-4 py-2 text-sm font-medium text-white bg-amber-700 hover:bg-amber-800 rounded-md transition-colors flex items-center justify-center"
2025-12-13 19:15:26 -05:00
>
< PlusCircle size = { 18 } className = "mr-1" /> Add Character
< /button>
< /form>
{ campaignCharacters . length === 0 && (
2026-04-25 20:25:34 -04:00
< p className = "text-sm text-stone-400" > No characters added yet . < /p>
2025-12-13 19:15:26 -05:00
)}
< ul className = "space-y-2" >
{ campaignCharacters . map ( character => (
2026-04-25 20:25:34 -04:00
< li key = { character . id } className = "flex justify-between items-center p-3 bg-stone-800 rounded-md" >
2025-12-13 19:15:26 -05:00
{ editingCharacter && editingCharacter . id === character . id ? (
< form
onSubmit = {( e ) => {
e . preventDefault ();
handleUpdateCharacter (
character . id ,
editingCharacter . name ,
editingCharacter . defaultMaxHp ,
editingCharacter . defaultInitMod
);
}}
className = "flex-grow flex flex-wrap gap-2 items-center"
>
< input
type = "text"
value = { editingCharacter . name }
onChange = {( e ) => setEditingCharacter ({ ... editingCharacter , name : e . target . value })}
2026-04-25 20:25:34 -04:00
className = "flex-grow min-w-[100px] px-2 py-1 bg-stone-700 border border-stone-600 rounded-md text-white"
2025-12-13 19:15:26 -05:00
/>
< input
type = "number"
value = { editingCharacter . defaultMaxHp }
onChange = {( e ) => setEditingCharacter ({ ... editingCharacter , defaultMaxHp : e . target . value })}
2026-04-25 20:25:34 -04:00
className = "w-20 px-2 py-1 bg-stone-700 border border-stone-600 rounded-md text-white"
2025-12-13 19:15:26 -05:00
title = "Default Max HP"
/>
< input
type = "number"
value = { editingCharacter . defaultInitMod }
onChange = {( e ) => setEditingCharacter ({ ... editingCharacter , defaultInitMod : e . target . value })}
2026-04-25 20:25:34 -04:00
className = "w-20 px-2 py-1 bg-stone-700 border border-stone-600 rounded-md text-white"
2025-12-13 19:15:26 -05:00
title = "Default Init Mod"
/>
< button type = "submit" className = "p-1 text-green-400 hover:text-green-300" >
< Save size = { 18 } />
< /button>
< button
type = "button"
onClick = {() => setEditingCharacter ( null )}
2026-04-25 20:25:34 -04:00
className = "p-1 text-stone-400 hover:text-stone-200"
2025-12-13 19:15:26 -05:00
>
< XCircle size = { 18 } />
< /button>
< /form>
) : (
<>
2026-04-25 20:25:34 -04:00
< span className = "text-stone-100" >
2025-12-13 19:15:26 -05:00
{ character . name }{ ' ' }
2026-04-25 20:25:34 -04:00
< span className = "text-xs text-stone-400" >
2025-12-13 19:15:26 -05:00
( HP : { character . defaultMaxHp || 'N/A' }, Init Mod : { formatInitMod ( character . defaultInitMod )})
< /span>
< /span>
< div className = "flex space-x-2" >
< button
onClick = {() => setEditingCharacter ({
id : character . id ,
name : character . name ,
defaultMaxHp : character . defaultMaxHp || DEFAULT_MAX_HP ,
defaultInitMod : character . defaultInitMod || DEFAULT_INIT_MOD
})}
2026-04-25 20:25:34 -04:00
className = "p-1 rounded transition-colors text-yellow-400 hover:text-yellow-300 bg-stone-700 hover:bg-stone-600"
2025-12-13 19:15:26 -05:00
aria - label = "Edit character"
>
< Edit3 size = { 18 } />
< /button>
< button
onClick = {() => requestDeleteCharacter ( character . id , character . name )}
2026-04-25 20:25:34 -04:00
className = "p-1 rounded transition-colors text-red-400 hover:text-red-300 bg-stone-700 hover:bg-stone-600"
2025-12-13 19:15:26 -05:00
aria - label = "Delete character"
>
< Trash2 size = { 18 } />
< /button>
< /div>
< />
)}
< /li>
))}
< /ul>
< />
)}
2025-05-25 21:19:22 -04:00
< /div>
2025-12-13 19:15:26 -05:00
< ConfirmationModal
isOpen = { showDeleteConfirm }
onClose = {() => setShowDeleteConfirm ( false )}
onConfirm = { confirmDeleteCharacter }
title = "Delete Character?"
message = { `Are you sure you want to remove " ${ itemToDelete ? . name } " from this campaign?` }
/>
2025-05-26 09:41:50 -04:00
< />
2025-05-25 21:19:22 -04:00
);
}
2025-12-13 19:15:26 -05:00
// ============================================================================
// PARTICIPANT MANAGER COMPONENT
// ============================================================================
2025-05-25 21:19:22 -04:00
2025-12-13 19:15:26 -05:00
function ParticipantManager ({ encounter , encounterPath , campaignCharacters }) {
2025-05-25 21:19:22 -04:00
const [ participantName , setParticipantName ] = useState ( '' );
2025-12-13 19:15:26 -05:00
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 ({});
2025-05-25 21:19:22 -04:00
const [ draggedItemId , setDraggedItemId ] = useState ( null );
2025-12-13 19:15:26 -05:00
const [ showDeleteConfirm , setShowDeleteConfirm ] = useState ( false );
const [ itemToDelete , setItemToDelete ] = useState ( null );
const [ lastRollDetails , setLastRollDetails ] = useState ( null );
2026-04-26 10:37:25 -04:00
const [ openConditionsId , setOpenConditionsId ] = useState ( null );
2025-12-13 19:15:26 -05:00
2025-05-25 21:19:22 -04:00
const participants = encounter . participants || [];
2025-05-26 21:48:28 -04:00
useEffect (() => {
if ( participantType === 'character' && selectedCharacterId ) {
const selectedChar = campaignCharacters . find ( c => c . id === selectedCharacterId );
2025-12-13 19:15:26 -05:00
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 );
2025-05-27 10:51:29 -04:00
}
2025-05-26 21:48:28 -04:00
}, [ selectedCharacterId , participantType , campaignCharacters ]);
2025-05-25 21:19:22 -04:00
const handleAddParticipant = async () => {
2025-12-13 19:15:26 -05:00
if ( ! db ) return ;
if ( participantType === 'monster' && ! participantName . trim ()) return ;
if ( participantType === 'character' && ! selectedCharacterId ) return ;
2025-05-25 21:19:22 -04:00
let nameToAdd = participantName . trim ();
2025-05-26 22:31:43 -04:00
const initiativeRoll = rollD20 ();
let modifier = 0 ;
2025-12-13 19:15:26 -05:00
let currentMaxHp = parseInt ( maxHp , 10 ) || DEFAULT_MAX_HP ;
2025-05-27 10:51:29 -04:00
let participantIsNpc = false ;
2025-05-26 21:48:28 -04:00
2025-05-25 21:19:22 -04:00
if ( participantType === 'character' ) {
const character = campaignCharacters . find ( c => c . id === selectedCharacterId );
2025-12-13 19:15:26 -05:00
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 ;
}
2025-05-25 21:19:22 -04:00
nameToAdd = character . name ;
2025-12-13 19:15:26 -05:00
currentMaxHp = character . defaultMaxHp || currentMaxHp ;
2025-05-26 22:31:43 -04:00
modifier = character . defaultInitMod || 0 ;
2025-12-13 19:15:26 -05:00
} else {
modifier = parseInt ( monsterInitMod , 10 ) || 0 ;
participantIsNpc = isNpc ;
2025-05-25 21:19:22 -04:00
}
2025-12-13 19:15:26 -05:00
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
2025-05-27 10:51:29 -04:00
};
2025-12-13 19:15:26 -05:00
try {
2026-06-28 21:05:39 -04:00
await storage . updateDoc ( encounterPath , {
2025-12-13 19:15:26 -05:00
participants : [... participants , newParticipant ]
});
2026-06-27 16:45:07 -04:00
logAction ( ` ${ nameToAdd } added to encounter (Initiative: ${ finalInitiative } )` , { encounterName : encounter . name }, {
encounterPath ,
updates : { participants : [... participants ] },
});
2025-12-13 19:15:26 -05:00
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." );
}
2025-05-25 21:19:22 -04:00
};
2025-05-26 21:48:28 -04:00
const handleAddAllCampaignCharacters = async () => {
if ( ! db || ! campaignCharacters || campaignCharacters . length === 0 ) return ;
2025-12-13 19:15:26 -05:00
const existingCharacterIds = participants
. filter ( p => p . type === 'character' && p . originalCharacterId )
. map ( p => p . originalCharacterId );
2025-05-26 21:48:28 -04:00
const newParticipants = campaignCharacters
2025-12-13 19:15:26 -05:00
. 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 {
2026-06-28 21:05:39 -04:00
await storage . updateDoc ( encounterPath , {
2025-12-13 19:15:26 -05:00
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." );
}
2025-05-26 21:48:28 -04:00
};
2025-05-25 21:19:22 -04:00
const handleUpdateParticipant = async ( updatedData ) => {
2025-05-25 22:21:45 -04:00
if ( ! db || ! editingParticipant ) return ;
2025-12-13 19:15:26 -05:00
const updatedParticipants = participants . map ( p =>
p . id === editingParticipant . id ? { ... p , ... updatedData } : p
);
try {
2026-06-28 21:05:39 -04:00
await storage . updateDoc ( encounterPath , { participants : updatedParticipants });
2025-12-13 19:15:26 -05:00
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 );
2025-05-25 21:19:22 -04:00
};
2025-12-13 19:15:26 -05:00
2025-05-26 09:41:50 -04:00
const confirmDeleteParticipant = async () => {
2025-12-13 19:15:26 -05:00
if ( ! db || ! itemToDelete ) return ;
const updatedParticipants = participants . filter ( p => p . id !== itemToDelete . id );
2026-06-27 16:45:07 -04:00
const deleteUndoData = {
encounterPath ,
updates : {
participants : [... participants ],
...( encounter . isStarted ? {
currentTurnParticipantId : encounter . currentTurnParticipantId ,
turnOrderIds : [...( encounter . turnOrderIds || [])],
} : {}),
},
};
2025-12-13 19:15:26 -05:00
try {
2026-06-28 21:05:39 -04:00
await storage . updateDoc ( encounterPath , {
2026-05-16 10:00:17 -04:00
participants : updatedParticipants ,
... computeTurnOrderAfterRemoval ( encounter , itemToDelete . id , updatedParticipants )
});
2026-06-27 16:45:07 -04:00
logAction ( ` ${ itemToDelete . name } removed from encounter` , { encounterName : encounter . name }, deleteUndoData );
2025-12-13 19:15:26 -05:00
} catch ( err ) {
console . error ( "Error deleting participant:" , err );
alert ( "Failed to delete participant. Please try again." );
}
setShowDeleteConfirm ( false );
setItemToDelete ( null );
2025-05-25 21:19:22 -04:00
};
2025-12-13 19:15:26 -05:00
2025-05-25 21:19:22 -04:00
const toggleParticipantActive = async ( participantId ) => {
2025-05-25 22:21:45 -04:00
if ( ! db ) return ;
2025-12-13 19:15:26 -05:00
2026-05-16 10:00:17 -04:00
const participant = participants . find ( p => p . id === participantId );
if ( ! participant ) return ;
const newIsActive = ! participant . isActive ;
2025-12-13 19:15:26 -05:00
const updatedParticipants = participants . map ( p =>
2026-05-16 10:00:17 -04:00
p . id === participantId ? { ... p , isActive : newIsActive } : p
2025-12-13 19:15:26 -05:00
);
2026-05-16 10:00:17 -04:00
const turnUpdates = newIsActive
? computeTurnOrderAfterAddition ( encounter , participantId )
: computeTurnOrderAfterRemoval ( encounter , participantId , updatedParticipants );
2025-12-13 19:15:26 -05:00
try {
2026-06-28 21:05:39 -04:00
await storage . updateDoc ( encounterPath , { participants : updatedParticipants , ... turnUpdates });
2026-06-27 16:45:07 -04:00
logAction ( ` ${ participant . name } ${ newIsActive ? 'reactivated' : 'deactivated' } ` , { encounterName : encounter . name }, {
encounterPath ,
updates : {
participants : [... participants ],
...( encounter . isStarted ? {
currentTurnParticipantId : encounter . currentTurnParticipantId ,
turnOrderIds : [...( encounter . turnOrderIds || [])],
} : {}),
},
});
2025-12-13 19:15:26 -05:00
} catch ( err ) {
console . error ( "Error toggling active state:" , err );
}
2025-05-25 21:19:22 -04:00
};
2025-12-13 19:15:26 -05:00
2025-05-25 21:19:22 -04:00
const applyHpChange = async ( participantId , changeType ) => {
2025-05-25 22:21:45 -04:00
if ( ! db ) return ;
2025-12-13 19:15:26 -05:00
2025-05-25 21:19:22 -04:00
const amountStr = hpChangeValues [ participantId ];
2025-12-13 19:15:26 -05:00
if ( ! amountStr || amountStr . trim () === '' ) return ;
2025-05-25 21:19:22 -04:00
const amount = parseInt ( amountStr , 10 );
2025-12-13 19:15:26 -05:00
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 ;
});
2026-05-16 10:00:17 -04:00
const turnUpdates = ( isDead && ! wasDead )
? computeTurnOrderAfterRemoval ( encounter , participantId , updatedParticipants )
: wasResurrected
? computeTurnOrderAfterAddition ( encounter , participantId )
: {};
2026-06-27 16:45:07 -04:00
const hpUndoData = {
encounterPath ,
updates : {
participants : [... participants ],
...(( isDead && ! wasDead ) || wasResurrected ? {
currentTurnParticipantId : encounter . currentTurnParticipantId ,
turnOrderIds : [...( encounter . turnOrderIds || [])],
} : {}),
},
};
2025-12-13 19:15:26 -05:00
try {
2026-06-28 21:05:39 -04:00
await storage . updateDoc ( encounterPath , { participants : updatedParticipants , ... turnUpdates });
2025-12-13 19:15:26 -05:00
setHpChangeValues ( prev => ({ ... prev , [ participantId ] : '' }));
2026-05-16 10:25:17 -04:00
const hpLine = ` ${ participant . currentHp } → ${ newHp } HP` ;
const deathSuffix = ( isDead && ! wasDead ) ? ( participant . type === 'character' ? ' — Unconscious' : ' — Defeated' ) : '' ;
const resurSuffix = wasResurrected ? ' — Revived' : '' ;
if ( changeType === 'damage' ) {
2026-06-27 16:45:07 -04:00
logAction ( ` ${ participant . name } took ${ amount } damage ( ${ hpLine } ) ${ deathSuffix } ` , { encounterName : encounter . name }, hpUndoData );
2026-05-16 10:25:17 -04:00
} else {
2026-06-27 16:45:07 -04:00
logAction ( ` ${ participant . name } healed for ${ amount } ( ${ hpLine } ) ${ resurSuffix } ` , { encounterName : encounter . name }, hpUndoData );
2026-05-16 10:25:17 -04:00
}
2025-12-13 19:15:26 -05:00
} 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 {
2026-06-28 21:05:39 -04:00
await storage . updateDoc ( encounterPath , { participants : updatedParticipants });
2025-12-13 19:15:26 -05:00
// Wait for animation to complete on player display (2 seconds) then remove participant
setTimeout ( async () => {
const finalParticipants = participants . filter ( p => p . id !== participantId );
try {
2026-06-28 21:05:39 -04:00
await storage . updateDoc ( encounterPath , { participants : finalParticipants });
2025-12-13 19:15:26 -05:00
} 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 {
2026-06-28 21:05:39 -04:00
await storage . updateDoc ( encounterPath , { participants : updatedParticipants });
2025-12-13 19:15:26 -05:00
} catch ( err ) {
console . error ( "Error updating death saves:" , err );
}
}
};
2026-04-26 10:37:25 -04:00
const toggleCondition = async ( participantId , conditionId ) => {
if ( ! db ) return ;
2026-05-16 10:25:17 -04:00
const participant = participants . find ( p => p . id === participantId );
if ( ! participant ) return ;
const wasActive = ( participant . conditions || []). includes ( conditionId );
2026-04-26 10:37:25 -04:00
const updatedParticipants = participants . map ( p => {
if ( p . id !== participantId ) return p ;
const current = p . conditions || [];
2026-05-16 10:25:17 -04:00
const next = wasActive
2026-04-26 10:37:25 -04:00
? current . filter ( c => c !== conditionId )
: [... current , conditionId ];
return { ... p , conditions : next };
});
try {
2026-06-28 21:05:39 -04:00
await storage . updateDoc ( encounterPath , { participants : updatedParticipants });
2026-05-16 10:25:17 -04:00
const cond = CONDITIONS . find ( c => c . id === conditionId );
const condLabel = cond ? ` ${ cond . label } ${ cond . emoji } ` : conditionId ;
2026-06-27 16:45:07 -04:00
logAction ( ` ${ participant . name } ${ wasActive ? 'lost' : 'gained' } ${ condLabel } ` , { encounterName : encounter . name }, {
encounterPath ,
updates : { participants : [... participants ] },
});
2026-04-26 10:37:25 -04:00
} catch ( err ) {
console . error ( "Error updating conditions:" , err );
}
};
2025-12-13 19:15:26 -05:00
const handleDragStart = ( e , id ) => {
setDraggedItemId ( id );
e . dataTransfer . effectAllowed = 'move' ;
2025-05-25 21:19:22 -04:00
};
2025-12-13 19:15:26 -05:00
const handleDragOver = ( e ) => {
e . preventDefault ();
e . dataTransfer . dropEffect = 'move' ;
};
2025-05-25 21:19:22 -04:00
const handleDrop = async ( e , targetId ) => {
2025-12-13 19:15:26 -05:00
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 {
2026-06-28 21:05:39 -04:00
await storage . updateDoc ( encounterPath , { participants : currentParticipants });
2025-12-13 19:15:26 -05:00
} catch ( err ) {
console . error ( "Error reordering participants:" , err );
}
setDraggedItemId ( null );
2025-05-25 21:19:22 -04:00
};
2025-12-13 19:15:26 -05:00
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 );
2025-05-25 21:19:22 -04:00
return (
2025-05-26 09:41:50 -04:00
<>
2026-04-25 20:25:34 -04:00
< div className = "p-3 bg-stone-900 rounded-md mt-4" >
2025-12-13 19:15:26 -05:00
< div className = "flex justify-between items-center mb-3" >
2026-04-25 18:37:55 -04:00
< h4 className = "text-lg font-medium text-amber-200 font-cinzel tracking-wide" > Add Participants < /h4>
2025-12-13 19:15:26 -05:00
< button
onClick = { handleAddAllCampaignCharacters }
2026-04-25 20:25:34 -04:00
className = "px-3 py-1.5 text-xs font-medium text-white bg-violet-700 hover:bg-violet-800 rounded-md transition-colors flex items-center"
2025-12-13 19:15:26 -05:00
disabled = { ! campaignCharacters || campaignCharacters . length === 0 || ( encounter . isStarted && ! encounter . isPaused )}
>
< Users2 size = { 16 } className = "mr-1.5" />
< Dices size = { 16 } className = "mr-1.5" /> Add All ( Roll Init )
< /button>
2025-05-25 21:19:22 -04:00
< /div>
2025-05-27 11:02:03 -04:00
2025-12-13 19:15:26 -05:00
{ /* Warning when combat is active */ }
{ encounter . isStarted && ! encounter . isPaused && (
< div className = "mb-4 p-3 bg-yellow-900 bg-opacity-30 border border-yellow-600 rounded-md flex items-start" >
< AlertTriangle size = { 20 } className = "text-yellow-400 mr-3 flex-shrink-0 mt-0.5" />
< div >
< p className = "text-yellow-300 font-semibold text-sm" > Combat is Running < /p>
< p className = "text-yellow-200 text-xs mt-1" >
2026-04-25 20:25:34 -04:00
Pause combat to add or remove participants . The turn order will be recalculated when combat is resumed .
2025-12-13 19:15:26 -05:00
< /p>
2025-05-27 11:02:03 -04:00
< /div>
2025-12-13 19:15:26 -05:00
< /div>
2025-05-26 22:31:43 -04:00
)}
2025-12-13 19:15:26 -05:00
< form
onSubmit = {( e ) => {
e . preventDefault ();
handleAddParticipant ();
}}
2026-04-25 20:25:34 -04:00
className = "grid grid-cols-1 md:grid-cols-6 gap-x-4 gap-y-2 mb-4 p-3 bg-stone-800 rounded items-end"
2025-12-13 19:15:26 -05:00
>
< div className = "md:col-span-2" >
2026-04-25 20:25:34 -04:00
< label className = "block text-sm font-medium text-stone-300" > Type < /label>
2025-12-13 19:15:26 -05:00
< select
value = { participantType }
onChange = {( e ) => {
setParticipantType ( e . target . value );
setSelectedCharacterId ( '' );
setIsNpc ( false );
}}
2026-04-25 20:25:34 -04:00
className = "mt-1 w-full px-3 py-2 bg-stone-700 border-stone-600 rounded text-white"
2025-12-13 19:15:26 -05:00
>
< option value = "monster" > Monster < /option>
< option value = "character" > Character < /option>
< /select>
< /div>
{ participantType === 'monster' ? (
<>
< div className = "md:col-span-4" >
2026-04-25 20:25:34 -04:00
< label htmlFor = "monsterName" className = "block text-sm font-medium text-stone-300" >
2025-12-13 19:15:26 -05:00
Monster Name
< /label>
< input
type = "text"
id = "monsterName"
value = { participantName }
onChange = {( e ) => setParticipantName ( e . target . value )}
placeholder = "e.g., Dire Wolf"
2026-04-25 20:25:34 -04:00
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"
2025-12-13 19:15:26 -05:00
/>
< /div>
< div className = "md:col-span-2" >
2026-04-25 20:25:34 -04:00
< label htmlFor = "monsterInitMod" className = "block text-sm font-medium text-stone-300" >
2025-12-13 19:15:26 -05:00
Init Mod
< /label>
< input
type = "number"
id = "monsterInitMod"
value = { monsterInitMod }
onChange = {( e ) => setMonsterInitMod ( e . target . value )}
2026-04-25 20:25:34 -04:00
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"
2025-12-13 19:15:26 -05:00
/>
< /div>
< div className = "md:col-span-2" >
2026-04-25 20:25:34 -04:00
< label htmlFor = "monsterMaxHp" className = "block text-sm font-medium text-stone-300" >
2025-12-13 19:15:26 -05:00
Max HP
< /label>
< input
type = "number"
id = "monsterMaxHp"
value = { maxHp }
onChange = {( e ) => setMaxHp ( e . target . value )}
2026-04-25 20:25:34 -04:00
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"
2025-12-13 19:15:26 -05:00
/>
< /div>
< div className = "md:col-span-2 flex items-center pt-5" >
< input
type = "checkbox"
id = "isNpc"
checked = { isNpc }
onChange = {( e ) => setIsNpc ( e . target . checked )}
2026-04-25 20:25:34 -04:00
className = "h-4 w-4 text-violet-600 border-stone-400 rounded focus:ring-violet-500"
2025-12-13 19:15:26 -05:00
/>
2026-04-25 20:25:34 -04:00
< label htmlFor = "isNpc" className = "ml-2 block text-sm text-stone-300" >
2025-12-13 19:15:26 -05:00
Is NPC ?
< /label>
< /div>
< />
) : (
<>
< div className = "md:col-span-4" >
2026-04-25 20:25:34 -04:00
< label className = "block text-sm font-medium text-stone-300" > Select Character < /label>
2025-12-13 19:15:26 -05:00
< select
value = { selectedCharacterId }
onChange = {( e ) => setSelectedCharacterId ( e . target . value )}
2026-04-25 20:25:34 -04:00
className = "mt-1 w-full px-3 py-2 bg-stone-700 border-stone-600 rounded text-white"
2025-12-13 19:15:26 -05:00
>
< option value = "" >-- Select from Campaign --< /option>
{ campaignCharacters . map ( c => (
< option key = { c . id } value = { c . id } >
{ c . name } ( HP : { c . defaultMaxHp || 'N/A' }, Mod : { formatInitMod ( c . defaultInitMod )})
< /option>
))}
2025-05-27 11:02:03 -04:00
< /select>
2025-05-25 21:19:22 -04:00
< /div>
2025-12-13 19:15:26 -05:00
< div className = "md:col-span-2" >
2026-04-25 20:25:34 -04:00
< label className = "block text-sm font-medium text-stone-300" > Max HP ( Encounter ) < /label>
2025-12-13 19:15:26 -05:00
< input
type = "number"
value = { maxHp }
onChange = {( e ) => setMaxHp ( e . target . value )}
2026-04-25 20:25:34 -04:00
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"
2025-12-13 19:15:26 -05:00
/>
2025-05-25 21:19:22 -04:00
< /div>
2025-12-13 19:15:26 -05:00
< />
)}
2025-05-25 21:19:22 -04:00
2025-12-13 19:15:26 -05:00
< div className = "md:col-span-6 flex justify-end mt-2" >
< button
type = "submit"
disabled = { encounter . isStarted && ! encounter . isPaused }
2026-04-25 20:25:34 -04:00
className = { `px-4 py-2 text-sm font-medium text-white rounded-md transition-colors flex items-center ${ encounter . isStarted && ! encounter . isPaused ? 'bg-stone-600 cursor-not-allowed opacity-50' : 'bg-red-700 hover:bg-red-800' } ` }
2025-12-13 19:15:26 -05:00
title = { encounter . isStarted && ! encounter . isPaused ? 'Pause combat to add participants' : 'Add participant and roll initiative' }
>
< Dices size = { 18 } className = "mr-1.5" /> Add to Encounter ( Roll Init )
< /button>
< /div>
< /form>
2025-05-27 10:51:29 -04:00
2025-12-13 19:15:26 -05:00
{ lastRollDetails && (
< p className = "text-sm text-green-400 mt-2 mb-2 text-center" >
2026-04-25 20:25:34 -04:00
{ lastRollDetails . name } ({ lastRollDetails . type === 'character' ? 'Character' : lastRollDetails . type === 'monster' ? 'Monster' : lastRollDetails . type })
: Rolled d20 ({ lastRollDetails . roll }) { formatInitMod ( lastRollDetails . mod )} = { lastRollDetails . total } Initiative
2025-12-13 19:15:26 -05:00
< /p>
2025-05-27 10:51:29 -04:00
)}
2025-12-13 19:15:26 -05:00
2026-04-25 20:25:34 -04:00
{ participants . length === 0 && < p className = "text-sm text-stone-400" > No participants added yet . < /p>}
2025-12-13 19:15:26 -05:00
< ul className = "space-y-2" >
{ 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' ;
2026-04-25 20:25:34 -04:00
let bgColor = p . type === 'character' ? 'bg-indigo-950' : ( p . isNpc ? 'bg-stone-700' : 'bg-[#8e351c]' );
2025-12-13 19:15:26 -05:00
if ( isCurrentTurn && ! encounter . isPaused ) bgColor = 'bg-green-600' ;
const isDead = p . currentHp === 0 ;
return (
< li
key = { p . id }
draggable = { isDraggable }
onDragStart = { isDraggable ? ( e ) => 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' : '' } ` }
>
< div className = "flex-1 flex items-start sm:items-center" >
{ isDraggable && (
< ChevronsUpDown
size = { 18 }
2026-04-25 20:25:34 -04:00
className = "mr-2 text-stone-400 flex-shrink-0"
2025-12-13 19:15:26 -05:00
title = "Drag to reorder in tie"
/>
)}
< div className = "flex-1" >
< p className = "font-semibold text-lg text-white" >
{ isDead && < span className = "mr-2" > ☠️ < /span>}
{ p . name } < span className = "text-xs" > ({ participantDisplayType }) < /span>
{ isCurrentTurn && ! encounter . isPaused && (
< span className = "ml-2 px-2 py-0.5 bg-yellow-400 text-black text-xs font-bold rounded-full inline-flex items-center" >
< Zap size = { 12 } className = "mr-1" /> CURRENT
< /span>
)}
2026-04-25 20:25:34 -04:00
{ isDead && < span className = "ml-2 text-xs text-red-300 font-semibold" > { p . type === 'character' ? '(Unconscious)' : '(Dead)' } < /span>}
2025-12-13 19:15:26 -05:00
< /p>
2026-04-25 20:25:34 -04:00
< p className = { `text-sm ${ isCurrentTurn && ! encounter . isPaused ? 'text-green-100' : 'text-stone-200' } ` } >
2025-12-13 19:15:26 -05:00
Init : { p . initiative } | HP : { p . currentHp } / { p . maxHp }
< /p>
2026-04-25 20:25:34 -04:00
{ /* Death Saves - only player characters make death saving throws */ }
{ isDead && encounter . isStarted && p . type === 'character' && (
2025-12-13 19:15:26 -05:00
< div className = "mt-2 flex items-center space-x-2" >
< span className = "text-xs text-red-300 font-medium" > Death Saves :< /span>
{[ 1 , 2 , 3 ]. map ( saveNum => (
< button
key = { saveNum }
onClick = {() => handleDeathSaveChange ( p . id , saveNum )}
2026-04-25 20:25:34 -04:00
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' : '' } ` }
2025-12-13 19:15:26 -05:00
title = { `Death save ${ saveNum } ` }
>
{( p . deathSaves || 0 ) >= saveNum && < span className = "text-white text-sm" > ✕ < /span>}
< /button>
))}
< /div>
)}
2026-04-26 10:37:25 -04:00
{ /* Active condition badges */ }
{( p . conditions || []). length > 0 && (
< div className = "mt-1 flex flex-wrap gap-1" >
{( p . conditions || []). map ( cId => {
const cond = CONDITIONS . find ( c => c . id === cId );
return cond ? (
< span
key = { cId }
className = "inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-full bg-purple-900 border border-purple-600 text-xs text-purple-200 cursor-pointer hover:bg-purple-800"
title = { `Remove ${ cond . label } ` }
onClick = {() => toggleCondition ( p . id , cId )}
>
{ cond . emoji } { cond . label }
< /span>
) : null ;
})}
< /div>
)}
{ /* Expandable conditions picker */ }
{ openConditionsId === p . id && (
< div className = "mt-2 p-2 bg-stone-900 rounded-md border border-stone-600" >
< p className = "text-xs text-stone-400 mb-1 font-medium" > Toggle Conditions < /p>
< div className = "flex flex-wrap gap-1" >
{ CONDITIONS . map ( cond => {
const active = ( p . conditions || []). includes ( cond . id );
return (
< button
key = { cond . id }
onClick = {() => 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 }
< /button>
);
})}
< /div>
< /div>
)}
2025-12-13 19:15:26 -05:00
< /div>
< /div>
< div className = "flex flex-wrap items-center space-x-2 mt-2 sm:mt-0" >
{ encounter . isStarted && (
2026-04-25 20:25:34 -04:00
< div className = "flex items-center space-x-1 bg-stone-800 p-1 rounded-md" >
2025-12-13 19:15:26 -05:00
< input
type = "number"
placeholder = "HP"
value = { hpChangeValues [ p . id ] || '' }
onChange = {( e ) => setHpChangeValues ( prev => ({ ... prev , [ p . id ] : e . target . value }))}
2026-04-25 20:25:34 -04:00
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"
2025-12-13 19:15:26 -05:00
aria - label = { `HP change for ${ p . name } ` }
/>
{ ! isDead && (
< button
onClick = {() => applyHpChange ( p . id , 'damage' )}
className = "p-1 bg-red-500 hover:bg-red-600 text-white rounded-md text-xs"
title = "Damage"
>
< HeartCrack size = { 16 } />
< /button>
)}
< button
onClick = {() => applyHpChange ( p . id , 'heal' )}
2026-04-25 20:25:34 -04:00
className = "p-1 bg-emerald-600 hover:bg-emerald-700 text-white rounded-md text-xs"
2025-12-13 19:15:26 -05:00
title = { isDead ? "Heal / Revive" : "Heal" }
>
< HeartPulse size = { 16 } />
< /button>
< /div>
)}
{ ! isDead && (
< button
onClick = {() => toggleParticipantActive ( p . id )}
2026-04-25 20:25:34 -04:00
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` }
2025-12-13 19:15:26 -05:00
title = { p . isActive ? "Mark Inactive" : "Mark Active" }
>
{ p . isActive ? < UserCheck size = { 18 } /> : < UserX size = { 18 } /> }
< /button>
)}
2026-04-26 10:37:25 -04:00
< button
onClick = {() => 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"
>
< span className = "text-base leading-none" > ✨ < /span>
< /button>
2025-12-13 19:15:26 -05:00
< button
onClick = {() => setEditingParticipant ( p )}
2026-04-25 20:25:34 -04:00
className = "p-1 rounded transition-colors text-yellow-400 hover:text-yellow-300 bg-stone-700 hover:bg-stone-600"
2025-12-13 19:15:26 -05:00
title = "Edit"
>
< Edit3 size = { 18 } />
< /button>
< button
onClick = {() => requestDeleteParticipant ( p . id , p . name )}
2026-04-25 20:25:34 -04:00
className = "p-1 rounded transition-colors text-red-400 hover:text-red-300 bg-stone-700 hover:bg-stone-600"
2025-12-13 19:15:26 -05:00
title = "Remove"
>
< Trash2 size = { 18 } />
< /button>
< /div>
< /li>
);
})}
< /ul>
{ editingParticipant && (
< EditParticipantModal
participant = { editingParticipant }
onClose = {() => setEditingParticipant ( null )}
onSave = { handleUpdateParticipant }
/>
)}
< /div>
< ConfirmationModal
isOpen = { showDeleteConfirm }
onClose = {() => setShowDeleteConfirm ( false )}
onConfirm = { confirmDeleteParticipant }
title = "Delete Participant?"
message = { `Are you sure you want to remove " ${ itemToDelete ? . name } " from this encounter?` }
/>
< />
2025-05-25 21:19:22 -04:00
);
}
2025-12-13 19:15:26 -05:00
// ============================================================================
// INITIATIVE CONTROLS COMPONENT
// ============================================================================
function InitiativeControls ({ campaignId , encounter , encounterPath }) {
const [ showEndConfirm , setShowEndConfirm ] = useState ( false );
2026-05-16 09:30:35 -04:00
const { data : activeDisplayData } = useFirestoreDocument ( getPath . activeDisplay ());
const hidePlayerHp = activeDisplayData ? . hidePlayerHp ?? true ;
const handleToggleHidePlayerHp = async () => {
if ( ! db ) return ;
try {
2026-06-28 21:05:39 -04:00
await storage . setDoc ( getPath . activeDisplay (), { hidePlayerHp : ! hidePlayerHp }, { merge : true });
2026-05-16 09:30:35 -04:00
} catch ( err ) {
console . error ( "Error toggling hidePlayerHp:" , err );
}
};
2025-12-13 19:15:26 -05:00
2025-05-25 21:19:22 -04:00
const handleStartEncounter = async () => {
2025-12-13 19:15:26 -05:00
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 );
2025-05-25 21:19:22 -04:00
try {
2026-06-28 21:05:39 -04:00
await storage . updateDoc ( encounterPath , {
2025-12-13 19:15:26 -05:00
isStarted : true ,
isPaused : false ,
round : 1 ,
currentTurnParticipantId : sortedParticipants [ 0 ]. id ,
turnOrderIds : sortedParticipants . map ( p => p . id )
});
2026-06-28 21:05:39 -04:00
await storage . setDoc ( getPath . activeDisplay (), {
2025-12-13 19:15:26 -05:00
activeCampaignId : campaignId ,
activeEncounterId : encounter . id
}, { merge : true });
2026-06-27 16:45:07 -04:00
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 || [])],
},
});
2025-05-25 21:19:22 -04:00
console . log ( "Encounter started and set as active display." );
2025-12-13 19:15:26 -05:00
} catch ( err ) {
console . error ( "Error starting encounter:" , err );
alert ( "Failed to start encounter. Please try again." );
}
2025-05-25 21:19:22 -04:00
};
2025-12-13 19:15:26 -05:00
2025-05-26 21:34:37 -04:00
const handleTogglePause = async () => {
if ( ! db || ! encounter || ! encounter . isStarted ) return ;
2025-12-13 19:15:26 -05:00
2025-05-26 21:34:37 -04:00
const newPausedState = ! encounter . isPaused ;
let newTurnOrderIds = encounter . turnOrderIds ;
2025-12-13 19:15:26 -05:00
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 {
2026-06-28 21:05:39 -04:00
await storage . updateDoc ( encounterPath , {
2025-12-13 19:15:26 -05:00
isPaused : newPausedState ,
turnOrderIds : newTurnOrderIds
});
2026-06-27 16:45:07 -04:00
logAction ( `Combat ${ newPausedState ? 'paused' : 'resumed' } : " ${ encounter . name } "` , { encounterName : encounter . name }, {
encounterPath ,
updates : {
2026-06-27 17:19:52 -04:00
isPaused : encounter . isPaused ?? false ,
2026-06-27 16:45:07 -04:00
turnOrderIds : [...( encounter . turnOrderIds || [])],
},
});
2025-12-13 19:15:26 -05:00
} catch ( err ) {
console . error ( "Error toggling pause state:" , err );
2025-05-26 21:34:37 -04:00
}
};
2025-12-13 19:15:26 -05:00
2025-05-25 21:19:22 -04:00
const handleNextTurn = async () => {
2025-12-13 19:15:26 -05:00
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." );
2026-06-28 21:05:39 -04:00
await storage . updateDoc ( encounterPath , {
2025-12-13 19:15:26 -05:00
isStarted : false ,
isPaused : false ,
currentTurnParticipantId : null ,
round : encounter . round
});
return ;
}
2026-05-16 10:00:17 -04:00
let currentIndex = activePsInOrder . findIndex ( p => p . id === encounter . currentTurnParticipantId );
2025-05-25 21:19:22 -04:00
let nextRound = encounter . round ;
2025-12-13 19:15:26 -05:00
2026-05-16 10:00:17 -04:00
// 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 ;
2026-06-27 16:57:08 -04:00
let newTurnOrderIds = encounter . turnOrderIds ;
2026-05-16 10:00:17 -04:00
2025-12-13 19:15:26 -05:00
if ( nextIndex === 0 && currentIndex !== - 1 ) {
nextRound += 1 ;
2026-06-27 16:57:08 -04:00
// 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 );
2025-12-13 19:15:26 -05:00
}
2026-06-27 16:57:08 -04:00
// 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 ;
2025-12-13 19:15:26 -05:00
try {
2026-06-28 21:05:39 -04:00
await storage . updateDoc ( encounterPath , {
2026-06-27 16:57:08 -04:00
currentTurnParticipantId : nextParticipant . id ,
round : nextRound ,
turnOrderIds : newTurnOrderIds ,
2025-12-13 19:15:26 -05:00
});
2026-06-27 16:57:08 -04:00
logAction ( ` ${ nextParticipant . name } 's turn (Round ${ nextRound } )` , { encounterName : encounter . name }, {
2026-06-27 16:45:07 -04:00
encounterPath ,
updates : {
currentTurnParticipantId : encounter . currentTurnParticipantId ,
round : encounter . round ,
2026-06-27 16:57:08 -04:00
turnOrderIds : [... encounter . turnOrderIds ],
2026-06-27 16:45:07 -04:00
},
});
2025-12-13 19:15:26 -05:00
} catch ( err ) {
console . error ( "Error advancing turn:" , err );
}
2025-05-25 21:19:22 -04:00
};
2025-12-13 19:15:26 -05:00
2025-05-26 09:41:50 -04:00
const confirmEndEncounter = async () => {
2025-05-25 22:21:45 -04:00
if ( ! db ) return ;
2025-12-13 19:15:26 -05:00
2025-05-25 21:19:22 -04:00
try {
2026-06-28 21:05:39 -04:00
await storage . updateDoc ( encounterPath , {
2025-12-13 19:15:26 -05:00
isStarted : false ,
isPaused : false ,
currentTurnParticipantId : null ,
round : 0 ,
turnOrderIds : []
});
2026-06-28 21:05:39 -04:00
await storage . setDoc ( getPath . activeDisplay (), {
2025-12-13 19:15:26 -05:00
activeCampaignId : null ,
activeEncounterId : null
}, { merge : true });
2026-06-27 16:45:07 -04:00
logAction ( `Combat ended: " ${ encounter . name } "` , { encounterName : encounter . name }, {
encounterPath ,
updates : {
2026-06-27 17:19:52 -04:00
isStarted : encounter . isStarted ?? false ,
isPaused : encounter . isPaused ?? false ,
round : encounter . round ?? 0 ,
currentTurnParticipantId : encounter . currentTurnParticipantId ?? null ,
2026-06-27 16:45:07 -04:00
turnOrderIds : [...( encounter . turnOrderIds || [])],
},
});
2025-05-26 07:59:05 -04:00
console . log ( "Encounter ended and deactivated from Player Display." );
2025-12-13 19:15:26 -05:00
} catch ( err ) {
console . error ( "Error ending encounter:" , err );
}
setShowEndConfirm ( false );
2025-05-25 21:19:22 -04:00
};
2025-12-13 19:15:26 -05:00
2025-05-25 21:19:22 -04:00
if ( ! encounter || ! encounter . participants ) return null ;
2025-12-13 19:15:26 -05:00
2025-05-25 21:19:22 -04:00
return (
2025-05-26 09:41:50 -04:00
<>
2026-04-25 20:25:34 -04:00
< div className = "lg:sticky lg:top-4 p-4 bg-stone-900 rounded-md shadow-lg" >
2026-04-25 18:37:55 -04:00
< h4 className = "text-lg font-medium text-amber-200 mb-4 text-center font-cinzel tracking-wide" > Combat Controls < /h4>
2025-12-13 19:15:26 -05:00
< div className = "flex flex-col gap-3" >
{ ! encounter . isStarted ? (
< button
onClick = { handleStartEncounter }
2026-04-25 20:25:34 -04:00
className = "w-full px-4 py-3 text-sm font-medium text-white bg-red-700 hover:bg-red-800 rounded-md transition-colors flex items-center justify-center"
2025-12-13 19:15:26 -05:00
disabled = { ! encounter . participants || encounter . participants . filter ( p => p . isActive ). length === 0 }
>
< PlayIcon size = { 18 } className = "mr-2" /> Start Combat
2025-05-26 21:34:37 -04:00
< /button>
2025-12-13 19:15:26 -05:00
) : (
<>
< button
onClick = { handleTogglePause }
2026-04-25 20:25:34 -04:00
className = { `w-full px-4 py-3 text-sm font-medium text-white rounded-md transition-colors flex items-center justify-center ${ encounter . isPaused ? 'bg-red-700 hover:bg-red-800' : 'bg-amber-600 hover:bg-amber-700' } ` }
title = { encounter . isPaused
? 'Resume combat from the current turn. The initiative order will be recalculated to include any participants added while paused.'
: 'Pause combat to freeze the turn order. While paused, you can add or remove participants, adjust HP, and edit initiative values. The turn order will be recalculated when you resume.' }
2025-12-13 19:15:26 -05:00
>
{ encounter . isPaused ? < PlayIcon size = { 18 } className = "mr-2" /> : < PauseIcon size = { 18 } className = "mr-2" /> }
2026-04-25 20:25:34 -04:00
{ encounter . isPaused ? 'Resume Combat' : 'Pause Combat' }
2025-12-13 19:15:26 -05:00
< /button>
< button
onClick = { handleNextTurn }
2026-04-25 20:25:34 -04:00
className = "w-full px-4 py-3 text-sm font-medium text-white bg-purple-700 hover:bg-purple-800 rounded-md transition-colors flex items-center justify-center"
2025-12-13 19:15:26 -05:00
disabled = { ! encounter . currentTurnParticipantId || encounter . isPaused }
>
< SkipForwardIcon size = { 18 } className = "mr-2" /> Next Turn
< /button>
< button
onClick = {() => 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"
>
< StopCircleIcon size = { 18 } className = "mr-2" /> End Combat
< /button>
{ /* Round Counter */ }
2026-04-25 20:25:34 -04:00
< div className = "mt-2 pt-3 border-t border-stone-700" >
2026-04-25 18:37:55 -04:00
< p className = "text-center text-lg font-semibold text-amber-300 font-cinzel" > Round : { encounter . round } < /p>
2025-12-13 19:15:26 -05:00
{ encounter . isPaused && (
< p className = "text-center text-sm text-yellow-400 font-semibold mt-1" > ( Paused ) < /p>
)}
< /div>
< />
)}
< /div>
2026-05-16 09:30:35 -04:00
{ /* Display Settings */ }
< div className = "mt-3 pt-3 border-t border-stone-700" >
< h5 className = "text-xs font-semibold text-stone-400 uppercase tracking-wider mb-2" > Player Display < /h5>
< label className = "flex items-center justify-between cursor-pointer gap-2" >
< span className = "text-sm text-stone-300" > Hide player HP < /span>
< button
role = "switch"
aria - checked = { hidePlayerHp }
onClick = { handleToggleHidePlayerHp }
className = { `relative inline-flex h-5 w-9 flex-shrink-0 rounded-full border-2 border-transparent transition-colors focus:outline-none ${ hidePlayerHp ? 'bg-amber-600' : 'bg-stone-600' } ` }
>
< span className = { `inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${ hidePlayerHp ? 'translate-x-4' : 'translate-x-0' } ` } />
< /button>
< /label>
< /div>
2025-12-13 19:15:26 -05:00
< /div>
< ConfirmationModal
isOpen = { showEndConfirm }
onClose = {() => 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 {
2026-06-28 21:05:39 -04:00
await storage . setDoc ( ` ${ getPath . encounters ( campaignId ) } / ${ newEncounterId } ` , {
2025-12-13 19:15:26 -05:00
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 {
2026-06-28 21:05:39 -04:00
await storage . deleteDoc ( getPath . encounter ( campaignId , encounterId ));
2025-12-13 19:15:26 -05:00
if ( selectedEncounterId === encounterId ) {
setSelectedEncounterId ( null );
}
if ( activeDisplayInfo && activeDisplayInfo . activeEncounterId === encounterId ) {
2026-06-28 21:05:39 -04:00
await storage . updateDoc ( getPath . activeDisplay (), {
2025-12-13 19:15:26 -05:00
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 ) {
2026-06-28 21:05:39 -04:00
await storage . setDoc ( getPath . activeDisplay (), {
2025-12-13 19:15:26 -05:00
activeCampaignId : null ,
activeEncounterId : null ,
}, { merge : true });
} else {
2026-06-28 21:05:39 -04:00
await storage . setDoc ( getPath . activeDisplay (), {
2025-12-13 19:15:26 -05:00
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 ) {
2026-04-25 20:25:34 -04:00
return < p className = "text-center text-stone-300 mt-4" > Loading encounters ... < /p>;
2025-12-13 19:15:26 -05:00
}
return (
<>
2026-04-25 20:25:34 -04:00
< div className = "mt-6 p-4 bg-stone-900 rounded-lg shadow" >
2025-12-13 19:15:26 -05:00
< div className = "flex justify-between items-center mb-3" >
2026-04-25 18:37:55 -04:00
< h3 className = "text-xl font-semibold text-amber-300 font-cinzel tracking-wide flex items-center" >
2025-12-13 19:15:26 -05:00
< Swords size = { 24 } className = "mr-2" /> Encounters
< /h3>
< button
onClick = {() => setShowCreateModal ( true )}
2026-04-25 20:25:34 -04:00
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"
2025-12-13 19:15:26 -05:00
>
< PlusCircle size = { 18 } className = "mr-1" /> Create Encounter
< /button>
< /div>
{( ! encounters || encounters . length === 0 ) && (
2026-04-25 20:25:34 -04:00
< p className = "text-sm text-stone-400" > No encounters yet . < /p>
2025-12-13 19:15:26 -05:00
)}
< div className = "space-y-3" >
{ encounters ? . map ( encounter => {
const isLive = activeDisplayInfo &&
activeDisplayInfo . activeCampaignId === campaignId &&
activeDisplayInfo . activeEncounterId === encounter . id ;
return (
< div
key = { encounter . id }
2026-04-25 20:25:34 -04:00
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' : '' } ` }
2025-12-13 19:15:26 -05:00
>
< div className = "flex justify-between items-center" >
< div onClick = {() => setSelectedEncounterId ( encounter . id )} className = "cursor-pointer flex-grow" >
< h4 className = "font-medium text-white" > { encounter . name } < /h4>
2026-04-25 20:25:34 -04:00
< p className = "text-xs text-stone-300" >
2025-12-13 19:15:26 -05:00
Participants : { encounter . participants ? . length || 0 }
< /p>
{ isLive && (
< span className = "text-xs text-green-400 font-semibold block mt-1" >
LIVE ON PLAYER DISPLAY
< /span>
)}
< /div>
< div className = "flex items-center space-x-2" >
< button
onClick = {() => handleTogglePlayerDisplay ( encounter . id )}
2026-04-25 20:25:34 -04:00
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' } ` }
2025-12-13 19:15:26 -05:00
title = { isLive ? "Deactivate for Player Display" : "Activate for Player Display" }
>
{ isLive ? < EyeOff size = { 18 } /> : < Eye size = { 18 } /> }
< /button>
< button
onClick = {( e ) => {
e . stopPropagation ();
requestDeleteEncounter ( encounter . id , encounter . name );
}}
2026-04-25 20:25:34 -04:00
className = "p-1 rounded transition-colors text-red-400 hover:text-red-300 bg-stone-700 hover:bg-stone-600"
2025-12-13 19:15:26 -05:00
title = "Delete Encounter"
>
< Trash2 size = { 18 } />
< /button>
< /div>
< /div>
< /div>
);
})}
< /div>
{ showCreateModal && (
< Modal onClose = {() => setShowCreateModal ( false )} title = "Create New Encounter" >
< CreateEncounterForm
onCreate = { handleCreateEncounter }
onCancel = {() => setShowCreateModal ( false )}
/>
< /Modal>
)}
{ selectedEncounter && (
2026-04-25 20:25:34 -04:00
< div className = "mt-6 p-4 bg-stone-900 rounded-lg shadow-inner" >
2026-04-25 18:37:55 -04:00
< h3 className = "text-xl font-semibold text-amber-300 mb-3 font-cinzel tracking-wide" >
2025-12-13 19:15:26 -05:00
Managing Encounter : { selectedEncounter . name }
< /h3>
< div className = "flex flex-col lg:flex-row gap-4" >
{ /* Combat Controls - Left Side (Sticky on large screens) */ }
< div className = "lg:w-64 flex-shrink-0" >
< InitiativeControls
campaignId = { campaignId }
encounter = { selectedEncounter }
encounterPath = { getPath . encounter ( campaignId , selectedEncounter . id )}
/>
< /div>
{ /* Participant Manager - Right Side */ }
< div className = "flex-grow" >
< ParticipantManager
encounter = { selectedEncounter }
encounterPath = { getPath . encounter ( campaignId , selectedEncounter . id )}
campaignCharacters = { campaignCharacters }
/>
< /div>
< /div>
< /div>
2025-05-25 21:19:22 -04:00
)}
< /div>
2025-12-13 19:15:26 -05:00
< ConfirmationModal
isOpen = { showDeleteConfirm }
onClose = {() => setShowDeleteConfirm ( false )}
onConfirm = { confirmDeleteEncounter }
title = "Delete Encounter?"
message = { `Are you sure you want to delete the encounter " ${ itemToDelete ? . name } "? This action cannot be undone.` }
/>
2025-05-26 09:41:50 -04:00
< />
2025-05-25 21:19:22 -04:00
);
}
2025-12-13 19:15:26 -05:00
// ============================================================================
// 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 {
2026-06-28 21:05:39 -04:00
const encounters = await storage . getCollection ( getPath . encounters ( campaign . id ));
encounterCount = encounters . length ;
2025-12-13 19:15:26 -05:00
} 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 {
2026-06-28 21:05:39 -04:00
await storage . setDoc ( getPath . campaign ( newCampaignId ), {
2025-12-13 19:15:26 -05:00
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 );
2026-06-28 21:05:39 -04:00
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 );
2025-12-13 19:15:26 -05:00
2026-06-28 21:05:39 -04:00
await storage . deleteDoc ( getPath . campaign ( campaignId ));
2025-12-13 19:15:26 -05:00
if ( selectedCampaignId === campaignId ) {
setSelectedCampaignId ( null );
}
2026-06-28 21:05:39 -04:00
const activeDisplay = await storage . getDoc ( getPath . activeDisplay ());
2025-12-13 19:15:26 -05:00
2026-06-28 21:05:39 -04:00
if ( activeDisplay && activeDisplay . activeCampaignId === campaignId ) {
await storage . updateDoc ( getPath . activeDisplay (), {
2025-12-13 19:15:26 -05:00
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 ) {
2026-04-25 20:25:34 -04:00
return < p className = "text-center text-stone-300" > Loading campaigns ... < /p>;
2025-12-13 19:15:26 -05:00
}
if ( campaignsError ) {
return (
< p className = "text-center text-red-400" >
Error loading campaigns : { campaignsError . message || String ( campaignsError )}
< /p>
);
}
return (
<>
< div className = "space-y-6" >
< div >
< div className = "flex justify-between items-center mb-4" >
2026-04-25 18:37:55 -04:00
< h2 className = "text-2xl font-semibold text-amber-300 font-cinzel tracking-wide" > Campaigns < /h2>
2025-12-13 19:15:26 -05:00
< button
onClick = {() => setShowCreateModal ( true )}
2026-04-25 20:25:34 -04:00
className = "bg-red-700 hover:bg-red-800 text-white font-bold py-2 px-4 rounded-lg flex items-center transition-colors"
2025-12-13 19:15:26 -05:00
>
< PlusCircle size = { 20 } className = "mr-2" /> Create Campaign
< /button>
< /div>
{ campaignsWithDetails . length === 0 && ! isLoadingCampaigns && (
2026-04-25 20:25:34 -04:00
< p className = "text-stone-400" > No campaigns yet . Create one to get started !< /p>
2025-12-13 19:15:26 -05:00
)}
< div className = "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4" >
{ campaignsWithDetails . map ( campaign => {
const cardStyle = campaign . playerDisplayBackgroundUrl
? { backgroundImage : `url( ${ campaign . playerDisplayBackgroundUrl } )` }
: {};
2026-04-25 20:25:34 -04:00
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' } ` ;
2025-12-13 19:15:26 -05:00
return (
< div
key = { campaign . id }
onClick = {() => setSelectedCampaignId ( campaign . id )}
className = { cardClasses }
style = { cardStyle }
>
< div
className = { `relative z-10 flex flex-col justify-between h-full ${ campaign . playerDisplayBackgroundUrl ? 'bg-black bg-opacity-60 p-3' : 'p-4' } ` }
>
< div >
< h3 className = "text-xl font-semibold text-white" > { campaign . name } < /h3>
2026-04-25 20:25:34 -04:00
< div className = "text-xs text-stone-100 mt-1 space-x-3" >
2025-12-13 19:15:26 -05:00
< span className = "inline-flex items-center" >
< Users size = { 12 } className = "mr-1" /> { campaign . characters ? . length || 0 } Characters
< /span>
< span className = "inline-flex items-center" >
< Swords size = { 12 } className = "mr-1" /> { campaign . encounterCount === undefined ? '...' : campaign . encounterCount } Encounters
< /span>
< /div>
< /div>
< button
onClick = {( e ) => {
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"
>
< Trash2 size = { 14 } className = "mr-1" /> Delete
< /button>
< /div>
< /div>
);
})}
< /div>
< /div>
{ showCreateModal && (
< Modal onClose = {() => setShowCreateModal ( false )} title = "Create New Campaign" >
< CreateCampaignForm
onCreate = { handleCreateCampaign }
onCancel = {() => setShowCreateModal ( false )}
/>
< /Modal>
)}
{ selectedCampaign && (
2026-04-25 20:25:34 -04:00
< div className = "mt-6 p-6 bg-stone-900 rounded-lg shadow-xl" >
2026-04-25 18:37:55 -04:00
< h2 className = "text-2xl font-semibold text-amber-300 mb-4 font-cinzel tracking-wide" >
2025-12-13 19:15:26 -05:00
Managing : { selectedCampaign . name }
< /h2>
< CharacterManager
campaignId = { selectedCampaignId }
campaignCharacters = { selectedCampaign . characters || []}
/>
2026-04-25 20:25:34 -04:00
< hr className = "my-6 border-stone-700" />
2025-12-13 19:15:26 -05:00
< EncounterManager
campaignId = { selectedCampaignId }
initialActiveEncounterId = {
initialActiveInfo && initialActiveInfo . activeCampaignId === selectedCampaignId
? initialActiveInfo . activeEncounterId
: null
}
campaignCharacters = { selectedCampaign . characters || []}
/>
< /div>
)}
< /div>
< ConfirmationModal
isOpen = { showDeleteConfirm }
onClose = {() => 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 ()
);
2025-05-25 21:19:22 -04:00
const [ activeEncounterData , setActiveEncounterData ] = useState ( null );
2025-12-13 19:15:26 -05:00
const [ isLoadingEncounter , setIsLoadingEncounter ] = useState ( true );
2025-05-26 08:33:39 -04:00
const [ encounterError , setEncounterError ] = useState ( null );
2025-05-25 23:28:36 -04:00
const [ campaignBackgroundUrl , setCampaignBackgroundUrl ] = useState ( '' );
2025-12-13 19:15:26 -05:00
const [ isPlayerDisplayActive , setIsPlayerDisplayActive ] = useState ( false );
2026-06-27 11:53:56 -04:00
const [ isFullscreen , setIsFullscreen ] = useState ( false );
2026-06-27 11:56:11 -04:00
const [ wakeLockEnabled , setWakeLockEnabled ] = useState ( false );
const wakeLockRef = useRef ( null );
2025-12-13 19:15:26 -05:00
const currentParticipantRef = useRef ( null );
2026-06-27 11:53:56 -04:00
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 ();
}
};
2026-06-27 11:56:11 -04:00
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 ]);
2025-05-25 21:19:22 -04:00
useEffect (() => {
2025-12-13 19:15:26 -05:00
if ( ! db ) {
setEncounterError ( "Firestore not available." );
setIsLoadingEncounter ( false );
setIsPlayerDisplayActive ( false );
return ;
}
2025-05-25 21:19:22 -04:00
let unsubscribeEncounter ;
2025-05-25 23:28:36 -04:00
let unsubscribeCampaign ;
2025-12-13 19:15:26 -05:00
2025-05-26 08:33:39 -04:00
if ( activeDisplayData ) {
2025-12-13 19:15:26 -05:00
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 )) {
2026-04-25 20:25:34 -04:00
return < div className = "text-center py-10 text-2xl text-stone-300" > Loading Player Display ... < /div>;
2025-12-13 19:15:26 -05:00
}
if ( activeDisplayError || ( isPlayerDisplayActive && encounterError )) {
return < div className = "text-center py-10 text-2xl text-red-400" > { activeDisplayError || encounterError } < /div>;
}
2025-05-26 07:50:24 -04:00
if ( ! isPlayerDisplayActive || ! activeEncounterData ) {
return (
2026-04-25 20:25:34 -04:00
< div className = "min-h-screen bg-black text-stone-400 flex flex-col items-center justify-center p-4 text-center" >
< EyeOff size = { 64 } className = "mb-4 text-stone-500" />
2026-04-25 18:37:55 -04:00
< h2 className = "text-3xl font-semibold font-cinzel tracking-wide" > Game Session Paused < /h2>
2025-05-26 07:50:24 -04:00
< p className = "text-xl mt-2" > The Dungeon Master has not activated an encounter for display . < /p>
2025-12-13 19:15:26 -05:00
< /div>
);
2025-05-26 07:50:24 -04:00
}
2025-12-13 19:15:26 -05:00
const { name , participants , round , currentTurnParticipantId , isStarted , isPaused } = activeEncounterData ;
2026-05-16 09:30:35 -04:00
const hidePlayerHp = activeDisplayData ? . hidePlayerHp ?? true ;
2025-12-13 19:15:26 -05:00
let participantsToRender = [];
if ( participants ) {
2026-06-26 14:39:59 -04:00
// 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 );
2025-05-25 21:19:22 -04:00
}
2025-12-13 19:15:26 -05:00
const displayStyles = campaignBackgroundUrl
? {
backgroundImage : `url( ${ campaignBackgroundUrl } )` ,
backgroundSize : 'cover' ,
backgroundPosition : 'center center' ,
backgroundRepeat : 'no-repeat' ,
minHeight : '100vh'
}
: { minHeight : '100vh' };
2025-05-25 21:19:22 -04:00
return (
2025-12-13 19:15:26 -05:00
< div
2026-04-25 20:25:34 -04:00
className = { `p-4 md:p-8 rounded-xl shadow-2xl ${ ! campaignBackgroundUrl ? 'bg-stone-950' : '' } ` }
2025-12-13 19:15:26 -05:00
style = { displayStyles }
>
2026-06-27 11:56:11 -04:00
< div className = "fixed top-3 right-3 z-50 flex gap-2" >
< button
onClick = {() => 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 ? < Coffee size = { 20 } /> : < Moon size = { 20 } /> }
< /button>
< button
onClick = { toggleFullscreen }
title = { isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen' }
className = "bg-stone-800 bg-opacity-80 hover:bg-opacity-100 text-stone-300 hover:text-white p-2 rounded-lg transition-all"
>
{ isFullscreen ? < Minimize2 size = { 20 } /> : < Maximize2 size = { 20 } /> }
< /button>
< /div>
2026-04-25 20:25:34 -04:00
< div className = { campaignBackgroundUrl ? 'bg-stone-950 bg-opacity-75 p-4 md:p-6 rounded-lg' : '' } >
2026-04-25 18:37:55 -04:00
< h2 className = "text-4xl md:text-5xl font-bold text-center text-amber-400 mb-2 font-cinzel tracking-wide" > { name } < /h2>
2025-12-13 19:15:26 -05:00
2026-04-25 18:37:55 -04:00
{ isStarted && < p className = "text-2xl text-center text-amber-300 mb-1 font-cinzel" > Round : { round } < /p>}
2025-12-13 19:15:26 -05:00
{ isStarted && isPaused && (
< p className = "text-xl text-center text-yellow-400 mb-4 font-semibold" > ( Combat Paused ) < /p>
)}
{ ! isStarted && participants ? . length > 0 && (
2026-04-25 20:25:34 -04:00
< p className = "text-2xl text-center text-stone-400 mb-6" > Awaiting Start < /p>
2025-12-13 19:15:26 -05:00
)}
{ ! isStarted && ( ! participants || participants . length === 0 ) && (
2026-04-25 20:25:34 -04:00
< p className = "text-2xl text-stone-500 mb-6" > No participants . < /p>
2025-12-13 19:15:26 -05:00
)}
{ participantsToRender . length === 0 && isStarted && (
2026-04-25 20:25:34 -04:00
< p className = "text-xl text-stone-400" > No active participants . < /p>
2025-12-13 19:15:26 -05:00
)}
< div className = "space-y-4 max-w-3xl mx-auto" >
2025-05-27 10:51:29 -04:00
{ participantsToRender . map ( p => {
2025-12-13 19:15:26 -05:00
const isDead = p . currentHp === 0 ;
const isDying = p . isDying || false ;
let participantBgColor = p . type === 'monster'
2026-04-25 20:25:34 -04:00
? ( p . isNpc ? 'bg-stone-800' : 'bg-[#8e351c]' )
: 'bg-indigo-950' ;
2025-12-13 19:15:26 -05:00
const isCurrentTurn = p . id === currentTurnParticipantId && isStarted && ! isPaused ;
if ( isCurrentTurn ) {
2025-05-27 10:51:29 -04:00
participantBgColor = 'bg-green-700 ring-4 ring-green-400 scale-105' ;
} else if ( isPaused && p . id === currentTurnParticipantId ) {
participantBgColor += ' ring-2 ring-yellow-400' ;
}
return (
2025-12-13 19:15:26 -05:00
< div
key = { p . id }
ref = { isCurrentTurn ? currentParticipantRef : null }
className = { `p-4 md:p-6 rounded-lg shadow-lg transition-all ${ participantBgColor } ${ isDead ? 'opacity-40 grayscale' : ( ! p . isActive ? 'opacity-40 grayscale' : '' ) } ${ isDying ? 'animate-death-dissolve' : '' } ` }
>
< div className = "flex justify-between items-center mb-2" >
< h3
2026-04-25 20:25:34 -04:00
className = { `text-2xl md:text-3xl font-bold font-cinzel ${ isCurrentTurn ? 'text-white' : ( p . type === 'character' ? 'text-amber-100' : ( p . isNpc ? 'text-stone-100' : 'text-white' )) } ` }
2025-12-13 19:15:26 -05:00
>
{ isDead && < span className = "mr-2" > ☠️ < /span>}
{ p . name }
{ isCurrentTurn && (
< span className = "text-yellow-300 animate-pulse ml-2" > ( Current ) < /span>
)}
2026-04-25 20:25:34 -04:00
{ isDead && < span className = "text-red-300 text-lg ml-2" > { p . type === 'character' ? '(Unconscious)' : '(Dead)' } < /span>}
2025-12-13 19:15:26 -05:00
< /h3>
< span
2026-04-25 20:25:34 -04:00
className = { `text-xl md:text-2xl font-semibold ${ isCurrentTurn ? 'text-green-200' : 'text-stone-200' } ` }
2025-12-13 19:15:26 -05:00
>
Init : { p . initiative }
< /span>
2025-05-25 23:28:36 -04:00
< /div>
2025-12-13 19:15:26 -05:00
2026-05-16 09:30:35 -04:00
{ ! ( hidePlayerHp && p . type === 'character' ) && (
< div className = "flex justify-between items-center" >
< div className = "w-full bg-stone-700 rounded-full h-6 md:h-8 relative overflow-hidden border-2 border-stone-600" >
< div
className = { `h-full rounded-full transition-all ${ isDead ? 'bg-red-900' : ( p . currentHp <= p . maxHp / 4 ? 'bg-red-500' : ( p . currentHp <= p . maxHp / 2 ? 'bg-yellow-500' : 'bg-green-500' )) } ` }
style = {{ width : ` ${ Math . max ( 0 , ( p . currentHp / p . maxHp ) * 100 ) } %` }}
>< /div>
{ p . type !== 'monster' && (
< span className = "absolute inset-0 flex items-center justify-center text-xs md:text-sm font-medium text-white px-2" >
HP : { p . currentHp } / { p . maxHp }
< /span>
)}
< /div>
2025-12-13 19:15:26 -05:00
< /div>
2026-05-16 09:30:35 -04:00
)}
2025-12-13 19:15:26 -05:00
{ p . conditions ? . length > 0 && (
2026-04-26 10:37:25 -04:00
< div className = "flex flex-wrap gap-1 mt-2" >
{ p . conditions . map ( cId => {
const cond = CONDITIONS . find ( c => c . id === cId );
return cond ? (
< span
key = { cId }
className = "inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-purple-900 border border-purple-500 text-xs md:text-sm text-purple-200 font-medium"
>
{ cond . emoji } { cond . label }
< /span>
) : null ;
})}
< /div>
2025-12-13 19:15:26 -05:00
)}
{ ! p . isActive && ! isDead && (
2026-04-25 20:25:34 -04:00
< p className = "text-center text-lg font-semibold text-stone-300 mt-2" > ( Inactive ) < /p>
2025-12-13 19:15:26 -05:00
)}
2025-05-25 23:28:36 -04:00
< /div>
2025-12-13 19:15:26 -05:00
);
2025-05-27 10:51:29 -04:00
})}
2025-05-25 23:28:36 -04:00
< /div>
2025-05-25 21:19:22 -04:00
< /div>
2025-12-13 19:15:26 -05:00
< /div>
);
2025-05-25 21:19:22 -04:00
}
2026-05-16 10:25:17 -04:00
// ============================================================================
// LOGS VIEW COMPONENT
// ============================================================================
function LogsView () {
const { data : logs , isLoading } = useFirestoreCollection ( getPath . logs (), LOG_QUERY );
const [ showClearConfirm , setShowClearConfirm ] = useState ( false );
2026-06-27 16:45:07 -04:00
const [ undoingId , setUndoingId ] = useState ( null );
2026-05-16 10:25:17 -04:00
const handleClearLogs = async () => {
try {
2026-06-28 21:05:39 -04:00
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 );
2026-05-16 10:25:17 -04:00
}
} catch ( err ) {
console . error ( 'Error clearing logs:' , err );
}
setShowClearConfirm ( false );
};
2026-06-27 16:45:07 -04:00
const handleUndo = async ( entry ) => {
if ( ! db || ! entry . undo ) return ;
setUndoingId ( entry . id );
try {
2026-06-28 21:05:39 -04:00
await storage . updateDoc ( entry . undo . encounterPath , entry . undo . updates );
await storage . updateDoc ( ` ${ getPath . logs () } / ${ entry . id } ` , { undone : true });
2026-06-27 16:45:07 -04:00
} catch ( err ) {
console . error ( 'Error undoing action:' , err );
alert ( 'Failed to roll back. The encounter may have changed or no longer exists.' );
}
setUndoingId ( null );
};
2026-05-16 10:25:17 -04:00
return (
< div className = "min-h-screen bg-stone-950 text-stone-100 font-garamond" >
< header className = "bg-stone-950 p-4 shadow-lg border-b border-amber-900" >
< div className = "container mx-auto flex justify-between items-center" >
< h1 className = "text-3xl font-bold text-amber-400 font-cinzel tracking-wide" > Combat Log < /h1>
< div className = "flex gap-2" >
< a
href = "/"
className = "px-4 py-2 rounded-md text-sm font-medium bg-stone-700 hover:bg-stone-600 text-white transition-colors"
>
← Back to Tracker
< /a>
< button
onClick = {() => 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
< /button>
< /div>
< /div>
< /header>
< main className = "container mx-auto p-4 md:p-8" >
{ isLoading ? (
< LoadingSpinner message = "Loading logs..." />
) : logs . length === 0 ? (
< p className = "text-stone-400 text-center mt-12 text-lg" > No log entries yet . < /p>
) : (
<>
< p className = "text-stone-500 text-sm mb-4" > { logs . length } entries — newest first < /p>
< div className = "space-y-1 max-w-5xl" >
{ logs . map ( entry => (
2026-06-27 16:45:07 -04:00
< div
key = { entry . id }
className = { `flex gap-3 items-center p-3 rounded-md border text-sm transition-opacity ${
entry . undone
? 'bg-stone-900/50 border-stone-800/50 opacity-50'
: 'bg-stone-900 border-stone-800'
} ` }
>
2026-05-16 10:25:17 -04:00
< span className = "text-stone-500 whitespace-nowrap font-mono shrink-0" >
{ new Date ( entry . timestamp ). toLocaleString ()}
< /span>
{ entry . encounterName && (
< span className = "text-amber-600 whitespace-nowrap shrink-0" > [{ entry . encounterName }] < /span>
)}
2026-06-27 16:45:07 -04:00
< span className = { `flex-1 ${ entry . undone ? 'line-through text-stone-500' : 'text-stone-100' } ` } >
{ entry . message }
< /span>
{ entry . undone ? (
< span className = "shrink-0 text-xs text-stone-600 italic" > rolled back < /span>
) : entry . undo ? (
< button
onClick = {() => 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' }
< /button>
) : null }
2026-05-16 10:25:17 -04:00
< /div>
))}
< /div>
< />
)}
< /main>
< ConfirmationModal
isOpen = { showClearConfirm }
onClose = {() => setShowClearConfirm ( false )}
onConfirm = { handleClearLogs }
title = "Clear All Logs?"
message = "This will permanently delete all log entries and cannot be undone."
/>
< /div>
);
}
2025-12-13 19:15:26 -05:00
// ============================================================================
// 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 );
2026-05-16 10:25:17 -04:00
const [ isLogsMode , setIsLogsMode ] = useState ( false );
2025-12-13 19:15:26 -05:00
2025-05-25 21:19:22 -04:00
useEffect (() => {
2025-12-13 19:15:26 -05:00
const queryParams = new URLSearchParams ( window . location . search );
2026-04-25 18:37:55 -04:00
if ( queryParams . get ( 'playerView' ) === 'true' || window . location . pathname === '/display' ) {
2025-12-13 19:15:26 -05:00
setIsPlayerViewOnlyMode ( true );
}
2026-05-16 10:25:17 -04:00
if ( window . location . pathname === '/logs' ) {
setIsLogsMode ( true );
}
2025-12-13 19:15:26 -05:00
if ( ! auth ) {
2026-06-28 21:11:56 -04:00
setError ( "Auth not initialized." );
2025-12-13 19:15:26 -05:00
setIsLoading ( false );
setIsAuthReady ( false );
return ;
}
2026-06-28 21:11:56 -04:00
// 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 ;
}
2025-12-13 19:15:26 -05:00
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." );
2026-05-16 10:53:03 -04:00
// Auth failed and onAuthStateChanged won't fire with a user, so unblock the UI here
setIsAuthReady ( true );
setIsLoading ( false );
2025-12-13 19:15:26 -05:00
}
};
const unsubscribe = onAuthStateChanged ( auth , ( user ) => {
setUserId ( user ? user . uid : null );
2026-05-16 10:53:03 -04:00
// 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 );
}
2025-12-13 19:15:26 -05:00
});
initAuth ();
return () => unsubscribe ();
}, []);
2026-06-28 21:11:56 -04:00
if ( ! isInitialized || ! auth ) {
2025-12-13 19:15:26 -05:00
return (
< ErrorDisplay
critical
2026-06-28 21:11:56 -04:00
message = { ` ${ STORAGE_MODE === 'firebase' ? 'Firebase' : 'Storage' } is not properly configured. Check your .env.local file and ensure all REACT_APP_* variables are correctly set.` }
2025-12-13 19:15:26 -05:00
/>
);
}
if ( isLoading || ! isAuthReady ) {
return < LoadingSpinner message = "Loading Initiative Tracker..." /> ;
}
if ( error ) {
return < ErrorDisplay message = { error } /> ;
}
const openPlayerWindow = () => {
2026-04-25 18:37:55 -04:00
const playerViewUrl = window . location . origin + '/display' ;
2025-12-13 19:15:26 -05:00
window . open ( playerViewUrl , '_blank' , 'noopener,noreferrer,width=1024,height=768' );
};
if ( isPlayerViewOnlyMode ) {
return (
2026-04-25 20:25:34 -04:00
< div className = "min-h-screen bg-stone-950 text-stone-100 font-garamond" >
2025-12-13 19:15:26 -05:00
{ isAuthReady && < DisplayView /> }
{ ! isAuthReady && ! error && < p > Authenticating for Player Display ... < /p>}
< /div>
);
}
2026-05-16 10:25:17 -04:00
if ( isLogsMode ) {
return isAuthReady ? < LogsView /> : < LoadingSpinner message = "Authenticating..." /> ;
}
2025-05-25 21:19:22 -04:00
return (
2026-04-25 20:25:34 -04:00
< div className = "min-h-screen bg-stone-950 text-stone-100 font-garamond" >
< header className = "bg-stone-950 p-4 shadow-lg border-b border-amber-900" >
2025-12-13 19:15:26 -05:00
< div className = "container mx-auto flex justify-between items-center" >
2026-04-25 18:37:55 -04:00
< h1 className = "text-3xl font-bold text-amber-400 font-cinzel tracking-wide" > TTRPG Initiative Tracker < /h1>
2026-05-16 10:25:17 -04:00
< div className = "flex gap-2" >
< a
href = "/logs"
target = "_blank"
rel = "noopener noreferrer"
className = "px-4 py-2 rounded-md text-sm font-medium transition-colors bg-stone-700 hover:bg-stone-600 text-white flex items-center"
>
< ScrollText size = { 16 } className = "mr-2" /> View Logs
< /a>
< button
onClick = { openPlayerWindow }
className = "px-4 py-2 rounded-md text-sm font-medium transition-colors bg-amber-700 hover:bg-amber-800 text-white flex items-center"
>
< ExternalLink size = { 16 } className = "mr-2" /> Open Player Window
< /button>
< /div>
2025-05-25 21:19:22 -04:00
< /div>
2025-12-13 19:15:26 -05:00
< /header>
< main className = "container mx-auto p-4 md:p-8" >
{ isAuthReady && userId && < AdminView userId = { userId } /> }
{ ! isAuthReady && ! error && < p > Authenticating ... < /p>}
< /main>
2026-04-25 20:25:34 -04:00
< footer className = "bg-stone-950 p-4 text-center text-sm text-stone-400 mt-8" >
2025-12-13 19:15:26 -05:00
TTRPG Initiative Tracker { APP_VERSION }
< /footer>
2025-05-25 21:19:22 -04:00
< /div>
);
}
2025-05-26 08:53:26 -04:00
export default App ;