2026-07-08 14:12:35 -04:00
//
2026-07-04 22:27:32 -04:00
import React , { useState , useEffect , useRef , useMemo , createContext , useContext , useCallback } from 'react' ;
2026-07-01 16:02:35 -04:00
import * as shared from '@ttrpg/shared' ;
2026-07-06 10:31:46 -04:00
import { initializeApp , getAuth , signInAnonymously , onAuthStateChanged , signInWithCustomToken , getFirestore , where , orderBy , limit , offset , getStorage , getStorageMode } from './storage' ;
2026-07-08 11:46:56 -04:00
import { isDevToolsEnabled } from './config/devTools' ;
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-07-07 14:31:04 -04:00
StopCircle as StopCircleIcon , Users2 , Dices , ChevronDown , ScrollText ,
2026-07-06 10:31:46 -04:00
Maximize2 , Minimize2 , Moon , Coffee , Clock , ChevronRight , X ,
2026-07-06 18:38:49 -04:00
Undo2 , Redo2 , Crosshair
2025-12-13 19:15:26 -05:00
} from 'lucide-react' ;
2026-07-04 22:27:32 -04:00
// ----- UI feedback: toast (transient) + info modal (persistent) -----
const UIFeedbackContext = createContext ( null );
const useUIFeedback = () => useContext ( UIFeedbackContext );
function ToastStack ({ toasts , onDismiss }) {
if ( ! toasts . length ) return null ;
return (
< div className = "fixed bottom-4 right-4 z-[60] flex flex-col gap-2 max-w-sm" >
{ toasts . map ( t => (
< div key = { t . id } className = "flex items-start gap-2 bg-stone-800 border border-red-600 text-stone-100 px-3 py-2 rounded-md shadow-lg" >
< AlertTriangle size = { 16 } className = "text-red-400 flex-shrink-0 mt-0.5" />
< span className = "text-sm flex-grow" > { t . message } < /span>
< button onClick = {() => onDismiss ( t . id )} className = "text-stone-400 hover:text-stone-200 flex-shrink-0" aria - label = "Dismiss" >
< X size = { 14 } />
< /button>
< /div>
))}
< /div>
);
}
function InfoModal ({ message , onClose }) {
if ( ! message ) return null ;
return (
< div className = "fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center p-4 z-50" >
< div className = "bg-stone-900 p-6 rounded-lg shadow-xl w-full max-w-md" >
< 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" > Notice < /h2>
< /div>
< p className = "text-stone-300 mb-6" > { message } < /p>
< div className = "flex justify-end" >
< button onClick = { onClose } className = "px-4 py-2 text-sm font-medium text-stone-900 bg-amber-400 hover:bg-amber-300 rounded-md transition-colors" >
OK
< /button>
< /div>
< /div>
< /div>
);
}
function UIFeedbackProvider ({ children }) {
const [ toasts , setToasts ] = useState ([]);
const [ info , setInfo ] = useState ( null );
const nextId = useRef ( 1 );
const showToast = useCallback (( message ) => {
const id = nextId . current ++ ;
setToasts ( prev => [... prev , { id , message }]);
setTimeout (() => setToasts ( prev => prev . filter ( t => t . id !== id )), 6000 );
}, []);
const showInfo = useCallback (( message ) => {
setInfo ( message );
}, []);
const dismissToast = useCallback (( id ) => {
setToasts ( prev => prev . filter ( t => t . id !== id ));
}, []);
return (
< UIFeedbackContext . Provider value = {{ showToast , showInfo }} >
{ children }
< ToastStack toasts = { toasts } onDismiss = { dismissToast } />
< InfoModal message = { info } onClose = {() => setInfo ( null )} />
< /UIFeedbackContext.Provider>
);
}
2025-12-13 19:15:26 -05:00
// 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-07-01 19:59:46 -04:00
const APP_VERSION = 'v0.4' ;
2026-07-03 17:59:41 -04:00
const {
DEFAULT_MAX_HP , DEFAULT_INIT_MOD , MONSTER_DEFAULT_INIT_MOD ,
generateId , rollD20 , formatInitMod ,
startEncounter , nextTurn , togglePause , endEncounter ,
addParticipant , addParticipants , updateParticipant , removeParticipant ,
toggleParticipantActive : combatToggleActive ,
applyHpChange : combatApplyHpChange ,
deathSave : combatDeathSave ,
2026-07-06 16:39:11 -04:00
stabilizeParticipant ,
reviveParticipant ,
2026-07-07 13:32:26 -04:00
markDead ,
2026-07-03 17:59:41 -04:00
toggleCondition : combatToggleCondition ,
reorderParticipants ,
} = shared ;
2025-12-13 19:15:26 -05:00
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-29 12:36:16 -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-29 12:36:16 -04:00
// ws / memory mode: stub auth so App's anon-sign-in path works.
2026-06-29 12:36:16 -04:00
// db stays a truthy sentinel object so legacy `if (!db) return` guards pass;
// all real reads/writes route through `storage.*`, never the SDK `db`.
2026-06-29 12:36:16 -04:00
const FAKE_USER = { uid : 'local-user' , isAnonymous : true };
auth = {
currentUser : FAKE_USER ,
};
2026-06-29 12:36:16 -04:00
db = { __localStub : true };
2026-06-29 12:36:16 -04:00
storage = getStorage ();
return true ;
2025-12-13 19:15:26 -05:00
};
2025-05-25 21:19:22 -04:00
2026-06-29 12:36:16 -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
2026-07-01 16:02:35 -04:00
// generateId, rollD20, formatInitMod, sortParticipantsByInitiative,
// computeTurnOrderAfterRemoval/Addition: imported from @ttrpg/shared (1-list model).
2025-12-13 19:15:26 -05:00
2026-07-06 10:31:46 -04:00
// Display limit: recent logs only. Download/copy fetch full set on demand.
// Keep small — 500 = ~900KB per snapshot = slow load.
const LOG_PAGE_SIZE = 100 ;
const LOG_ORDER = orderBy ( 'ts' , 'desc' );
// Combat actions now live in shared/turn.js and WRITE THEIR OWN LOGS.
// Every mutating func is async, takes ctx {storage, encPath, logPath},
// persists encounter patch + log entry itself. No separate logEvent call.
//
// buildCtx: standard context for combat action calls. Lazy getStorage()
// so handlers work even if module-level `storage` assigned late (test/auth).
const buildCtx = ( encounterPath ) => ({
storage : getStorage (),
encPath : encounterPath ,
logPath : getPath . logs (),
});
// Read undo payload: new lean `undo` (id-based) or legacy (`undo_payload`/`undo`).
// Lean events need expandUndo() at click time (needs current enc doc).
// expandUndo lives in shared/turn.js (re-exported via @ttrpg/shared).
const { expandUndo } = shared ;
const getUndo = ( entry ) => entry . undo_payload || entry . undo || null ;
2026-05-16 10:25:17 -04:00
2026-05-16 10:00:17 -04:00
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 );
2026-07-04 12:22:04 -04:00
}, ( err ) => {
console . error ( `Error fetching document ${ docPath } :` , err );
setError ( err . message || "Failed to fetch document." );
setIsLoading ( false );
setData ( null );
2026-06-28 19:03:44 -04:00
});
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 );
2026-07-04 12:22:04 -04:00
}, queryConstraints , ( err ) => {
2026-07-06 10:31:46 -04:00
console . error ( `[useFirestoreCollection] ERR ${ collectionPath } :` , err );
2026-07-04 12:22:04 -04:00
setError ( err . message || "Failed to fetch collection." );
setIsLoading ( false );
setData ([]);
2026-06-28 19:03:44 -04:00
});
2026-07-06 10:31:46 -04:00
return () => {
if ( typeof unsubscribe === 'function' ) unsubscribe ();
};
2026-06-28 19:03:44 -04:00
// 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 }) {
2026-07-06 10:31:46 -04:00
const [ pending , setPending ] = useState ( false );
const [ error , setError ] = useState ( null );
// Reset transient state each time modal opens.
useEffect (() => {
if ( isOpen ) { setPending ( false ); setError ( null ); }
}, [ isOpen ]);
2025-05-26 09:41:50 -04:00
if ( ! isOpen ) return null ;
2025-12-13 19:15:26 -05:00
2026-07-06 10:31:46 -04:00
const handleConfirm = async () => {
if ( pending ) return ; // double-click guard
setPending ( true );
setError ( null );
try {
await onConfirm ();
onClose ();
} catch ( err ) {
console . error ( 'ConfirmationModal action failed:' , err );
setError ( err ? . message || 'Action failed. Try again.' );
setPending ( false );
}
};
const close = () => {
if ( pending ) return ; // block close mid-action
onClose ();
};
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>
2026-07-06 10:31:46 -04:00
{ error && (
< p className = "text-red-400 text-sm mb-4" > { error } < /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
2026-07-06 10:31:46 -04:00
onClick = { close }
disabled = { pending }
className = "px-4 py-2 text-sm font-medium text-stone-300 bg-stone-700 hover:bg-stone-600 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
2025-12-13 19:15:26 -05:00
>
Cancel
< /button>
< button
2026-07-06 10:31:46 -04:00
onClick = { handleConfirm }
disabled = { pending }
className = "px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
2025-12-13 19:15:26 -05:00
>
2026-07-06 10:31:46 -04:00
{ pending ? "Working..." : "Confirm" }
2025-12-13 19:15:26 -05:00
< /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 ( '' );
2026-07-07 13:32:26 -04:00
const [ ruleset , setRuleset ] = useState ( '5e' );
2025-12-13 19:15:26 -05:00
const handleSubmit = ( e ) => {
e . preventDefault ();
if ( name . trim ()) {
2026-07-07 13:32:26 -04:00
onCreate ( name , backgroundUrl , ruleset );
2025-12-13 19:15:26 -05:00
}
};
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>
2026-07-07 13:32:26 -04:00
< div >
< label className = "block text-sm font-medium text-stone-300 mb-1" > Ruleset < /label>
< div className = "flex gap-4" >
< label className = "flex items-center gap-2 text-sm text-stone-200" >
< input type = "radio" value = "5e" checked = { ruleset === '5e' } onChange = {() => setRuleset ( '5e' )} />
D & D 5 e ( death saves )
< /label>
< label className = "flex items-center gap-2 text-sm text-stone-200" >
< input type = "radio" value = "generic" checked = { ruleset === 'generic' } onChange = {() => setRuleset ( 'generic' )} />
Generic ( no death saves , negative HP )
< /label>
< /div>
< /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>
);
}
2026-07-07 13:32:26 -04:00
function CreateEncounterForm ({ onCreate , onCancel , defaultRuleset = '5e' }) {
2025-12-13 19:15:26 -05:00
const [ name , setName ] = useState ( '' );
2026-07-07 13:32:26 -04:00
const [ ruleset , setRuleset ] = useState ( defaultRuleset );
2025-05-28 12:23:10 -04:00
2025-12-13 19:15:26 -05:00
const handleSubmit = ( e ) => {
e . preventDefault ();
if ( name . trim ()) {
2026-07-07 13:32:26 -04:00
onCreate ( name , ruleset );
2025-12-13 19:15:26 -05:00
}
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>
2026-07-07 13:32:26 -04:00
< div >
< label className = "block text-sm font-medium text-stone-300 mb-1" > Ruleset < /label>
< div className = "flex gap-4" >
< label className = "flex items-center gap-2 text-sm text-stone-200" >
< input type = "radio" value = "5e" checked = { ruleset === '5e' } onChange = {() => setRuleset ( '5e' )} />
D & D 5 e ( death saves )
< /label>
< label className = "flex items-center gap-2 text-sm text-stone-200" >
< input type = "radio" value = "generic" checked = { ruleset === 'generic' } onChange = {() => setRuleset ( 'generic' )} />
Generic ( no death saves , negative HP )
< /label>
< /div>
< /div>
2025-12-13 19:15:26 -05:00
< 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 );
2026-07-07 17:24:36 -04:00
const [ hpFormula , setHpFormula ] = useState ( participant . hpFormula || '' );
2026-07-08 12:23:19 -04:00
const [ ac , setAc ] = useState ( participant . ac != null ? participant . ac : '' );
2026-07-06 16:39:11 -04:00
const [ asNpc , setAsNpc ] = useState ( participant . type === 'npc' );
2025-12-13 19:15:26 -05:00
const handleSubmit = ( e ) => {
e . preventDefault ();
2026-07-07 17:24:36 -04:00
let finalMaxHp = parseInt ( maxHp , 10 );
// formula rolls only if maxHp empty. maxHp wins if both.
if ( isNaN ( finalMaxHp ) && hpFormula . trim ()) {
const rolled = shared . rollHpFormula ( hpFormula );
if ( rolled ) finalMaxHp = rolled ;
}
if ( isNaN ( finalMaxHp )) finalMaxHp = participant . maxHp ;
let finalCurrentHp = parseInt ( currentHp , 10 );
if ( isNaN ( finalCurrentHp )) finalCurrentHp = finalMaxHp ;
2025-12-13 19:15:26 -05:00
onSave ({
name : name . trim (),
initiative : parseInt ( initiative , 10 ),
2026-07-07 17:24:36 -04:00
currentHp : finalCurrentHp ,
maxHp : finalMaxHp ,
hpFormula : ( participant . type === 'monster' || participant . type === 'npc' ) ? ( hpFormula . trim () || null ) : null ,
2026-07-08 12:23:19 -04:00
ac : ac !== '' ? parseInt ( ac , 10 ) : null ,
2026-07-06 16:39:11 -04:00
type : participant . type === 'monster' || participant . type === 'npc' ? ( asNpc ? 'npc' : 'monster' ) : participant . type ,
2025-12-13 19:15:26 -05:00
});
};
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>
2026-07-08 12:23:19 -04:00
< div className = "flex gap-4" >
< div className = "flex-1" >
< label htmlFor = "edit-initiative" className = "block text-sm font-medium text-stone-300" > Initiative < /label>
< input
type = "number"
id = "edit-initiative"
value = { initiative }
onChange = {( e ) => setInitiative ( e . target . value )}
className = "mt-1 block w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
/>
< /div>
< div className = "flex-1" >
< label className = "block text-sm font-medium text-stone-300" > AC < /label>
< input
type = "number"
value = { ac }
onChange = {( e ) => setAc ( e . target . value )}
placeholder = "optional"
className = "mt-1 block w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
/>
< /div>
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>
2026-07-07 17:24:36 -04:00
{( participant . type === 'monster' || participant . type === 'npc' ) && (
< div >
< label className = "block text-sm font-medium text-stone-300 mb-1" > HP Formula < /label>
< div className = "flex gap-2" >
< input
type = "text"
value = { hpFormula }
onChange = {( e ) => setHpFormula ( e . target . value )}
2026-07-08 12:23:19 -04:00
placeholder = "e.g. 2d6+9"
2026-07-07 17:24:36 -04:00
className = "flex-1 px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
/>
< button
type = "button"
disabled = { ! shared . rollHpFormula ( hpFormula )}
onClick = {() => {
const rolled = shared . rollHpFormula ( hpFormula );
if ( rolled ) setMaxHp ( rolled );
}}
className = "px-3 py-2 text-sm font-medium text-white bg-violet-700 hover:bg-violet-800 rounded-md transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
title = "Roll formula, set Max HP"
>
< Dices size = { 16 } className = "inline" /> Reroll
< /button>
< /div>
< /div>
)}
2026-07-06 16:39:11 -04:00
{( participant . type === 'monster' || participant . type === 'npc' ) && (
2025-12-13 19:15:26 -05:00
< div className = "flex items-center" >
< input
type = "checkbox"
id = "editIsNpc"
2026-07-06 16:39:11 -04:00
checked = { asNpc }
onChange = {( e ) => setAsNpc ( 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 }) {
2026-07-04 22:27:32 -04:00
const { showToast , showInfo } = useUIFeedback ();
2025-12-13 19:15:26 -05:00
const [ characterName , setCharacterName ] = useState ( '' );
const [ defaultMaxHp , setDefaultMaxHp ] = useState ( DEFAULT_MAX_HP );
const [ defaultInitMod , setDefaultInitMod ] = useState ( DEFAULT_INIT_MOD );
2026-07-08 12:23:19 -04:00
const [ defaultAc , setDefaultAc ] = useState ( '' );
2025-12-13 19:15:26 -05:00
const [ editingCharacter , setEditingCharacter ] = useState ( null );
const [ showDeleteConfirm , setShowDeleteConfirm ] = useState ( false );
const [ itemToDelete , setItemToDelete ] = useState ( null );
2026-07-07 15:40:58 -04:00
const [ draggedCharId , setDraggedCharId ] = useState ( null );
const handleCharDragStart = ( e , id ) => { setDraggedCharId ( id ); e . dataTransfer . effectAllowed = 'move' ; };
const handleCharDragOver = ( e ) => { e . preventDefault (); e . dataTransfer . dropEffect = 'move' ; };
const handleCharDrop = async ( e , targetId ) => {
e . preventDefault ();
if ( ! campaignId || ! draggedCharId || draggedCharId === targetId ) { setDraggedCharId ( null ); return ; }
const reordered = [... campaignCharacters ];
const fromIdx = reordered . findIndex ( c => c . id === draggedCharId );
const toIdx = reordered . findIndex ( c => c . id === targetId );
if ( fromIdx === - 1 || toIdx === - 1 ) { setDraggedCharId ( null ); return ; }
const [ moved ] = reordered . splice ( fromIdx , 1 );
reordered . splice ( toIdx , 0 , moved );
try { await storage . updateDoc ( getPath . campaign ( campaignId ), { players : reordered }); } catch ( err ) {}
setDraggedCharId ( null );
};
2026-07-07 14:31:04 -04:00
const [ isOpen , setIsOpen ] = useState (() => {
try { return localStorage . getItem ( 'ttrpg.charactersCollapsed' ) !== 'true' ; }
catch { return true ; }
});
useEffect (() => {
try { localStorage . setItem ( 'ttrpg.charactersCollapsed' , String ( ! isOpen )); }
catch {}
}, [ isOpen ]);
2025-12-13 19:15:26 -05:00
const handleAddCharacter = async () => {
if ( ! db || ! characterName . trim () || ! campaignId ) return ;
const hp = parseInt ( defaultMaxHp , 10 );
const initMod = parseInt ( defaultInitMod , 10 );
if ( isNaN ( hp ) || hp <= 0 ) {
2026-07-04 22:27:32 -04:00
showInfo ( "Please enter a valid positive number for Default Max HP." ); return ;
2025-12-13 19:15:26 -05:00
}
if ( isNaN ( initMod )) {
2026-07-04 22:27:32 -04:00
showInfo ( "Please enter a valid number for Default Initiative Modifier." ); return ;
2025-12-13 19:15:26 -05:00
}
const newCharacter = {
id : generateId (),
name : characterName . trim (),
defaultMaxHp : hp ,
2026-07-08 12:23:19 -04:00
defaultInitMod : initMod ,
defaultAc : defaultAc !== '' ? parseInt ( defaultAc , 10 ) : null
2025-12-13 19:15:26 -05:00
};
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 );
2026-07-08 12:23:19 -04:00
setDefaultAc ( '' );
2025-12-13 19:15:26 -05:00
} catch ( err ) {
console . error ( "Error adding character:" , err );
2026-07-04 22:27:32 -04:00
showToast ( "Failed to add character. Please try again." ); }
2025-05-25 21:19:22 -04:00
};
2025-12-13 19:15:26 -05:00
2026-07-08 12:23:19 -04:00
const handleUpdateCharacter = async ( characterId , newName , newDefaultMaxHp , newDefaultInitMod , newDefaultAc ) => {
2025-12-13 19:15:26 -05:00
if ( ! db || ! newName . trim () || ! campaignId ) return ;
const hp = parseInt ( newDefaultMaxHp , 10 );
const initMod = parseInt ( newDefaultInitMod , 10 );
if ( isNaN ( hp ) || hp <= 0 ) {
2026-07-04 22:27:32 -04:00
showInfo ( "Please enter a valid positive number for Default Max HP." ); setEditingCharacter ( null );
2025-12-13 19:15:26 -05:00
return ;
}
if ( isNaN ( initMod )) {
2026-07-04 22:27:32 -04:00
showInfo ( "Please enter a valid number for Default Initiative Modifier." ); setEditingCharacter ( null );
2025-12-13 19:15:26 -05:00
return ;
}
const updatedCharacters = campaignCharacters . map ( c =>
c . id === characterId
2026-07-08 12:23:19 -04:00
? { ... c , name : newName . trim (), defaultMaxHp : hp , defaultInitMod : initMod , defaultAc : newDefaultAc !== undefined && newDefaultAc !== '' ? parseInt ( newDefaultAc , 10 ) : null }
2025-12-13 19:15:26 -05:00
: 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 );
2026-07-04 22:27:32 -04:00
showToast ( "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 );
2026-07-04 22:27:32 -04:00
showToast ( "Failed to delete character. Please try again." ); }
2025-12-13 19:15:26 -05:00
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" >
< button
onClick = {() => setIsOpen ( ! isOpen )}
2026-07-07 14:31:04 -04:00
className = "flex items-center text-xl font-semibold text-amber-300 font-cinzel tracking-wide hover:text-amber-200 transition-colors"
aria - expanded = { isOpen }
2025-12-13 19:15:26 -05:00
>
2026-07-07 14:31:04 -04:00
{ isOpen ? < ChevronDown size = { 20 } className = "mr-1" /> : < ChevronRight size = { 20 } className = "mr-1" /> }
< Users size = { 24 } className = "mr-2" /> Campaign Characters
< span className = "text-sm font-normal text-stone-400 ml-1" > ({ campaignCharacters . length }) < /span>
2025-12-13 19:15:26 -05:00
< /button>
< /div>
{ isOpen && (
<>
2026-07-08 12:23:19 -04:00
< form onSubmit = {( e ) => { e . preventDefault (); handleAddCharacter (); }} className = "grid grid-cols-2 sm:grid-cols-12 gap-2 mb-4 items-end" >
< div className = "col-span-2 sm:col-span-6" >
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>
2026-07-08 12:23:19 -04:00
< div className = "sm:col-span-2" >
< label htmlFor = "defaultInitMod" className = "block text-xs font-medium text-stone-400" >
Init Mod
2025-12-13 19:15:26 -05:00
< /label>
< input
type = "number"
2026-07-08 12:23:19 -04:00
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>
2026-07-08 12:23:19 -04:00
< div className = "sm:col-span-2" >
< label htmlFor = "defaultAc" className = "block text-xs font-medium text-stone-400" >
AC
2025-12-13 19:15:26 -05:00
< /label>
< input
type = "number"
2026-07-08 12:23:19 -04:00
id = "defaultAc"
value = { defaultAc }
onChange = {( e ) => setDefaultAc ( e . target . value )}
className = "w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
/>
< /div>
< div className = "sm:col-span-2" >
< label htmlFor = "defaultMaxHp" className = "block text-xs font-medium text-stone-400" >
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>
< button
type = "submit"
2026-07-08 12:23:19 -04:00
className = "col-span-2 sm:col-span-12 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-07-07 15:40:58 -04:00
< li
key = { character . id }
draggable
onDragStart = {( e ) => handleCharDragStart ( e , character . id )}
onDragOver = { handleCharDragOver }
onDrop = {( e ) => handleCharDrop ( e , character . id )}
onDragEnd = {() => setDraggedCharId ( null )}
className = { `flex justify-between items-center p-3 bg-stone-800 rounded-md cursor-grab ${ draggedCharId === character . id ? 'opacity-50 ring-2 ring-yellow-400' : '' } ` }
>
< div className = "flex items-center flex-grow" >
< ChevronsUpDown size = { 16 } className = "text-stone-400 flex-shrink-0 mr-2" title = "Drag to reorder" />
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 ,
2026-07-08 12:23:19 -04:00
editingCharacter . defaultInitMod ,
editingCharacter . defaultAc
2025-12-13 19:15:26 -05:00
);
}}
className = "flex-grow flex flex-wrap gap-2 items-center"
>
2026-07-08 12:23:19 -04:00
< div className = "flex flex-col flex-grow min-w-[100px]" >
< label className = "text-[10px] text-stone-400 mb-0.5" > Name < /label>
< input
type = "text"
value = { editingCharacter . name }
onChange = {( e ) => setEditingCharacter ({ ... editingCharacter , name : e . target . value })}
className = "w-full px-2 py-1 bg-stone-700 border border-stone-600 rounded-md text-white"
/>
< /div>
< div className = "flex flex-col" >
< label className = "text-[10px] text-stone-400 mb-0.5" > HP < /label>
< input
type = "number"
value = { editingCharacter . defaultMaxHp }
onChange = {( e ) => setEditingCharacter ({ ... editingCharacter , defaultMaxHp : e . target . value })}
className = "w-20 px-2 py-1 bg-stone-700 border border-stone-600 rounded-md text-white"
title = "Default Max HP"
/>
< /div>
< div className = "flex flex-col" >
< label className = "text-[10px] text-stone-400 mb-0.5" > Init < /label>
< input
type = "number"
value = { editingCharacter . defaultInitMod }
onChange = {( e ) => setEditingCharacter ({ ... editingCharacter , defaultInitMod : e . target . value })}
className = "w-20 px-2 py-1 bg-stone-700 border border-stone-600 rounded-md text-white"
title = "Default Init Mod"
/>
< /div>
< div className = "flex flex-col" >
< label className = "text-[10px] text-stone-400 mb-0.5" > AC < /label>
< input
type = "number"
value = { editingCharacter . defaultAc ?? '' }
onChange = {( e ) => setEditingCharacter ({ ... editingCharacter , defaultAc : e . target . value })}
className = "w-16 px-2 py-1 bg-stone-700 border border-stone-600 rounded-md text-white"
title = "AC"
/>
< /div>
2025-12-13 19:15:26 -05:00
< 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" >
2026-07-08 12:23:19 -04:00
( HP : { character . defaultMaxHp || 'N/A' }, Init Mod : { formatInitMod ( character . defaultInitMod )}{ character . defaultAc != null ? `, AC: ${ character . defaultAc } ` : '' })
2025-12-13 19:15:26 -05:00
< /span>
< /span>
< div className = "flex space-x-2" >
< button
onClick = {() => setEditingCharacter ({
id : character . id ,
name : character . name ,
defaultMaxHp : character . defaultMaxHp || DEFAULT_MAX_HP ,
2026-07-08 12:23:19 -04:00
defaultInitMod : character . defaultInitMod || DEFAULT_INIT_MOD ,
defaultAc : character . defaultAc ?? ''
2025-12-13 19:15:26 -05:00
})}
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>
< />
)}
2026-07-07 15:40:58 -04:00
< /div>
2025-12-13 19:15:26 -05:00
< /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
2026-07-04 18:20:32 -04:00
function ParticipantManager ({ encounter , encounterPath , campaignCharacters , campaignId }) {
2026-07-04 22:27:32 -04:00
const { showToast , showInfo } = useUIFeedback ();
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 );
2026-07-07 17:24:36 -04:00
const [ maxHp , setMaxHp ] = useState ( '' );
const [ hpFormula , setHpFormula ] = useState ( '' );
2026-07-01 22:25:52 -04:00
const [ manualInitiative , setManualInitiative ] = useState ( '' );
2026-07-08 12:23:19 -04:00
const [ monsterAc , setMonsterAc ] = useState ( '' );
2026-07-06 16:39:11 -04:00
const [ asNpc , setAsNpc ] = useState ( false );
2025-12-13 19:15:26 -05:00
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 );
2026-07-04 18:20:32 -04:00
const [ customConditionInput , setCustomConditionInput ] = useState ( '' );
// Per-campaign custom conditions (DM-added freeform strings).
const { data : campaignDoc } = useFirestoreDocument ( campaignId ? getPath . campaign ( campaignId ) : null );
const customConditions = ( campaignDoc && campaignDoc . customConditions ) || [];
// Merged condition list: built-ins + custom. Custom = { id: label, label, emoji: '' }.
// id may contain spaces/emoji — toggleCondition stores raw string.
const allConditions = [
... CONDITIONS ,
... customConditions . map ( label => ({ id : label , label , emoji : '' })),
];
2025-12-13 19:15:26 -05:00
2025-05-25 21:19:22 -04:00
const participants = encounter . participants || [];
2026-07-08 14:05:55 -04:00
const [ editingField , setEditingField ] = useState ( null );
const [ editingId , setEditingId ] = useState ( null );
const blurTimeoutRef = useRef ( null );
const handleFieldFocus = ( id , label ) => {
if ( blurTimeoutRef . current ) { clearTimeout ( blurTimeoutRef . current ); blurTimeoutRef . current = null ; }
setEditingId ( id );
setEditingField ( label );
};
const handleFieldBlur = () => {
blurTimeoutRef . current = setTimeout (() => { setEditingField ( null ); setEditingId ( null ); }, 150 );
};
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 {
2026-07-07 17:24:36 -04:00
setMaxHp ( '' );
2025-12-13 19:15:26 -05:00
}
2026-07-06 16:39:11 -04:00
setAsNpc ( false );
2025-12-13 19:15:26 -05:00
} else if ( participantType === 'monster' ) {
2026-07-07 17:24:36 -04:00
setMaxHp ( '' );
2025-12-13 19:15:26 -05:00
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 ;
2026-07-06 16:39:11 -04:00
let participantAsNpc = false ;
2026-07-01 22:25:52 -04:00
const manualInit = manualInitiative !== '' && ! isNaN ( parseInt ( manualInitiative , 10 ));
const finalInitiative = manualInit ? parseInt ( manualInitiative , 10 ) : null ;
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 ) {
return ;
}
if ( participants . some ( p => p . type === 'character' && p . originalCharacterId === selectedCharacterId )) {
2026-07-04 22:27:32 -04:00
showInfo ( ` ${ character . name } is already in this encounter.` );
2025-12-13 19:15:26 -05:00
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 ;
2026-07-06 16:39:11 -04:00
participantAsNpc = asNpc ;
2026-07-07 17:24:36 -04:00
// maxHp wins if both set; else roll formula
if ( ! maxHp && hpFormula . trim ()) {
const rolled = shared . rollHpFormula ( hpFormula );
if ( rolled ) currentMaxHp = rolled ;
}
2025-05-25 21:19:22 -04:00
}
2025-12-13 19:15:26 -05:00
2026-07-01 22:25:52 -04:00
const computedInitiative = manualInit ? finalInitiative : ( initiativeRoll + modifier );
2025-12-13 19:15:26 -05:00
const newParticipant = {
id : generateId (),
name : nameToAdd ,
2026-07-06 16:39:11 -04:00
type : participantType === 'monster' && participantAsNpc ? 'npc' : participantType ,
2025-12-13 19:15:26 -05:00
originalCharacterId : participantType === 'character' ? selectedCharacterId : null ,
2026-07-01 22:25:52 -04:00
initiative : computedInitiative ,
2025-12-13 19:15:26 -05:00
maxHp : currentMaxHp ,
currentHp : currentMaxHp ,
2026-07-07 17:24:36 -04:00
hpFormula : participantType === 'monster' ? ( hpFormula . trim () || null ) : null ,
2026-07-08 12:23:19 -04:00
ac : participantType === 'monster' && monsterAc !== '' ? parseInt ( monsterAc , 10 ) : ( participantType === 'character' ? ( campaignCharacters . find ( c => c . id === selectedCharacterId ) ? . defaultAc ?? null ) : null ),
2025-12-13 19:15:26 -05:00
conditions : [],
isActive : true ,
2026-07-06 16:39:11 -04:00
status : 'conscious' ,
deathSaveSuccesses : 0 ,
deathSaveFailures : 0 ,
2025-05-27 10:51:29 -04:00
};
2025-12-13 19:15:26 -05:00
try {
2026-07-06 10:31:46 -04:00
await addParticipant ( encounter , newParticipant , buildCtx ( encounterPath ));
2025-12-13 19:15:26 -05:00
setLastRollDetails ({
name : nameToAdd ,
2026-07-01 22:25:52 -04:00
roll : manualInit ? null : initiativeRoll ,
mod : manualInit ? null : modifier ,
total : computedInitiative ,
2026-07-06 16:39:11 -04:00
type : participantAsNpc ? 'NPC' : participantType ,
2026-07-01 22:25:52 -04:00
manual : manualInit ,
2025-12-13 19:15:26 -05:00
});
setTimeout (() => setLastRollDetails ( null ), ROLL_DISPLAY_DURATION );
setParticipantName ( '' );
2026-07-07 17:24:36 -04:00
setMaxHp ( '' );
setHpFormula ( '' );
2025-12-13 19:15:26 -05:00
setSelectedCharacterId ( '' );
setMonsterInitMod ( MONSTER_DEFAULT_INIT_MOD );
2026-07-08 12:23:19 -04:00
setMonsterAc ( '' );
2026-07-06 16:39:11 -04:00
setAsNpc ( false );
2026-07-01 22:25:52 -04:00
setManualInitiative ( '' );
2025-12-13 19:15:26 -05:00
} catch ( err ) {
2026-07-04 22:27:32 -04:00
showToast ( "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 ,
2026-07-06 16:39:11 -04:00
status : 'conscious' ,
deathSaveSuccesses : 0 ,
deathSaveFailures : 0 ,
2025-12-13 19:15:26 -05:00
};
});
if ( newParticipants . length === 0 ) {
2026-07-04 22:27:32 -04:00
showInfo ( "All campaign characters are already in this encounter." ); return ;
2025-12-13 19:15:26 -05:00
}
try {
2026-07-06 10:31:46 -04:00
await addParticipants ( encounter , newParticipants , buildCtx ( encounterPath ));
2025-12-13 19:15:26 -05:00
} catch ( err ) {
2026-07-04 22:27:32 -04:00
showToast ( "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
try {
2026-07-06 10:31:46 -04:00
await updateParticipant ( encounter , editingParticipant . id , updatedData , buildCtx ( encounterPath ));
2025-12-13 19:15:26 -05:00
setEditingParticipant ( null );
} catch ( err ) {
2026-07-04 22:27:32 -04:00
showToast ( "Failed to update participant. Please try again." ); }
2025-12-13 19:15:26 -05:00
};
2026-07-01 22:29:38 -04:00
// Inline initiative edit (FEAT-3): blur/Enter commits. Reslots participant
// into correct list position (stable sort by init desc, tie-break original
// index). Display + AdminView both reflect new order. Pre-combat only —
// field gated to !started||paused elsewhere.
2026-07-01 22:25:52 -04:00
const handleInlineInitiative = async ( participantId , value ) => {
if ( ! db ) return ;
const n = parseInt ( value , 10 );
if ( isNaN ( n )) return ;
try {
2026-07-06 10:31:46 -04:00
await updateParticipant ( encounter , participantId , { initiative : n }, buildCtx ( encounterPath ));
2026-07-01 22:25:52 -04:00
} catch ( err ) {
2026-07-03 17:59:41 -04:00
// fall through silently
2026-07-01 22:25:52 -04:00
}
};
2025-12-13 19:15:26 -05:00
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 ;
try {
2026-07-06 10:31:46 -04:00
await removeParticipant ( encounter , itemToDelete . id , buildCtx ( encounterPath ));
2025-12-13 19:15:26 -05:00
} catch ( err ) {
2026-07-04 22:27:32 -04:00
showToast ( "Failed to delete participant. Please try again." ); }
2025-12-13 19:15:26 -05:00
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
try {
2026-07-06 10:31:46 -04:00
await combatToggleActive ( encounter , participantId , buildCtx ( encounterPath ));
2025-12-13 19:15:26 -05:00
} catch ( err ) {
2026-07-03 17:59:41 -04:00
// fall through silently
2025-12-13 19:15:26 -05:00
}
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-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 ;
}
2026-07-03 17:59:41 -04:00
try {
2026-07-06 10:31:46 -04:00
await combatApplyHpChange ( encounter , participantId , changeType , amount , buildCtx ( encounterPath ));
2025-12-13 19:15:26 -05:00
setHpChangeValues ( prev => ({ ... prev , [ participantId ] : '' }));
} catch ( err ) {
2026-07-03 17:59:41 -04:00
// fall through silently
2025-12-13 19:15:26 -05:00
}
};
2026-07-08 14:05:55 -04:00
const handleInlineCurrentHp = async ( participantId , value ) => {
let n = parseInt ( value , 10 );
if ( isNaN ( n )) return ;
const p = participants . find ( pp => pp . id === participantId );
if ( ! p || n === p . currentHp ) return ;
try {
const isGeneric = ( encounter . ruleset === 'generic' );
let patch ;
if ( ! isGeneric && n < 0 ) n = 0 ;
if ( isGeneric ) {
patch = { currentHp : n , status : n > 0 ? 'conscious' : 'down' };
} else {
patch = { currentHp : n , deathSaveSuccesses : 0 , deathSaveFailures : 0 };
if ( n > 0 ) { patch . status = 'conscious' ; }
else if ( p . type === 'monster' ) { patch . status = 'dead' ; patch . isActive = false ; }
else { patch . status = 'dying' ; }
}
await updateParticipant ( encounter , participantId , patch , buildCtx ( encounterPath ));
} catch ( err ) {
showToast ( 'Failed to update HP.' );
}
};
const handleInlineMaxHp = async ( participantId , value ) => {
const n = parseInt ( value , 10 );
if ( isNaN ( n )) return ;
const p = participants . find ( pp => pp . id === participantId );
if ( ! p || n === p . maxHp || n <= 0 ) return ;
try {
await updateParticipant ( encounter , participantId , { maxHp : n }, buildCtx ( encounterPath ));
} catch ( err ) {
showToast ( 'Failed to update Max HP.' );
}
};
const handleInlineAc = async ( participantId , value ) => {
const n = parseInt ( value , 10 );
if ( isNaN ( n )) return ;
const p = participants . find ( pp => pp . id === participantId );
if ( ! p || n === p . ac ) return ;
try {
await updateParticipant ( encounter , participantId , { ac : n }, buildCtx ( encounterPath ));
} catch ( err ) {
showToast ( 'Failed to update AC.' );
}
};
2026-07-06 16:39:11 -04:00
const handleDeathSaveChange = async ( participantId , outcome ) => {
2025-12-13 19:15:26 -05:00
if ( ! db ) return ;
2026-07-03 17:59:41 -04:00
try {
2026-07-06 16:39:11 -04:00
await combatDeathSave ( encounter , participantId , outcome , buildCtx ( encounterPath ));
2026-07-03 17:59:41 -04:00
} catch ( err ) {
2026-07-06 16:39:11 -04:00
showToast ( err . message );
}
};
const handleNat20 = async ( participantId ) => handleDeathSaveChange ( participantId , 'nat20' );
const handleNat1 = async ( participantId ) => handleDeathSaveChange ( participantId , 'nat1' );
const handleStabilize = async ( participantId ) => {
if ( ! db ) return ;
try {
await stabilizeParticipant ( encounter , participantId , buildCtx ( encounterPath ));
} catch ( err ) {
showToast ( err . message );
}
};
const handleRevive = async ( participantId ) => {
if ( ! db ) return ;
try {
await reviveParticipant ( encounter , participantId , buildCtx ( encounterPath ));
} catch ( err ) {
showToast ( err . message );
2025-12-13 19:15:26 -05:00
}
};
2026-07-07 13:32:26 -04:00
const handleMarkDead = async ( participantId ) => {
if ( ! db ) return ;
try {
await markDead ( encounter , participantId , buildCtx ( encounterPath ));
} catch ( err ) {
showToast ( err . message );
}
};
2026-07-06 18:38:49 -04:00
const handleCritDamage = async ( participantId ) => {
if ( ! db ) return ;
try {
// Any damage at 0 HP + isCriticalHit = 2 death-save failures (5e crit-at-0).
await combatApplyHpChange ( encounter , participantId , 'damage' , 1 , { isCriticalHit : true }, buildCtx ( encounterPath ));
} catch ( err ) {
showToast ( err . message );
}
};
2026-04-26 10:37:25 -04:00
const toggleCondition = async ( participantId , conditionId ) => {
if ( ! db ) return ;
try {
2026-07-06 10:31:46 -04:00
await combatToggleCondition ( encounter , participantId , conditionId , buildCtx ( encounterPath ));
2026-04-26 10:37:25 -04:00
} catch ( err ) {
2026-07-03 17:59:41 -04:00
// fall through silently
2026-04-26 10:37:25 -04:00
}
};
2026-07-04 20:41:34 -04:00
// Add freeform condition: persist to campaign palette + apply to current participant.
const addCustomCondition = async ( label , participantId = null ) => {
2026-07-04 18:20:32 -04:00
const trimmed = ( label || '' ). trim ();
if ( ! trimmed || ! campaignId ) return ;
2026-07-04 20:41:34 -04:00
setCustomConditionInput ( '' );
2026-07-04 18:20:32 -04:00
const exists = allConditions . some ( c => c . label . toLowerCase () === trimmed . toLowerCase ());
2026-07-04 20:41:34 -04:00
if ( ! exists ) {
try {
await storage . updateDoc ( getPath . campaign ( campaignId ), { customConditions : [... customConditions , trimmed ] });
} catch ( err ) {}
}
if ( participantId ) {
const cur = ( encounter . participants . find ( p => p . id === participantId ) ? . conditions ) || [];
if ( ! cur . includes ( trimmed )) {
try {
2026-07-06 10:31:46 -04:00
await combatToggleCondition ( encounter , participantId , trimmed , buildCtx ( encounterPath ));
2026-07-04 20:41:34 -04:00
} catch ( err ) {}
}
2026-07-04 18:20:32 -04:00
}
};
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 ;
}
try {
2026-07-06 10:31:46 -04:00
await reorderParticipants ( encounter , draggedItemId , targetId , buildCtx ( encounterPath ));
2025-12-13 19:15:26 -05:00
} catch ( err ) {
2026-07-03 17:59:41 -04:00
// drag invalid (id not found) — ignore
2025-12-13 19:15:26 -05:00
}
setDraggedItemId ( null );
2025-05-25 21:19:22 -04:00
};
2025-12-13 19:15:26 -05:00
2026-07-01 16:03:38 -04:00
// 1-list model: participants[] IS the display order. No re-sort.
const sortedParticipants = participants ;
2025-12-13 19:15:26 -05:00
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-07-07 17:24:36 -04:00
className = "grid grid-cols-1 md:grid-cols-12 gap-x-4 gap-y-2 mb-4 p-3 bg-stone-800 rounded items-end"
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 ( '' );
2026-07-06 16:39:11 -04:00
setAsNpc ( false );
2025-12-13 19:15:26 -05:00
}}
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' ? (
<>
2026-07-07 17:24:36 -04:00
< div className = "md:col-span-10" >
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>
2026-07-08 12:23:19 -04:00
< 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>
2026-07-08 12:23:19 -04:00
< div className = "md:col-span-2" >
2026-07-01 22:25:52 -04:00
< label htmlFor = "manualInitiative" className = "block text-sm font-medium text-stone-300" >
Initiative < span className = "text-xs text-stone-400" > ( blank = roll ) < /span>
< /label>
< input
type = "number"
id = "manualInitiative"
value = { manualInitiative }
onChange = {( e ) => setManualInitiative ( e . target . value )}
placeholder = "auto"
className = "mt-1 w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
/>
< /div>
2026-07-08 12:23:19 -04:00
< div className = "md:col-span-2" >
< label htmlFor = "monsterAc" className = "block text-sm font-medium text-stone-300" > AC < /label>
< input
type = "number"
id = "monsterAc"
value = { monsterAc }
onChange = {( e ) => setMonsterAc ( e . target . value )}
className = "mt-1 w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
/>
< /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>
2026-07-08 12:23:19 -04:00
< div className = "md:col-span-4" >
2026-07-07 17:24:36 -04:00
< label htmlFor = "monsterHpFormula" className = "block text-sm font-medium text-stone-300" >
2026-07-08 12:23:19 -04:00
HP Formula
2026-07-07 17:24:36 -04:00
< /label>
< input
type = "text"
id = "monsterHpFormula"
value = { hpFormula }
onChange = {( e ) => setHpFormula ( e . target . value )}
2026-07-08 12:23:19 -04:00
placeholder = "e.g. 2d6+9"
2026-07-07 17:24:36 -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"
/>
< /div>
< div className = "md:col-span-12 flex items-center pt-2" >
2025-12-13 19:15:26 -05:00
< input
type = "checkbox"
2026-07-06 16:39:11 -04:00
id = "asNpc"
checked = { asNpc }
onChange = {( e ) => setAsNpc ( 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-07-06 16:39:11 -04:00
< label htmlFor = "asNpc" 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>
2026-07-04 22:28:41 -04:00
{ campaignCharacters . filter ( c => ! participants . some ( p => p . type === 'character' && p . originalCharacterId === c . id )). map ( c => (
2025-12-13 19:15:26 -05:00
< 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>
2026-07-01 22:25:52 -04:00
< div className = "md:col-span-2" >
< label htmlFor = "charManualInitiative" className = "block text-sm font-medium text-stone-300" >
Initiative < span className = "text-xs text-stone-400" > ( blank = roll ) < /span>
< /label>
< input
type = "number"
id = "charManualInitiative"
value = { manualInitiative }
onChange = {( e ) => setManualInitiative ( e . target . value )}
placeholder = "auto"
className = "mt-1 w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
/>
< /div>
2025-12-13 19:15:26 -05:00
< />
)}
2025-05-25 21:19:22 -04:00
2026-07-07 17:24:36 -04:00
< div className = "md:col-span-12 flex justify-end mt-2" >
2025-12-13 19:15:26 -05:00
< 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-07-01 22:25:52 -04:00
{ lastRollDetails . manual
2026-07-06 16:39:11 -04:00
? ` ${ lastRollDetails . name } ( ${ lastRollDetails . type === 'character' ? 'Character' : lastRollDetails . type === 'monster' ? 'Monster' : lastRollDetails . type === 'npc' ? 'NPC' : lastRollDetails . type } ): Set initiative ${ lastRollDetails . total } `
: ` ${ lastRollDetails . name } ( ${ lastRollDetails . type === 'character' ? 'Character' : lastRollDetails . type === 'monster' ? 'Monster' : lastRollDetails . type === 'npc' ? 'NPC' : lastRollDetails . type } ): Rolled d20 ( ${ lastRollDetails . roll } ) ${ formatInitMod ( lastRollDetails . mod ) } = ${ lastRollDetails . total } Initiative`
2026-07-01 22:25:52 -04:00
}
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
2026-07-08 14:05:55 -04:00
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 ));
2026-07-06 16:39:11 -04:00
const participantDisplayType = p . type === 'npc' ? 'NPC' : ( p . type === 'monster' ? 'Monster' : 'Character' );
2026-07-07 13:32:26 -04:00
const isGeneric = ( encounter . ruleset || '5e' ) === 'generic' ;
const hasDeathSaves = ! isGeneric && ( p . type === 'character' || p . type === 'npc' );
2025-12-13 19:15:26 -05:00
2026-07-06 16:39:11 -04:00
let bgColor = p . type === 'character' ? 'bg-indigo-950' : 'bg-[#8e351c]' ;
2025-12-13 19:15:26 -05:00
if ( isCurrentTurn && ! encounter . isPaused ) bgColor = 'bg-green-600' ;
2026-07-07 13:32:26 -04:00
const participantStatus = p . status || ( isGeneric
? ( p . currentHp <= 0 ? 'down' : 'conscious' )
: ( p . currentHp === 0 ? ( p . type === 'monster' ? 'dead' : 'dying' ) : 'conscious' ));
const isZeroHp = p . currentHp <= 0 ;
2026-07-06 16:39:11 -04:00
const isDead = participantStatus === 'dead' ;
2026-07-07 13:32:26 -04:00
const isDown = participantStatus === 'down' ;
2025-12-13 19:15:26 -05:00
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 )}
2026-07-08 14:05:55 -04:00
className = { `relative p-3 rounded-md flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 transition-all duration-300 ${ bgColor } ${ isCurrentTurn && ! encounter . isPaused ? 'ring-2 ring-green-300 shadow-lg' : '' } ${ ! p . isActive ? 'opacity-35 grayscale' : ( isDead ? 'opacity-90 brightness-75 saturate-125' : '' ) } ${ isDraggable ? 'cursor-grab' : '' } ${ draggedItemId === p . id ? 'opacity-50 ring-2 ring-yellow-400' : '' } ${ editingId === p . id ? 'ring-2 ring-amber-400' : '' } ` }
2025-12-13 19:15:26 -05:00
>
2026-07-08 14:05:55 -04:00
{ editingId === p . id && (
< span className = "pointer-events-none absolute inset-0 z-20 flex items-center justify-center" >
< span className = "px-4 py-1 bg-amber-600 rounded-full text-white text-sm font-bold shadow-lg" >
✎ Editing { editingField }
< /span>
< /span>
)}
2025-12-13 19:15:26 -05:00
< 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" >
2026-07-06 16:39:11 -04:00
{ isZeroHp && < span className = "mr-2" > ☠️ < /span>}
2025-12-13 19:15:26 -05:00
{ p . name } < span className = "text-xs" > ({ participantDisplayType }) < /span>
2026-07-08 12:23:19 -04:00
{ p . ac != null && (
< span className = "ml-2 inline-flex items-baseline gap-1 px-2 py-0.5 bg-sky-900 border border-sky-500 rounded" >
< span className = "text-[10px] text-sky-300 font-semibold tracking-wide" > AC < /span>
2026-07-08 14:05:55 -04:00
< input
type = "number"
defaultValue = { p . ac }
key = { `ac- ${ p . ac } ` }
onFocus = {( e ) => { e . target . select (); handleFieldFocus ( p . id , 'AC' ); }}
onBlur = {( e ) => { if ( e . target . value !== String ( p . ac )) handleInlineAc ( p . id , e . target . value ); handleFieldBlur (); }}
onKeyDown = {( e ) => { if ( e . key === 'Enter' ) e . target . blur (); if ( e . key === 'Escape' ) e . target . value = p . ac ; }}
className = "w-6 bg-transparent text-sky-100 text-lg font-bold leading-none text-center focus:outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
aria - label = { `AC for ${ p . name } ` }
/>
2026-07-08 12:23:19 -04:00
< /span>
)}
2025-12-13 19:15:26 -05:00
{ 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-07-06 16:39:11 -04:00
{ hasDeathSaves && participantStatus === 'dying' && < span className = "ml-2 text-xs text-red-300 font-semibold animate-pulse" > ( Dying ) < /span>}
{ hasDeathSaves && participantStatus === 'stable' && ( p . conditions || []). includes ( 'unconscious' ) && < span className = "ml-2 text-xs text-emerald-300 font-semibold" > ( Unconscious ) < /span>}
2026-07-07 13:32:26 -04:00
{ isDown && < span className = "ml-2 text-xs text-red-300 font-semibold animate-pulse" > ( Down ) < /span>}
2026-07-06 16:39:11 -04:00
{ isDead && < span className = "ml-2 px-2 py-0.5 rounded bg-red-950 border border-red-500 text-red-200 text-xs font-bold" > DEAD < /span>}
2025-12-13 19:15:26 -05:00
< /p>
2026-07-01 22:25:52 -04:00
< div className = { `text-sm ${ isCurrentTurn && ! encounter . isPaused ? 'text-green-100' : 'text-stone-200' } flex items-center gap-2` } >
< span className = "inline-flex items-center gap-1" >
< label htmlFor = { `init- ${ p . id } ` } className = "sr-only" > Initiative < /label>
< input
type = "number"
id = { `init- ${ p . id } ` }
defaultValue = { p . initiative }
2026-07-08 14:05:55 -04:00
key = { `init- ${ p . initiative } ` }
2026-07-01 22:25:52 -04:00
min = "0"
max = "99"
2026-07-01 22:29:38 -04:00
disabled = { encounter . isStarted && ! encounter . isPaused }
2026-07-01 22:25:52 -04:00
onChange = {( e ) => { if ( e . target . value . length > 2 ) e . target . value = e . target . value . slice ( 0 , 2 ); }}
2026-07-08 14:05:55 -04:00
onFocus = {( e ) => { e . target . select (); handleFieldFocus ( p . id , 'Initiative' ); }}
onBlur = {( e ) => { if ( e . target . value !== String ( p . initiative )) handleInlineInitiative ( p . id , e . target . value ); handleFieldBlur (); }}
2026-07-01 22:25:52 -04:00
onKeyDown = {( e ) => { if ( e . key === 'Enter' ) e . target . blur (); }}
2026-07-08 14:05:55 -04:00
className = "w-6 bg-transparent text-white text-sm text-center border-b border-transparent hover:border-stone-500 focus:border-amber-500 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
2026-07-01 22:25:52 -04:00
aria - label = { `Initiative for ${ p . name } ` }
/>
< /span>
2026-07-08 14:05:55 -04:00
< span className = "inline-flex items-baseline" >
< input
type = "number"
id = { `hp- ${ p . id } ` }
defaultValue = { p . currentHp }
key = { `hp- ${ p . currentHp } ` }
onFocus = {( e ) => { e . target . select (); handleFieldFocus ( p . id , 'HP' ); }}
onBlur = {( e ) => { if ( e . target . value !== String ( p . currentHp )) handleInlineCurrentHp ( p . id , e . target . value ); handleFieldBlur (); }}
onKeyDown = {( e ) => { if ( e . key === 'Enter' ) e . target . blur (); if ( e . key === 'Escape' ) e . target . value = p . currentHp ; }}
className = "w-8 bg-transparent text-white text-sm text-center border-b border-transparent hover:border-stone-500 focus:border-red-500 focus:outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
aria - label = { `Current HP for ${ p . name } ` }
/>
< span className = "text-stone-500 text-sm" > /</span>
< input
type = "number"
defaultValue = { p . maxHp }
key = { `maxhp- ${ p . maxHp } ` }
onFocus = {( e ) => { e . target . select (); handleFieldFocus ( p . id , 'Max HP' ); }}
onBlur = {( e ) => { if ( e . target . value !== String ( p . maxHp )) handleInlineMaxHp ( p . id , e . target . value ); handleFieldBlur (); }}
onKeyDown = {( e ) => { if ( e . key === 'Enter' ) e . target . blur (); if ( e . key === 'Escape' ) e . target . value = p . maxHp ; }}
className = "w-8 bg-transparent text-stone-400 text-sm text-center border-b border-transparent hover:border-stone-500 focus:border-red-500 focus:outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
aria - label = { `Max HP for ${ p . name } ` }
/>
< /span>
2026-07-01 22:25:52 -04:00
< /div>
2025-12-13 19:15:26 -05:00
2026-07-06 16:39:11 -04:00
{ participantStatus === 'dead' && (
2026-07-07 13:32:26 -04:00
< button onClick = {() => handleRevive ( p . id )} className = "mt-2 inline-flex items-center gap-1 self-start px-2.5 py-1 text-xs rounded bg-emerald-800 hover:bg-emerald-700 text-white" title = "Revive (clear dead status)" >
2026-07-06 16:39:11 -04:00
< HeartPulse size = { 14 } /> Revive
< /button>
)}
2026-07-07 13:32:26 -04:00
{ isGeneric && ! isDead && (
< button onClick = {() => handleMarkDead ( p . id )} className = "mt-2 inline-flex items-center gap-1 self-start px-2.5 py-1 text-xs rounded bg-red-900 hover:bg-red-800 text-white" title = "Mark dead" >
< HeartCrack size = { 14 } /> Mark Dead
< /button>
)}
2026-07-06 18:34:50 -04:00
{ hasDeathSaves && participantStatus === 'stable' && (
< div className = "mt-2 text-xs text-emerald-300/80 italic" > Stable — regains 1 HP after 1 d4 hours < /div>
)}
2026-07-06 16:39:11 -04:00
{ /* Death saves - D&D 5e status state machine */ }
{ hasDeathSaves && encounter . isStarted && participantStatus !== 'conscious' && (
2026-07-04 17:56:14 -04:00
< div className = "mt-2 flex flex-col space-y-1" >
2026-07-06 16:39:11 -04:00
{ participantStatus === 'dying' && (
<>
< div className = "flex items-center space-x-2" >
< span className = "text-xs text-emerald-300 font-medium w-10" > Saves :< /span>
{[ 1 , 2 , 3 ]. map ( saveNum => (
< span
key = { saveNum }
className = { `w-6 h-6 rounded border-2 flex items-center justify-center ${ ( p . deathSaveSuccesses || 0 ) >= saveNum ? 'bg-emerald-600 border-emerald-500' : 'bg-stone-800 border-stone-600' } ` }
title = { `Success pip ${ saveNum } ` }
>
{( p . deathSaveSuccesses || 0 ) >= saveNum && < span className = "text-white text-sm" > ✓ < /span>}
< /span>
))}
< /div>
< div className = "flex items-center space-x-2" >
< span className = "text-xs text-red-300 font-medium w-10" > Fails :< /span>
{[ 1 , 2 , 3 ]. map ( failNum => (
< span
key = { failNum }
className = { `w-6 h-6 rounded border-2 flex items-center justify-center ${ ( p . deathSaveFailures || 0 ) >= failNum ? 'bg-red-600 border-red-500' : 'bg-stone-800 border-stone-600' } ` }
title = { `Fail pip ${ failNum } ` }
>
{( p . deathSaveFailures || 0 ) >= failNum && < span className = "text-white text-sm" > ✕ < /span>}
< /span>
))}
< /div>
< div className = "flex gap-2 mt-1" >
2026-07-06 22:13:31 -04:00
< button onClick = {() => handleDeathSaveChange ( p . id , 'success' )} className = "inline-flex items-center gap-1 px-2 py-1 text-xs rounded bg-emerald-700 hover:bg-emerald-600 text-white" title = "Death save success" >
2026-07-06 16:39:11 -04:00
Success
< /button>
2026-07-06 22:13:31 -04:00
< button onClick = {() => handleDeathSaveChange ( p . id , 'fail' )} className = "inline-flex items-center gap-1 px-2 py-1 text-xs rounded bg-red-700 hover:bg-red-600 text-white" title = "Death save failure" >
2026-07-06 16:39:11 -04:00
Fail
< /button>
2026-07-06 22:13:31 -04:00
< button onClick = {() => handleNat1 ( p . id )} className = "inline-flex items-center gap-1 px-2 py-1 text-xs rounded bg-red-800 hover:bg-red-700 text-white" title = "Natural 1: +2 failures" >
2026-07-06 16:39:11 -04:00
< AlertTriangle size = { 12 } /> Nat1
< /button>
2026-07-06 22:13:31 -04:00
< button onClick = {() => handleNat20 ( p . id )} className = "inline-flex items-center gap-1 px-2 py-1 text-xs rounded bg-yellow-600 hover:bg-yellow-500 text-white" title = "Natural 20: 1 HP + conscious" >
2026-07-06 16:39:11 -04:00
< Zap size = { 12 } /> Nat20
< /button>
2026-07-06 22:13:31 -04:00
< button onClick = {() => handleStabilize ( p . id )} className = "inline-flex items-center gap-1 px-2 py-1 text-xs rounded bg-emerald-700 hover:bg-emerald-600 text-white" title = "Stabilize at 0 HP" >
2026-07-06 16:39:11 -04:00
< HeartPulse size = { 12 } /> Stabilize
< /button>
< /div>
< />
2026-07-04 17:56:14 -04:00
)}
2025-12-13 19:15:26 -05:00
< /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 => {
2026-07-04 20:41:34 -04:00
const cond = allConditions . find ( c => c . id === cId );
2026-04-26 10:37:25 -04:00
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>
2026-07-04 18:20:32 -04:00
) : (
< 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 ${ cId } ` }
onClick = {() => toggleCondition ( p . id , cId )}
>
{ cId }
< /span>
);
2026-04-26 10:37:25 -04:00
})}
< /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" >
2026-07-04 18:20:32 -04:00
{ allConditions . map ( cond => {
2026-04-26 10:37:25 -04:00
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>
2026-07-04 18:20:32 -04:00
{ /* Add custom condition (per-campaign, freeform string) */ }
< div className = "mt-2 flex gap-1" >
< input
type = "text"
value = { customConditionInput }
onChange = {( e ) => setCustomConditionInput ( e . target . value )}
onKeyDown = {( e ) => {
2026-07-04 20:41:34 -04:00
if ( e . key === 'Enter' ) { e . preventDefault (); addCustomCondition ( customConditionInput , p . id ); }
2026-07-04 18:20:32 -04:00
}}
placeholder = "Add custom condition..."
className = "flex-grow px-2 py-1 text-xs rounded bg-stone-800 border border-stone-600 text-stone-200 placeholder-stone-500 focus:outline-none focus:border-amber-500"
maxLength = { 40 }
/>
< button
type = "button"
2026-07-04 20:41:34 -04:00
onClick = {() => addCustomCondition ( customConditionInput , p . id )}
2026-07-04 18:20:32 -04:00
disabled = { ! customConditionInput . trim ()}
className = "px-2 py-1 rounded text-xs bg-amber-700 border border-amber-500 text-white hover:bg-amber-600 disabled:opacity-40 disabled:cursor-not-allowed"
>
Add
< /button>
< /div>
2026-04-26 10:37:25 -04:00
< /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" >
2026-07-06 22:13:31 -04:00
{ hasDeathSaves && ( participantStatus === 'dying' || participantStatus === 'stable' ) && encounter . isStarted && (
< button onClick = {() => handleCritDamage ( p . id )} className = "inline-flex items-center gap-1 px-2 py-1 text-xs rounded bg-red-900 hover:bg-red-800 text-red-100 border border-red-700" title = "Critical hit at 0 HP: +2 death-save failures" >
< Crosshair size = { 12 } /> Crit
< /button>
)}
2025-12-13 19:15:26 -05:00
{ 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>
)}
2026-07-06 16:39:11 -04:00
< button
onClick = {() => toggleParticipantActive ( p . id )}
className = { `p-1 rounded transition-colors ${ p . isActive ? 'text-yellow-400 hover:text-yellow-300' : 'text-stone-400 hover:text-stone-300' } bg-stone-700 hover:bg-stone-600` }
title = { p . isActive ? "Mark Inactive" : "Mark Active" }
>
{ p . isActive ? < 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 }) {
2026-07-04 22:27:32 -04:00
const { showToast , showInfo } = useUIFeedback ();
2025-12-13 19:15:26 -05:00
const [ showEndConfirm , setShowEndConfirm ] = useState ( false );
2026-05-16 09:30:35 -04:00
const { data : activeDisplayData } = useFirestoreDocument ( getPath . activeDisplay ());
const hidePlayerHp = activeDisplayData ? . hidePlayerHp ?? true ;
2026-07-02 11:29:02 -04:00
const hideNpcHp = activeDisplayData ? . hideNpcHp ?? false ;
2026-05-16 09:30:35 -04:00
2026-07-06 10:31:46 -04:00
// Undo/redo queried on click. No live logs subscription during combat.
const latestLogQuery = [ where ( 'encounterPath' , '==' , encounterPath ), LOG_ORDER , limit ( 25 )];
const findUndoTarget = async ( redo = false ) => {
const rows = ( await storage . getCollection ( getPath . logs (), latestLogQuery ) || [])
. filter ( l => l . undo || l . undo_payload );
if ( ! redo ) return rows . find ( l => ! l . undone ) || null ;
// Redo stack = contiguous undone entries at top of log (newest first).
// If A3 then A2 undone, redo must replay A2 first, then A3.
const undoneBlock = [];
for ( const row of rows ) {
if ( ! row . undone ) break ;
undoneBlock . push ( row );
}
return undoneBlock . length ? undoneBlock [ undoneBlock . length - 1 ] : null ;
};
2026-07-04 22:48:58 -04:00
const handleUndo = async () => {
2026-07-06 10:31:46 -04:00
if ( ! db ) return ;
2026-07-04 22:48:58 -04:00
try {
2026-07-06 10:31:46 -04:00
if ( storage . undo ) {
const undoTarget = await findUndoTarget ( false );
if ( ! undoTarget ) { showToast ( 'No action to undo.' ); return ; }
const expanded = undoTarget . undo
? expandUndo ( undoTarget , encounter ) // lean: build patch from current enc
: getUndo ( undoTarget ); // legacy
if ( ! expanded ) return ;
await storage . undo ({ logPath : ` ${ getPath . logs () } / ${ undoTarget . id } ` , undo : expanded });
}
2026-07-04 22:48:58 -04:00
} catch ( err ) {
console . error ( 'Error undoing action:' , err );
showToast ( 'Failed to undo. The encounter may have changed or no longer exists.' );
}
};
const handleRedo = async () => {
2026-07-06 10:31:46 -04:00
if ( ! db ) return ;
2026-07-04 22:48:58 -04:00
try {
2026-07-06 10:31:46 -04:00
if ( storage . undo ) {
const redoTarget = await findUndoTarget ( true );
if ( ! redoTarget ) { showToast ( 'No action to redo.' ); return ; }
const expanded = redoTarget . undo
? expandUndo ( redoTarget , encounter )
: getUndo ( redoTarget );
if ( ! expanded ) return ;
await storage . undo ({ logPath : ` ${ getPath . logs () } / ${ redoTarget . id } ` , undo : expanded , redo : true });
}
2026-07-04 22:48:58 -04:00
} catch ( err ) {
console . error ( 'Error redoing action:' , err );
showToast ( 'Failed to redo.' );
}
};
2026-05-16 09:30:35 -04:00
const handleToggleHidePlayerHp = async () => {
if ( ! db ) return ;
try {
2026-07-06 10:31:46 -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
2026-07-02 11:29:02 -04:00
const handleToggleHideNpcHp = async () => {
if ( ! db ) return ;
try {
2026-07-04 12:22:04 -04:00
await storage . setDoc ( getPath . activeDisplay (), { hideNpcHp : ! hideNpcHp }, { merge : true });
2026-07-02 11:29:02 -04:00
} catch ( err ) {
console . error ( "Error toggling hideNpcHp:" , err );
}
};
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 ) {
2026-07-04 22:27:32 -04:00
showInfo ( "Add participants first." ); return ;
2025-12-13 19:15:26 -05:00
}
2025-05-25 21:19:22 -04:00
try {
2026-07-06 10:31:46 -04:00
await startEncounter ( encounter , buildCtx ( encounterPath ));
await storage . setDoc ( getPath . activeDisplay (), { activeCampaignId : campaignId , activeEncounterId : encounter . id }, { merge : true });
2025-12-13 19:15:26 -05:00
} catch ( err ) {
2026-07-04 22:27:32 -04:00
showToast ( err . message || "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
try {
2026-07-06 10:31:46 -04:00
await togglePause ( encounter , buildCtx ( encounterPath ));
2025-12-13 19:15:26 -05:00
} catch ( err ) {
2026-07-03 17:59:41 -04:00
// fall through silently
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 ;
2026-07-03 17:59:41 -04:00
try {
2026-07-06 10:31:46 -04:00
await nextTurn ( encounter , buildCtx ( encounterPath ));
2026-07-03 17:59:41 -04:00
} catch ( err ) {
// nextTurn throws if no active participants — auto-end combat
if ( err . message === 'Encounter not running.' || err . message === 'No active turn.' ) return ;
2026-07-04 22:27:32 -04:00
showInfo ( "No active participants left." ); await storage . updateDoc ( encounterPath , {
2025-12-13 19:15:26 -05:00
isStarted : false ,
isPaused : false ,
currentTurnParticipantId : null ,
round : encounter . round
});
}
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-05-25 21:19:22 -04:00
try {
2026-07-06 10:31:46 -04:00
await endEncounter ( encounter , buildCtx ( encounterPath ));
await storage . setDoc ( getPath . activeDisplay (), { activeCampaignId : null , activeEncounterId : null }, { merge : true });
2025-12-13 19:15:26 -05:00
} catch ( err ) {
2026-07-04 22:27:32 -04:00
showToast ( "Failed to end encounter. Please try again." ); }
2025-12-13 19:15:26 -05:00
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
2026-07-06 10:31:46 -04:00
{ /* Undo / Redo — queried on click (no live logs subscription during combat) */ }
2026-07-04 22:48:58 -04:00
< div className = "mt-3 flex gap-2" >
< button
onClick = { handleUndo }
2026-07-06 10:31:46 -04:00
className = "flex-1 px-3 py-2 text-xs font-medium text-white bg-amber-700 hover:bg-amber-600 rounded-md transition-colors flex items-center justify-center"
title = "Undo latest action for this encounter"
2026-07-04 22:48:58 -04:00
>
< Undo2 size = { 14 } className = "mr-1" /> Undo
< /button>
< button
onClick = { handleRedo }
2026-07-06 10:31:46 -04:00
className = "flex-1 px-3 py-2 text-xs font-medium text-white bg-sky-700 hover:bg-sky-600 rounded-md transition-colors flex items-center justify-center"
title = "Redo latest undone action for this encounter"
2026-07-04 22:48:58 -04:00
>
< Redo2 size = { 14 } className = "mr-1" /> Redo
< /button>
< /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>
2026-07-02 11:29:02 -04:00
< label className = "flex items-center justify-between cursor-pointer gap-2 mt-2" >
< span className = "text-sm text-stone-300" > Hide NPC / monster HP < /span>
< button
role = "switch"
aria - checked = { hideNpcHp }
onClick = { handleToggleHideNpcHp }
className = { `relative inline-flex h-5 w-9 flex-shrink-0 rounded-full border-2 border-transparent transition-colors focus:outline-none ${ hideNpcHp ? 'bg-amber-600' : 'bg-stone-600' } ` }
>
< span className = { `inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${ hideNpcHp ? 'translate-x-4' : 'translate-x-0' } ` } />
< /button>
< /label>
2026-05-16 09:30:35 -04:00
< /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
// ============================================================================
2026-07-07 14:28:32 -04:00
function EncounterManager ({ campaignId , initialActiveEncounterId , campaignCharacters , encounterStartedRef , encounterActiveRef }) {
2026-07-06 10:31:46 -04:00
const { showToast } = useUIFeedback ();
2025-12-13 19:15:26 -05:00
const { data : encountersData , isLoading : isLoadingEncounters } = useFirestoreCollection (
campaignId ? getPath . encounters ( campaignId ) : null
);
const { data : activeDisplayInfo } = useFirestoreDocument ( getPath . activeDisplay ());
2026-07-07 13:32:26 -04:00
const { data : campaignDoc } = useFirestoreDocument ( campaignId ? getPath . campaign ( campaignId ) : null );
2025-12-13 19:15:26 -05:00
const [ encounters , setEncounters ] = useState ([]);
2026-07-08 14:12:35 -04:00
const [ selectedEncounterId , setSelectedEncounterId ] = useState (() => {
try { return localStorage . getItem ( 'ttrpg.selectedEncounter' ) || null ; }
catch { return null ; }
});
2025-12-13 19:15:26 -05:00
const [ showCreateModal , setShowCreateModal ] = useState ( false );
const [ showDeleteConfirm , setShowDeleteConfirm ] = useState ( false );
const [ itemToDelete , setItemToDelete ] = useState ( null );
2026-07-07 15:18:08 -04:00
const [ draggedEncounterId , setDraggedEncounterId ] = useState ( null );
2025-12-13 19:15:26 -05:00
const selectedEncounterIdRef = useRef ( selectedEncounterId );
useEffect (() => {
if ( encountersData ) setEncounters ( encountersData );
}, [ encountersData ]);
2026-07-07 15:18:08 -04:00
// Sort by order field (fallback createdAt). Drag reorder writes order.
const sortedEncounters = [...( encounters || [])]. sort (( a , b ) => {
const ao = a . order ?? null , bo = b . order ?? null ;
if ( ao !== null && bo !== null ) return ao - bo ;
if ( ao !== null ) return - 1 ;
if ( bo !== null ) return 1 ;
return ( a . createdAt || '' ). localeCompare ( b . createdAt || '' );
});
const handleEncounterDragStart = ( e , id ) => {
setDraggedEncounterId ( id );
e . dataTransfer . effectAllowed = 'move' ;
};
const handleEncounterDragOver = ( e ) => {
e . preventDefault ();
e . dataTransfer . dropEffect = 'move' ;
};
const handleEncounterDrop = async ( e , targetId ) => {
e . preventDefault ();
if ( ! db || ! draggedEncounterId || draggedEncounterId === targetId ) {
setDraggedEncounterId ( null );
return ;
}
const reordered = [... sortedEncounters ];
const fromIdx = reordered . findIndex ( x => x . id === draggedEncounterId );
const toIdx = reordered . findIndex ( x => x . id === targetId );
if ( fromIdx === - 1 || toIdx === - 1 ) { setDraggedEncounterId ( null ); return ; }
const [ moved ] = reordered . splice ( fromIdx , 1 );
reordered . splice ( toIdx , 0 , moved );
// assign order = index, batch update
const ops = reordered . map (( enc , i ) => ({
type : 'update' ,
path : ` ${ getPath . encounters ( campaignId ) } / ${ enc . id } ` ,
data : { order : i },
}));
try { await storage . batchWrite ( ops ); } catch ( err ) {}
setDraggedEncounterId ( null );
};
2025-12-13 19:15:26 -05:00
useEffect (() => {
selectedEncounterIdRef . current = selectedEncounterId ;
2026-07-08 14:12:35 -04:00
try {
if ( selectedEncounterId ) localStorage . setItem ( 'ttrpg.selectedEncounter' , selectedEncounterId );
else localStorage . removeItem ( 'ttrpg.selectedEncounter' );
} catch {}
2025-12-13 19:15:26 -05:00
}, [ 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 ]);
2026-07-07 13:32:26 -04:00
const handleCreateEncounter = async ( name , ruleset = '5e' ) => {
2025-12-13 19:15:26 -05:00
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 ,
2026-07-07 13:32:26 -04:00
isPaused : false ,
ruleset : ruleset || '5e' ,
2026-07-07 15:18:08 -04:00
order : ( encounters || []). reduce (( max , e ) => Math . max ( max , e . order ?? - 1 ), - 1 ) + 1 ,
2025-12-13 19:15:26 -05:00
});
setShowCreateModal ( false );
setSelectedEncounterId ( newEncounterId );
} catch ( err ) {
console . error ( "Error creating encounter:" , err );
2026-07-04 22:27:32 -04:00
showToast ( "Failed to create encounter. Please try again." ); }
2025-12-13 19:15:26 -05:00
};
const requestDeleteEncounter = ( encounterId , encounterName ) => {
setItemToDelete ({ id : encounterId , name : encounterName });
setShowDeleteConfirm ( true );
};
const confirmDeleteEncounter = async () => {
if ( ! db || ! itemToDelete ) return ;
const encounterId = itemToDelete . id ;
try {
2026-07-06 10:31:46 -04:00
// cascade: delete logs for this encounter
const encPath = getPath . encounter ( campaignId , encounterId );
const allLogs = await storage . getCollection ( getPath . logs ());
const encLogs = allLogs . filter ( l =>
( l . encounterId && l . encounterId === encounterId ) ||
( l . encounterPath && l . encounterPath . includes ( encPath ))
);
if ( encLogs . length > 0 ) {
await storage . batchWrite ( encLogs . map ( l => ({
type : 'delete' ,
path : ` ${ getPath . logs () } / ${ l . id || l . path ? . split ( '/' ). pop () } ` ,
})));
}
await storage . deleteDoc ( encPath );
2025-12-13 19:15:26 -05:00
if ( selectedEncounterId === encounterId ) {
setSelectedEncounterId ( null );
}
if ( activeDisplayInfo && activeDisplayInfo . activeEncounterId === encounterId ) {
2026-07-06 10:31:46 -04:00
await storage . updateDoc ( getPath . activeDisplay (), { activeCampaignId : null , activeEncounterId : null });
2025-12-13 19:15:26 -05:00
}
} catch ( err ) {
console . error ( "Error deleting encounter:" , err );
2026-07-04 22:27:32 -04:00
showToast ( "Failed to delete encounter. Please try again." ); }
2025-12-13 19:15:26 -05:00
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-07-06 10:31:46 -04:00
await storage . setDoc ( getPath . activeDisplay (), { activeCampaignId : null , activeEncounterId : null }, { merge : true });
2025-12-13 19:15:26 -05:00
} else {
2026-07-04 12:22:04 -04:00
await storage . setDoc ( getPath . activeDisplay (), {
2025-12-13 19:15:26 -05:00
activeCampaignId : campaignId ,
activeEncounterId : encounterId ,
2026-07-01 23:32:23 -04:00
});
2025-12-13 19:15:26 -05:00
}
} catch ( err ) {
console . error ( "Error toggling Player Display:" , err );
}
};
const selectedEncounter = encounters ? . find ( e => e . id === selectedEncounterId );
2026-07-07 14:28:32 -04:00
useEffect (() => {
if ( encounterStartedRef ) {
encounterStartedRef . current = !! ( selectedEncounter && selectedEncounter . isStarted && ! selectedEncounter . isPaused );
}
if ( encounterActiveRef ) {
encounterActiveRef . current = !! ( selectedEncounter && selectedEncounter . isStarted );
}
}, [ selectedEncounter , encounterStartedRef , encounterActiveRef ]);
2025-12-13 19:15:26 -05:00
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" >
2026-07-07 15:18:08 -04:00
{ sortedEncounters . map ( encounter => {
2025-12-13 19:15:26 -05:00
const isLive = activeDisplayInfo &&
activeDisplayInfo . activeCampaignId === campaignId &&
activeDisplayInfo . activeEncounterId === encounter . id ;
return (
< div
key = { encounter . id }
2026-07-07 15:18:08 -04:00
draggable
onDragStart = {( e ) => handleEncounterDragStart ( e , encounter . id )}
onDragOver = { handleEncounterDragOver }
onDrop = {( e ) => handleEncounterDrop ( e , encounter . id )}
onDragEnd = {() => setDraggedEncounterId ( null )}
className = { `p-3 rounded-md shadow transition-all ${ selectedEncounterId === encounter . id ? 'bg-amber-900 ring-2 ring-amber-500' : 'bg-stone-800 hover:bg-stone-700' } ${ isLive ? 'ring-2 ring-green-500 shadow-md shadow-green-500/30' : '' } ${ draggedEncounterId === encounter . id ? 'opacity-50 ring-2 ring-yellow-400' : '' } cursor-grab` }
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" >
2026-07-07 14:28:32 -04:00
< h4 className = "font-medium text-white" > { encounter . name } < span className = { `ml-1 px-1.5 py-0.5 rounded text-xs font-bold tracking-wide ${ encounter . ruleset === 'generic' ? 'bg-purple-900/80 text-purple-200 border border-purple-500' : 'bg-amber-900/80 text-amber-200 border border-amber-500' } ` } > { encounter . ruleset === 'generic' ? 'GEN' : '5e' } < /span></h4>
2026-04-25 20:25:34 -04:00
< p className = "text-xs text-stone-300" >
2026-07-07 14:28:32 -04:00
{ encounter . createdAt && ` ${ new Date ( encounter . createdAt ). toLocaleDateString ( 'en-US' , { month : 'short' , day : 'numeric' , hour : '2-digit' , minute : '2-digit' , hour12 : false } )} · ` } Participants : { encounter . participants ? . length || 0 }
2025-12-13 19:15:26 -05:00
< /p>
2026-07-07 16:41:34 -04:00
{( encounter . startedAt || encounter . endedAt ) && (
< p className = "text-xs text-green-400/80 mt-0.5" >
{ encounter . startedAt && `⚔ Started: ${ new Date ( encounter . startedAt ). toLocaleString ( 'en-US' , { month : 'short' , day : 'numeric' , hour : '2-digit' , minute : '2-digit' , hour12 : false } )}` }{ encounter . startedAt && encounter . endedAt && ' · ' }{ encounter . endedAt && `Ended: ${ new Date ( encounter . endedAt ). toLocaleString ( 'en-US' , { month : 'short' , day : 'numeric' , hour : '2-digit' , minute : '2-digit' , hour12 : false } )}` }
< /p>
)}
2025-12-13 19:15:26 -05:00
{ 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" >
2026-07-07 15:18:08 -04:00
< ChevronsUpDown
size = { 18 }
className = "text-stone-400 flex-shrink-0 cursor-grab"
title = "Drag to reorder"
/>
2025-12-13 19:15:26 -05:00
< 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
2026-07-07 13:32:26 -04:00
key = { campaignId }
2025-12-13 19:15:26 -05:00
onCreate = { handleCreateEncounter }
onCancel = {() => setShowCreateModal ( false )}
2026-07-07 13:32:26 -04:00
defaultRuleset = { campaignDoc ? . ruleset || '5e' }
2025-12-13 19:15:26 -05:00
/>
< /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 }
2026-07-04 18:20:32 -04:00
campaignId = { campaignId }
2025-12-13 19:15:26 -05:00
/>
< /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 }) {
2026-07-06 10:31:46 -04:00
const { showToast } = useUIFeedback ();
2025-12-13 19:15:26 -05:00
const { data : campaignsData , isLoading : isLoadingCampaigns , error : campaignsError } = useFirestoreCollection (
getPath . campaigns ()
);
const { data : initialActiveInfo } = useFirestoreDocument ( getPath . activeDisplay ());
2026-07-08 13:19:10 -04:00
const [ isFullscreen , setIsFullscreen ] = useState ( false );
const [ wakeLockEnabled , setWakeLockEnabled ] = useState (() => {
try { return localStorage . getItem ( 'ttrpg.wakeLock' ) === 'true' ; }
catch { return false ; }
});
const wakeLockRef = useRef ( null );
const toggleFullscreen = () => {
if ( ! document . fullscreenElement ) {
document . documentElement . requestFullscreen ();
} else {
document . exitFullscreen ();
}
};
useEffect (() => {
const onFsChange = () => setIsFullscreen ( !! document . fullscreenElement );
document . addEventListener ( 'fullscreenchange' , onFsChange );
return () => document . removeEventListener ( 'fullscreenchange' , onFsChange );
}, []);
useEffect (() => {
if ( ! wakeLockEnabled ) {
wakeLockRef . current ? . release ();
wakeLockRef . current = null ;
return ;
}
const acquire = async () => {
try { wakeLockRef . current = await navigator . wakeLock . request ( 'screen' ); }
catch ( e ) {
console . error ( 'Wake lock failed:' , e );
showToast ( 'Prevent Sleep failed. Requires HTTPS or Chrome flag: chrome://flags/#unsafely-treat-insecure-origin-as-secure' );
}
};
acquire ();
const onVisChange = () => { if ( document . visibilityState === 'visible' ) acquire (); };
const onFsChange = () => { if ( document . fullscreenElement ) acquire (); };
document . addEventListener ( 'visibilitychange' , onVisChange );
document . addEventListener ( 'fullscreenchange' , onFsChange );
return () => {
document . removeEventListener ( 'visibilitychange' , onVisChange );
document . removeEventListener ( 'fullscreenchange' , onFsChange );
wakeLockRef . current ? . release ();
wakeLockRef . current = null ;
};
}, [ wakeLockEnabled , showToast ]);
2025-12-13 19:15:26 -05:00
const [ campaignsWithDetails , setCampaignsWithDetails ] = useState ([]);
2026-07-07 15:24:12 -04:00
const [ draggedCampaignId , setDraggedCampaignId ] = useState ( null );
2026-07-08 14:12:35 -04:00
const [ selectedCampaignId , setSelectedCampaignId ] = useState (() => {
try { return localStorage . getItem ( 'ttrpg.selectedCampaign' ) || null ; }
catch { return null ; }
});
2026-07-07 14:28:32 -04:00
const encounterStartedRef = useRef ( false );
const encounterActiveRef = useRef ( false );
const manualSelectRef = useRef ( false );
const prevDisplayCampaignRef = useRef ( null );
2026-07-08 14:12:35 -04:00
const scrollRestoredRef = useRef ( false );
// Save scroll on unload + visibility change + interval.
useEffect (() => {
const onSave = () => {
try { localStorage . setItem ( 'ttrpg.scrollY' , String ( window . scrollY )); } catch {}
};
window . addEventListener ( 'beforeunload' , onSave );
window . addEventListener ( 'pagehide' , onSave );
document . addEventListener ( 'visibilitychange' , onSave );
const interval = setInterval ( onSave , 2000 );
return () => {
window . removeEventListener ( 'beforeunload' , onSave );
window . removeEventListener ( 'pagehide' , onSave );
document . removeEventListener ( 'visibilitychange' , onSave );
clearInterval ( interval );
};
}, []);
// Restore scroll once data loaded.
useEffect (() => {
if ( scrollRestoredRef . current ) return ;
if ( campaignsWithDetails . length === 0 ) return ;
try {
const y = parseInt ( localStorage . getItem ( 'ttrpg.scrollY' ) || '0' , 10 );
if ( y > 0 ) {
scrollRestoredRef . current = true ;
setTimeout (() => window . scrollTo ( 0 , y ), 300 );
}
} catch {}
}, [ campaignsWithDetails ]);
// Persist selections across reload.
useEffect (() => {
try {
if ( selectedCampaignId ) localStorage . setItem ( 'ttrpg.selectedCampaign' , selectedCampaignId );
else localStorage . removeItem ( 'ttrpg.selectedCampaign' );
} catch {}
}, [ selectedCampaignId ]);
2026-07-07 14:28:32 -04:00
// External display change (replay/other DM) = clear manual override, allow follow.
useEffect (() => {
const cur = initialActiveInfo ? . activeCampaignId || null ;
if ( cur !== prevDisplayCampaignRef . current ) {
manualSelectRef . current = false ;
prevDisplayCampaignRef . current = cur ;
}
}, [ initialActiveInfo ]);
2025-12-13 19:15:26 -05:00
const [ showCreateModal , setShowCreateModal ] = useState ( false );
const [ showDeleteConfirm , setShowDeleteConfirm ] = useState ( false );
const [ itemToDelete , setItemToDelete ] = useState ( null );
2026-07-06 16:39:11 -04:00
const [ campaignsCollapsed , setCampaignsCollapsed ] = useState (() => {
try { return localStorage . getItem ( 'ttrpg.campaignsCollapsed' ) === 'true' ; }
catch { return false ; }
});
useEffect (() => {
try { localStorage . setItem ( 'ttrpg.campaignsCollapsed' , String ( campaignsCollapsed )); }
catch {}
}, [ campaignsCollapsed ]);
2025-12-13 19:15:26 -05:00
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 };
})
);
2026-07-07 15:37:04 -04:00
// sort by order field (fallback createdAt)
2026-07-06 10:31:46 -04:00
detailedCampaigns . sort (( a , b ) => {
2026-07-07 15:37:04 -04:00
const ao = a . order ?? null , bo = b . order ?? null ;
if ( ao !== null && bo !== null ) return ao - bo ;
if ( ao !== null ) return - 1 ;
if ( bo !== null ) return 1 ;
2026-07-06 10:31:46 -04:00
const at = a . createdAt || 0 ;
const bt = b . createdAt || 0 ;
if ( at === bt ) return ( a . name || '' ). localeCompare ( b . name || '' );
return bt - at ;
});
2025-12-13 19:15:26 -05:00
setCampaignsWithDetails ( detailedCampaigns );
};
fetchDetails ();
} else if ( campaignsData ) {
2026-07-06 10:31:46 -04:00
const sorted = [... campaignsData ]. sort (( a , b ) => {
2026-07-07 15:24:12 -04:00
const ao = a . order ?? null , bo = b . order ?? null ;
if ( ao !== null && bo !== null ) return ao - bo ;
if ( ao !== null ) return - 1 ;
if ( bo !== null ) return 1 ;
2026-07-06 10:31:46 -04:00
const at = a . createdAt || 0 ;
const bt = b . createdAt || 0 ;
if ( at === bt ) return ( a . name || '' ). localeCompare ( b . name || '' );
return bt - at ;
});
2025-12-13 19:15:26 -05:00
setCampaignsWithDetails (
2026-07-06 10:31:46 -04:00
sorted . map ( c => ({ ... c , characters : c . players || [], encounterCount : 0 }))
2025-12-13 19:15:26 -05:00
);
}
2026-07-06 10:31:46 -04:00
// eslint-disable-next-line react-hooks/exhaustive-deps
2025-12-13 19:15:26 -05:00
}, [ campaignsData ]);
useEffect (() => {
2026-07-07 14:28:32 -04:00
// Skip follow only if user manually selected AND display hasn't changed.
// (external display change clears manualSelectRef via prevDisplay effect)
if ( manualSelectRef . current && selectedCampaignId !== initialActiveInfo ? . activeCampaignId ) return ;
2025-12-13 19:15:26 -05:00
if (
initialActiveInfo &&
initialActiveInfo . activeCampaignId &&
2026-07-07 14:28:32 -04:00
campaignsWithDetails . length > 0 &&
! encounterStartedRef . current &&
! encounterActiveRef . current
2025-12-13 19:15:26 -05:00
) {
const campaignExists = campaignsWithDetails . some ( c => c . id === initialActiveInfo . activeCampaignId );
2026-07-01 17:05:00 -04:00
if ( campaignExists && selectedCampaignId !== initialActiveInfo . activeCampaignId ) {
2025-12-13 19:15:26 -05:00
setSelectedCampaignId ( initialActiveInfo . activeCampaignId );
}
}
}, [ initialActiveInfo , campaignsWithDetails , selectedCampaignId ]);
2026-07-07 13:32:26 -04:00
const handleCreateCampaign = async ( name , backgroundUrl , ruleset = '5e' ) => {
2025-12-13 19:15:26 -05:00
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 : [],
2026-07-07 13:32:26 -04:00
ruleset : ruleset || '5e' ,
2026-07-07 15:24:12 -04:00
order : ( campaignsWithDetails || []). reduce (( max , c ) => Math . max ( max , c . order ?? - 1 ), - 1 ) + 1 ,
2025-12-13 19:15:26 -05:00
});
setShowCreateModal ( false );
setSelectedCampaignId ( newCampaignId );
} catch ( err ) {
console . error ( "Error creating campaign:" , err );
2026-07-04 22:27:32 -04:00
showToast ( "Failed to create campaign. Please try again." ); }
2025-12-13 19:15:26 -05:00
};
const requestDeleteCampaign = ( campaignId , campaignName ) => {
setItemToDelete ({ id : campaignId , name : campaignName });
setShowDeleteConfirm ( true );
};
const confirmDeleteCampaign = async () => {
if ( ! db || ! itemToDelete ) return ;
const campaignId = itemToDelete . id ;
2026-07-06 19:06:52 -04:00
await deleteCampaignCascade ( campaignId );
2025-12-13 19:15:26 -05:00
2026-07-06 19:06:52 -04:00
if ( selectedCampaignId === campaignId ) {
setSelectedCampaignId ( null );
}
2026-07-06 10:31:46 -04:00
2026-07-06 19:06:52 -04:00
setShowDeleteConfirm ( false );
setItemToDelete ( null );
showToast ( 'Campaign deleted.' );
};
2026-07-06 10:31:46 -04:00
2026-07-06 19:06:52 -04:00
// Cascade delete: encounters + logs + display + campaign doc.
const deleteCampaignCascade = async ( campaignId ) => {
const encountersPath = getPath . encounters ( campaignId );
const encounters = await storage . getCollection ( encountersPath );
const deleteOps = encounters . map ( e => {
const id = e . id || e . path ? . split ( '/' ). pop ();
return { type : 'delete' , path : ` ${ encountersPath } / ${ id } ` };
});
2025-12-13 19:15:26 -05:00
2026-07-06 19:06:52 -04:00
const allLogs = await storage . getCollection ( getPath . logs ());
const encPaths = new Set ( encounters . map ( e => ` ${ encountersPath } / ${ e . id || e . path ? . split ( '/' ). pop () } ` ));
const encIds = new Set ( encounters . map ( e => e . id || e . path ? . split ( '/' ). pop ()));
const logOps = allLogs
. filter ( l =>
( l . encounterId && encIds . has ( l . encounterId )) ||
( l . encounterPath && [... encPaths ]. some ( p => l . encounterPath . includes ( p )))
)
. map ( l => ({
type : 'delete' ,
path : ` ${ getPath . logs () } / ${ l . id || l . path ? . split ( '/' ). pop () } ` ,
}));
if ( deleteOps . length > 0 || logOps . length > 0 ) {
await storage . batchWrite ([... deleteOps , ... logOps ]);
}
2025-12-13 19:15:26 -05:00
2026-07-06 19:06:52 -04:00
await storage . deleteDoc ( getPath . campaign ( campaignId ));
2025-12-13 19:15:26 -05:00
2026-07-06 19:06:52 -04:00
const activeDisplay = await storage . getDoc ( getPath . activeDisplay ());
if ( activeDisplay && activeDisplay . activeCampaignId === campaignId ) {
await storage . updateDoc ( getPath . activeDisplay (), { activeCampaignId : null , activeEncounterId : null });
}
};
2025-12-13 19:15:26 -05:00
2026-07-06 19:06:52 -04:00
// Dev-only: wipe all campaigns + encounters + logs. Irreversible.
// Uses deleteCollection (SQL bulk DELETE, no fetch). Parallel per campaign.
const [ showDeleteAllConfirm , setShowDeleteAllConfirm ] = useState ( false );
const handleDeleteAllCampaigns = async () => {
if ( ! db ) return ;
try {
const all = await storage . getCollection ( getPath . campaigns ());
await Promise . all ( all . map ( async ( c ) => {
const cid = c . id || c . path ? . split ( '/' ). pop ();
// bulk-delete all encounters under this campaign (SQL DELETE, no fetch)
if ( storage . deleteCollection ) {
await storage . deleteCollection ( getPath . encounters ( cid ));
}
await storage . deleteDoc ( getPath . campaign ( cid ));
}));
// nuke all logs in one shot (orphans acceptable in dev; logs span campaigns)
if ( storage . deleteCollection ) {
await storage . deleteCollection ( getPath . logs ());
}
const activeDisplay = await storage . getDoc ( getPath . activeDisplay ());
if ( activeDisplay && activeDisplay . activeCampaignId ) {
2026-07-06 10:31:46 -04:00
await storage . updateDoc ( getPath . activeDisplay (), { activeCampaignId : null , activeEncounterId : null });
2025-12-13 19:15:26 -05:00
}
2026-07-06 19:06:52 -04:00
setSelectedCampaignId ( null );
setShowDeleteAllConfirm ( false );
showToast ( `Deleted ${ all . length } campaign(s).` );
2025-12-13 19:15:26 -05:00
} catch ( err ) {
2026-07-06 19:06:52 -04:00
console . error ( 'Error deleting all campaigns:' , err );
showToast ( 'Failed to delete all campaigns.' );
}
2025-12-13 19:15:26 -05:00
};
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-07-02 11:17:04 -04:00
< button
onClick = {() => setCampaignsCollapsed ( c => ! c )}
className = "flex items-center gap-2 text-2xl font-semibold text-amber-300 font-cinzel tracking-wide hover:text-amber-200 transition-colors"
aria - expanded = { ! campaignsCollapsed }
aria - controls = "campaigns-grid"
>
{ campaignsCollapsed
? < ChevronRight size = { 24 } />
: < ChevronDown size = { 24 } /> }
Campaigns
< span className = "text-sm font-normal text-stone-400" > ({ campaignsWithDetails . length }) < /span>
< /button>
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>
2026-07-08 13:19:10 -04:00
< div className = "flex gap-1" >
< button
onClick = {() => setWakeLockEnabled ( v => { localStorage . setItem ( 'ttrpg.wakeLock' , String ( ! v )); return ! 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 hover:bg-stone-700 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 hover:bg-stone-700 text-stone-300 hover:text-white p-2 rounded-lg transition-all"
>
{ isFullscreen ? < Minimize2 size = { 20 } /> : < Maximize2 size = { 20 } /> }
< /button>
< /div>
2025-12-13 19:15:26 -05:00
< /div>
2026-07-02 11:17:04 -04:00
{ ! campaignsCollapsed && (
<>
{ campaignsWithDetails . length === 0 && ! isLoadingCampaigns && (
< p className = "text-stone-400" > No campaigns yet . Create one to get started !< /p>
)}
2025-12-13 19:15:26 -05:00
2026-07-02 11:17:04 -04:00
< div id = "campaigns-grid" className = "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4" >
{ campaignsWithDetails . map ( campaign => {
2025-12-13 19:15:26 -05:00
const cardStyle = campaign . playerDisplayBackgroundUrl
? { backgroundImage : `url( ${ campaign . playerDisplayBackgroundUrl } )` }
: {};
2026-07-07 15:24:12 -04:00
const cardClasses = `h-40 flex flex-col justify-between rounded-lg shadow-md cursor-grab transition-all relative overflow-hidden bg-cover bg-center ${ selectedCampaignId === campaign . id ? 'ring-4 ring-amber-500' : '' } ${ ! campaign . playerDisplayBackgroundUrl ? 'bg-stone-800 hover:bg-stone-700' : 'hover:shadow-xl' } ${ draggedCampaignId === campaign . id ? 'opacity-50 ring-2 ring-yellow-400' : '' } ` ;
2025-12-13 19:15:26 -05:00
return (
< div
key = { campaign . id }
2026-07-07 15:24:12 -04:00
draggable
onDragStart = {( e ) => { setDraggedCampaignId ( campaign . id ); e . dataTransfer . effectAllowed = 'move' ; }}
onDragOver = {( e ) => { e . preventDefault (); e . dataTransfer . dropEffect = 'move' ; }}
onDrop = { async ( e ) => {
e . preventDefault ();
if ( ! db || ! draggedCampaignId || draggedCampaignId === campaign . id ) { setDraggedCampaignId ( null ); return ; }
const reordered = [... campaignsWithDetails ];
const fromIdx = reordered . findIndex ( c => c . id === draggedCampaignId );
const toIdx = reordered . findIndex ( c => c . id === campaign . id );
if ( fromIdx === - 1 || toIdx === - 1 ) { setDraggedCampaignId ( null ); return ; }
const [ moved ] = reordered . splice ( fromIdx , 1 );
reordered . splice ( toIdx , 0 , moved );
const ops = reordered . map (( c , i ) => ({ type : 'update' , path : getPath . campaign ( c . id ), data : { order : i } }));
try { await storage . batchWrite ( ops ); } catch ( err ) {}
setDraggedCampaignId ( null );
}}
onDragEnd = {() => setDraggedCampaignId ( null )}
2026-07-07 14:28:32 -04:00
onClick = {() => {
if ( encounterStartedRef . current ) {
showToast ( 'End or pause active encounter before switching campaigns.' );
return ;
}
manualSelectRef . current = true ;
setSelectedCampaignId ( campaign . id );
}}
2025-12-13 19:15:26 -05:00
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 >
2026-07-07 15:24:12 -04:00
< div className = "flex items-center gap-1" >
< ChevronsUpDown size = { 14 } className = "text-white/60 flex-shrink-0" title = "Drag to reorder" />
< h3 className = "text-xl font-semibold text-white" > { campaign . name } < /h3>
< /div>
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>
2026-07-01 22:25:52 -04:00
{ campaign . createdAt && (
< div className = "text-xs text-stone-300 opacity-70 mt-1" >
< Clock size = { 12 } className = "mr-1 inline-block" /> Created : { new Date ( campaign . createdAt ). toLocaleDateString ( 'en-US' , { year : 'numeric' , month : 'short' , day : 'numeric' , hour : '2-digit' , minute : '2-digit' , hour12 : false })}
< /div>
)}
2025-12-13 19:15:26 -05:00
< /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>
2026-07-07 13:32:26 -04:00
< span
className = { `absolute bottom-2 right-2 z-20 px-2 py-0.5 rounded text-xs font-bold tracking-wide ${ campaign . ruleset === 'generic' ? 'bg-purple-900/80 text-purple-200 border border-purple-500' : 'bg-amber-900/80 text-amber-200 border border-amber-500' } ` }
title = { campaign . ruleset === 'generic' ? 'Generic ruleset' : 'D&D 5e ruleset' }
>
{ campaign . ruleset === 'generic' ? 'GEN' : '5e' }
< /span>
2025-12-13 19:15:26 -05:00
< /div>
< /div>
);
})}
2026-07-02 11:17:04 -04:00
< /div>
2026-07-08 11:46:56 -04:00
{ isDevToolsEnabled () && campaignsWithDetails . length > 0 && (
< div className = "mt-4" >
< button
onClick = {() => setShowDeleteAllConfirm ( true )}
className = "px-3 py-1.5 text-xs rounded bg-red-900 hover:bg-red-800 text-red-100 border border-red-700"
title = "DEV ONLY: Delete every campaign, encounter, and log. Irreversible."
>
< Trash2 size = { 12 } className = "inline mr-1" /> DEV : Delete All Campaigns
< /button>
< /div>
)}
2026-07-02 11:17:04 -04:00
< />
)}
2025-12-13 19:15:26 -05:00
< /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 || []}
2026-07-07 14:28:32 -04:00
encounterStartedRef = { encounterStartedRef }
encounterActiveRef = { encounterActiveRef }
2025-12-13 19:15:26 -05:00
/>
< /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.` }
/>
2026-07-06 19:06:52 -04:00
< ConfirmationModal
isOpen = { showDeleteAllConfirm }
onClose = {() => setShowDeleteAllConfirm ( false )}
onConfirm = { handleDeleteAllCampaigns }
title = "Delete ALL Campaigns?"
message = "DEV ONLY: This permanently deletes EVERY campaign, all encounters, and all logs. Cannot be undone. Are you absolutely sure?"
/>
2025-12-13 19:15:26 -05:00
< />
);
}
// ============================================================================
// DISPLAY VIEW COMPONENT (Player View)
// ============================================================================
2026-07-06 22:38:33 -04:00
// Player participant card: animate state transitions.
// mount/activate : animate in (opacity+scale+slide)
// deactivate : animate out then signal exit
// alive→dead : transition to death cue (dim+desaturate+skull pulse+red rim)
// conscious/dying/stable: full visible, no fade
function PlayerParticipantCard ({ id , isActive , status , onExit , className , children , divRef }) {
// phase: 'hidden' (opacity-0, start state) -> 'visible' (opacity-100)
// disable: 'visible' -> 'exiting' (opacity-0, transition plays) -> onExit
const [ phase , setPhase ] = useState ( 'hidden' );
const isDead = status === 'dead' ;
useEffect (() => {
if ( ! isActive ) {
// ensure current 'visible' frame painted, THEN flip to exiting so
// browser sees opacity change and plays the fade-out.
const raf = requestAnimationFrame (() => {
setPhase ( 'exiting' );
});
const t = setTimeout (() => onExit ( id ), 1000 );
return () => { cancelAnimationFrame ( raf ); clearTimeout ( t ); };
}
// enable/reactivate: paint hidden frame first, THEN flip to visible
// so browser sees opacity change and plays the transition.
setPhase ( 'hidden' );
const raf = requestAnimationFrame (() => {
requestAnimationFrame (() => setPhase ( 'visible' ));
});
return () => cancelAnimationFrame ( raf );
}, [ isActive , id , onExit ]);
// animate: opacity + scale + slide. Visible motion, not just fade.
const fadeClass = phase === 'visible'
? 'opacity-100 scale-100 translate-y-0'
: 'opacity-0 scale-75 translate-y-4' ;
// death transition: fires once when alive→dead. dim + desaturate + red rim.
const deathClass = isDead
? 'brightness-50 saturate-0 border-2 border-red-900/70 shadow-red-900/50 shadow-2xl'
: '' ;
return (
< div
ref = { divRef }
2026-07-06 22:48:10 -04:00
className = { `[transition:opacity_1s_ease-in-out,transform_1s_ease-in-out,filter_1s_ease-in-out,border-color_1s_ease-in-out,box-shadow_300ms_ease-in-out] ${ fadeClass } ${ deathClass } ${ className } ` }
2026-07-06 22:38:33 -04:00
>
{ children }
{ isDead && (
< div className = "pointer-events-none absolute inset-0 flex items-center justify-center" >
< span className = "text-6xl opacity-80 animate-pulse" > ☠️ < /span>
< /div>
)}
< /div>
);
}
2025-12-13 19:15:26 -05:00
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-07-08 13:19:10 -04:00
const [ wakeLockEnabled , setWakeLockEnabled ] = useState (() => {
try { return localStorage . getItem ( 'ttrpg.wakeLock' ) === 'true' ; }
catch { return false ; }
});
2026-07-06 22:38:33 -04:00
const [ displayParticipants , setDisplayParticipants ] = useState ([]);
2026-06-27 11:56:11 -04:00
const wakeLockRef = useRef ( null );
2025-12-13 19:15:26 -05:00
const currentParticipantRef = useRef ( null );
2026-07-06 22:38:33 -04:00
// Player display transition state. Active participants render normally.
// Active→inactive: keep prior card, mark __displayActive=false, animate out,
// then PlayerParticipantCard calls handleExit to remove from display list.
// Inactive→active: card reappears with __displayActive=true and fades in.
useEffect (() => {
const source = ( activeEncounterData && activeEncounterData . participants ) || [];
setDisplayParticipants ( prev => {
const prevById = new Map ( prev . map ( p => [ p . id , p ]));
const out = [];
for ( const p of source ) {
const prevP = prevById . get ( p . id );
if ( p . isActive !== false ) {
out . push ({ ... p , __displayActive : true });
} else if ( prevP && prevP . __displayActive !== false ) {
out . push ({ ... p , __displayActive : false });
} else if ( prevP && prevP . __displayActive === false ) {
out . push ( prevP );
}
}
return out ;
});
}, [ activeEncounterData ]);
const handleExit = useCallback (( id ) => {
setDisplayParticipants ( prev => prev . filter ( p => p . id !== id ));
}, []);
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 );
2026-07-08 13:19:10 -04:00
// Re-acquire on fullscreen change (Android discards wakeLock on screen off)
const onFsChange = () => { if ( document . fullscreenElement ) acquire (); };
document . addEventListener ( 'fullscreenchange' , onFsChange );
2026-06-27 11:56:11 -04:00
return () => {
document . removeEventListener ( 'visibilitychange' , onVisChange );
2026-07-08 13:19:10 -04:00
document . removeEventListener ( 'fullscreenchange' , onFsChange );
2026-06-27 11:56:11 -04:00
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 );
2026-06-29 13:13:46 -04:00
unsubscribeCampaign = storage . subscribeDoc (
getPath . campaign ( activeCampaignId ),
( camp ) => {
setCampaignBackgroundUrl (( camp && camp . playerDisplayBackgroundUrl ) || '' );
2026-07-04 12:22:04 -04:00
},
( err ) => console . error ( "Error fetching campaign background:" , err )
2025-12-13 19:15:26 -05:00
);
2026-06-29 13:13:46 -04:00
unsubscribeEncounter = storage . subscribeDoc (
getPath . encounter ( activeCampaignId , activeEncounterId ),
( enc ) => {
if ( enc ) {
setActiveEncounterData ({ id : activeEncounterId , ... enc });
2025-12-13 19:15:26 -05:00
} else {
setActiveEncounterData ( null );
setEncounterError ( "Active encounter data not found." );
}
setIsLoadingEncounter ( false );
2026-07-04 12:22:04 -04:00
},
( err ) => {
console . error ( "Error fetching active encounter details:" , err );
setEncounterError ( "Error loading active encounter data." );
setIsLoadingEncounter ( false );
2025-12-13 19:15:26 -05:00
}
);
} 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" >
2026-07-08 13:19:10 -04:00
< div className = "fixed top-3 right-3 z-50 flex gap-2" >
< button
onClick = {() => setWakeLockEnabled ( v => { localStorage . setItem ( 'ttrpg.wakeLock' , String ( ! v )); return ! 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
< 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 ;
2026-07-02 11:29:02 -04:00
const hideNpcHp = activeDisplayData ? . hideNpcHp ?? false ;
2025-12-13 19:15:26 -05:00
2026-07-06 22:38:33 -04:00
// 1-list model: displayParticipants IS the display order (participants[] order
// plus temporary exiting cards). Do NOT re-sort by initiative.
const participantsToRender = displayParticipants ;
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
2026-07-08 13:19:10 -04:00
onClick = {() => setWakeLockEnabled ( v => { localStorage . setItem ( 'ttrpg.wakeLock' , String ( ! v )); return ! v ; })}
2026-06-27 11:56:11 -04:00
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 => {
2026-07-07 13:32:26 -04:00
const isGeneric = ( activeEncounterData . ruleset || '5e' ) === 'generic' ;
const status = p . status || ( isGeneric ? ( p . currentHp <= 0 ? 'down' : 'conscious' ) : ( p . currentHp === 0 ? 'dying' : 'conscious' ));
const isZeroHp = p . currentHp <= 0 ;
2026-07-06 16:39:11 -04:00
let participantBgColor = p . type === 'monster' || p . type === 'npc'
? 'bg-[#8e351c]'
2026-04-25 20:25:34 -04:00
: '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 (
2026-07-06 22:38:33 -04:00
< PlayerParticipantCard
2025-12-13 19:15:26 -05:00
key = { p . id }
2026-07-06 22:38:33 -04:00
id = { p . id }
isActive = { p . isActive !== false }
status = { status }
onExit = { handleExit }
divRef = { isCurrentTurn ? currentParticipantRef : null }
className = { `relative p-4 md:p-6 rounded-lg shadow-lg ${ participantBgColor } ` }
2025-12-13 19:15:26 -05:00
>
< div className = "flex justify-between items-center mb-2" >
< h3
2026-07-06 16:39:11 -04:00
className = { `text-2xl md:text-3xl font-bold font-cinzel ${ isCurrentTurn ? 'text-white' : ( p . type === 'character' ? 'text-amber-100' : 'text-white' ) } ` }
2025-12-13 19:15:26 -05:00
>
2026-07-06 16:39:11 -04:00
{ isZeroHp && < span className = "mr-2" > ☠️ < /span>}
2025-12-13 19:15:26 -05:00
{ p . name }
{ isCurrentTurn && (
< span className = "text-yellow-300 animate-pulse ml-2" > ( Current ) < /span>
)}
2026-07-06 16:39:11 -04:00
{ status === 'dying' && < span className = "text-red-300 text-lg ml-2" > ( Dying ) < /span>}
{ status === 'stable' && ( p . conditions || []). includes ( 'unconscious' ) && < span className = "text-emerald-300 text-lg ml-2" > ( Unconscious ) < /span>}
2026-07-07 13:32:26 -04:00
{ status === 'down' && < span className = "text-red-300 text-lg ml-2 animate-pulse" > ( Down ) < /span>}
2026-07-06 16:39:11 -04:00
{ status === 'dead' && < span className = "text-red-300 text-lg ml-2" > ( 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-07-02 11:29:02 -04:00
{ ! ( hidePlayerHp && p . type === 'character' ) && ! ( hideNpcHp && p . type !== 'character' ) && (
2026-05-16 09:30:35 -04:00
< 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
2026-07-06 16:39:11 -04:00
className = { `h-full rounded-full transition-all ${ isZeroHp ? 'bg-red-900' : ( p . currentHp <= p . maxHp / 4 ? 'bg-red-500' : ( p . currentHp <= p . maxHp / 2 ? 'bg-yellow-500' : 'bg-green-500' )) } ` }
2026-05-16 09:30:35 -04:00
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>
2026-07-04 18:20:32 -04:00
) : (
< 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"
>
{ cId }
< /span>
);
2026-04-26 10:37:25 -04:00
})}
< /div>
2025-12-13 19:15:26 -05:00
)}
2026-07-06 16:39:11 -04:00
{ ! p . isActive && ! isZeroHp && (
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
)}
2026-07-06 22:38:33 -04:00
< /PlayerParticipantCard>
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 () {
2026-07-04 22:27:32 -04:00
const { showToast } = useUIFeedback ();
2026-07-06 10:31:46 -04:00
const [ page , setPage ] = useState ( 0 );
const pageQuery = useMemo (() => [ LOG_ORDER , limit ( LOG_PAGE_SIZE ), offset ( page * LOG_PAGE_SIZE )], [ page ]);
const { data : logs , isLoading } = useFirestoreCollection ( getPath . logs (), pageQuery );
const { data : campaignsData } = useFirestoreCollection ( getPath . campaigns ());
2026-05-16 10:25:17 -04:00
const [ showClearConfirm , setShowClearConfirm ] = useState ( false );
2026-06-27 16:45:07 -04:00
const [ undoingId , setUndoingId ] = useState ( null );
2026-07-06 10:31:46 -04:00
const [ dlMenu , setDlMenu ] = useState ( false );
const [ cpMenu , setCpMenu ] = useState ( false );
// Full typed-log set for download/copy dropdown lists. Page query (LOG_QUERY,
// 100/page) only sees current page, so the encounter list was truncated.
// Lazy-fetch on first menu open; reused after. Server getCollection with no
// constraint returns all docs, so this is the full set (not page-limited).
// Ref guard (not state): StrictMode double-mount sets state then unmounts
// before fetch resolves; state-based loading flag dead-locks the remount.
const [ allLogs , setAllLogs ] = useState ( null );
const allLogsFetched = useRef ( false );
useEffect (() => {
if ( ! dlMenu && ! cpMenu ) return ;
if ( allLogs || allLogsFetched . current ) return ;
allLogsFetched . current = true ;
let cancelled = false ;
getStorage (). getCollection ( getPath . logs ())
. then ( docs => { if ( ! cancelled ) setAllLogs ( docs . filter ( l => l . type )); })
. catch ( err => { console . error ( 'Error loading all logs:' , err ); allLogsFetched . current = false ; })
. finally (() => { if ( cancelled ) allLogsFetched . current = false ; });
return () => { cancelled = true ; };
}, [ dlMenu , cpMenu , allLogs ]);
// Total log count (separate cheap server count query, not full fetch).
const [ totalCount , setTotalCount ] = useState ( null );
useEffect (() => {
let cancelled = false ;
getStorage (). countCollection ( getPath . logs ())
. then ( n => { if ( ! cancelled ) setTotalCount ( n ); })
. catch (() => { if ( ! cancelled ) setTotalCount ( null ); });
return () => { cancelled = true ; };
}, [ logs ]);
const shown = ( logs || []). length ;
const rangeStart = totalCount ? page * LOG_PAGE_SIZE + 1 : 0 ;
const rangeEnd = totalCount ? Math . min (( page + 1 ) * LOG_PAGE_SIZE , totalCount ) : 0 ;
const hasPrev = page > 0 ;
const hasNext = totalCount === null ? shown === LOG_PAGE_SIZE : rangeEnd < totalCount ;
// Parse campaign id out of encounterPath (.../campaigns/{cid}/encounters/{eid}).
const cidFromPath = ( p ) => {
if ( ! p ) return null ;
const m = String ( p ). match ( /campaigns\/([^/]+)\/encounters/ );
return m ? m [ 1 ] : null ;
};
const campaignNameById = new Map (
( campaignsData || []). map ( c => [ c . id , c . name || '(unnamed campaign)' ])
);
// Group typed logs by encounter. Each item shows campaign · encounter ·
// last-entry date · entry count. Sorted newest first by last-entry ts.
// Legacy logs (no type) excluded — kept for human scroll only.
const encounterGroups = (() => {
const src = allLogs ?? ( logs || []);
const map = new Map (); // key: encounterId || encounterPath || 'none'
for ( const l of src ) {
if ( ! l . type ) continue ;
const key = l . encounterId || l . encounterPath || 'none' ;
const cid = cidFromPath ( l . encounterPath );
const campName = cid ? ( campaignNameById . get ( cid ) || '(unknown campaign)' ) : '(unknown campaign)' ;
const g = map . get ( key ) || {
key ,
encounter : l . encounterName || '(unnamed encounter)' ,
campaign : campName ,
count : 0 ,
latest : 0 ,
};
g . count ++ ;
g . latest = Math . max ( g . latest , l . ts || l . timestamp || 0 );
map . set ( key , g );
}
return [... map . values ()]. sort (( a , b ) => b . latest - a . latest );
})();
const typedCount = encounterGroups . reduce (( n , g ) => n + g . count , 0 );
const fmtDate = ( ts ) => ts ? new Date ( ts ). toLocaleString () : '—' ;
// Filename-safe human date: 2026-07-04_15-30
const fileDate = ( ts ) => ts
? new Date ( ts ). toISOString (). slice ( 0 , 16 ). replace ( 'T' , '_' ). replace ( ':' , '-' )
: new Date (). toISOString (). slice ( 0 , 16 ). replace ( 'T' , '_' ). replace ( ':' , '-' );
const dlFileName = ( g ) => g
? `ttrpg-logs- ${ ( g . campaign + '_' + g . encounter ). replace ( /[^a-z0-9]+/gi , '_' ) } - ${ fileDate ( g . latest ) } .json`
: `ttrpg-logs-all- ${ fileDate ( Date . now ()) } .json` ;
const handleDownloadFor = async ( encKey ) => {
setDlMenu ( false );
try {
const all = await storage . getCollection ( getPath . logs ());
const filtered = encKey
? all . filter ( l => ( l . encounterId || l . encounterPath || 'none' ) === encKey )
: all ;
const json = shared . logEvent . serializeEvents ( filtered );
if ( json === '[]' ) {
showToast ( 'No typed log entries for that selection.' );
return ;
}
const blob = new Blob ([ json ], { type : 'application/json' });
const url = URL . createObjectURL ( blob );
const a = document . createElement ( 'a' );
a . href = url ;
const grp = encKey ? encounterGroups . find ( g => g . key === encKey ) : null ;
a . download = dlFileName ( grp );
document . body . appendChild ( a );
a . click ();
document . body . removeChild ( a );
URL . revokeObjectURL ( url );
} catch ( err ) {
console . error ( 'Error downloading logs:' , err );
showToast ( 'Failed to download logs.' );
}
};
const handleCopyFor = async ( encKey ) => {
setCpMenu ( false );
try {
const all = await storage . getCollection ( getPath . logs ());
const filtered = encKey
? all . filter ( l => ( l . encounterId || l . encounterPath || 'none' ) === encKey )
: all ;
const json = shared . logEvent . serializeEvents ( filtered );
if ( json === '[]' ) {
showToast ( 'No typed log entries for that selection.' );
return ;
}
await navigator . clipboard . writeText ( json );
showToast ( 'Logs copied to clipboard.' );
} catch ( err ) {
console . error ( 'Error copying logs:' , err );
showToast ( 'Failed to copy logs.' );
}
};
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 ) => {
2026-07-06 10:31:46 -04:00
if ( ! db ) return ;
2026-06-27 16:45:07 -04:00
setUndoingId ( entry . id );
try {
2026-07-06 10:31:46 -04:00
if ( storage . undo ) {
const expanded = entry . undo
? expandUndo ( entry , await storage . getDoc ( entry . encounterPath ))
: getUndo ( entry );
if ( ! expanded ) { setUndoingId ( null ); return ; }
await storage . undo ({ logPath : ` ${ getPath . logs () } / ${ entry . id } ` , undo : expanded });
}
2026-06-27 16:45:07 -04:00
} catch ( err ) {
console . error ( 'Error undoing action:' , err );
2026-07-04 22:27:32 -04:00
showToast ( "Failed to roll back. The encounter may have changed or no longer exists." ); }
2026-06-27 16:45:07 -04:00
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>
2026-07-06 10:31:46 -04:00
< div className = "relative" >
< button
onClick = {() => { setDlMenu ( v => ! v ); setCpMenu ( false ); }}
disabled = { isLoading || typedCount === 0 }
className = "px-4 py-2 rounded-md text-sm font-medium bg-stone-700 hover:bg-stone-600 text-white transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
Download ▾
< /button>
{ dlMenu && (
< div className = "absolute right-0 mt-1 w-80 bg-stone-800 border border-stone-700 rounded-md shadow-xl z-20 py-1 max-h-96 overflow-y-auto" >
< button
onClick = {() => handleDownloadFor ( null )}
className = "block w-full text-left px-4 py-2 text-sm text-stone-100 hover:bg-stone-700"
>
All encounters ({ typedCount })
< /button>
{ encounterGroups . length > 0 && (
< div className = "border-t border-stone-700 my-1" />
)}
{ encounterGroups . map ( g => (
< button
key = { g . key }
onClick = {() => handleDownloadFor ( g . key )}
className = "block w-full text-left px-3 py-2 text-sm text-stone-100 hover:bg-stone-700"
>
< div className = "font-medium truncate" > { g . encounter } < /div>
< div className = "text-xs text-stone-400 truncate" > { g . campaign } < /div>
< div className = "text-xs text-stone-500" > { fmtDate ( g . latest )} · { g . count } entries < /div>
< /button>
))}
< /div>
)}
< /div>
< div className = "relative" >
< button
onClick = {() => { setCpMenu ( v => ! v ); setDlMenu ( false ); }}
disabled = { isLoading || typedCount === 0 }
className = "px-4 py-2 rounded-md text-sm font-medium bg-stone-700 hover:bg-stone-600 text-white transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
Copy ▾
< /button>
{ cpMenu && (
< div className = "absolute right-0 mt-1 w-80 bg-stone-800 border border-stone-700 rounded-md shadow-xl z-20 py-1 max-h-96 overflow-y-auto" >
< button
onClick = {() => handleCopyFor ( null )}
className = "block w-full text-left px-4 py-2 text-sm text-stone-100 hover:bg-stone-700"
>
All encounters ({ typedCount })
< /button>
{ encounterGroups . length > 0 && (
< div className = "border-t border-stone-700 my-1" />
)}
{ encounterGroups . map ( g => (
< button
key = { g . key }
onClick = {() => handleCopyFor ( g . key )}
className = "block w-full text-left px-3 py-2 text-sm text-stone-100 hover:bg-stone-700"
>
< div className = "font-medium truncate" > { g . encounter } < /div>
< div className = "text-xs text-stone-400 truncate" > { g . campaign } < /div>
< div className = "text-xs text-stone-500" > { fmtDate ( g . latest )} · { g . count } entries < /div>
< /button>
))}
< /div>
)}
< /div>
2026-05-16 10:25:17 -04:00
< button
onClick = {() => setShowClearConfirm ( true )}
2026-07-06 10:31:46 -04:00
disabled = { isLoading || ( logs || []). length === 0 }
2026-05-16 10:25:17 -04:00
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>
) : (
<>
2026-07-06 10:31:46 -04:00
< div className = "flex items-center justify-between mb-4" >
< p className = "text-stone-500 text-sm" >
{ totalCount === null
? ` ${ shown } entries — newest first`
: `Showing ${ rangeStart } – ${ rangeEnd } of ${ totalCount } ` }
< /p>
< div className = "flex items-center gap-2" >
< button
onClick = {() => setPage ( p => Math . max ( 0 , p - 1 ))}
disabled = { ! hasPrev }
className = "px-3 py-1 rounded text-sm bg-stone-700 hover:bg-stone-600 text-white transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
← Prev
< /button>
< span className = "text-stone-500 text-sm" > Page { page + 1 } < /span>
< button
onClick = {() => setPage ( p => p + 1 )}
disabled = { ! hasNext }
className = "px-3 py-1 rounded text-sm bg-stone-700 hover:bg-stone-600 text-white transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
Next →
< /button>
< /div>
< /div>
2026-05-16 10:25:17 -04:00
< 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" >
2026-07-06 10:31:46 -04:00
{ fmtDate ( entry . ts || entry . timestamp )}
2026-05-16 10:25:17 -04:00
< /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>
2026-07-06 10:31:46 -04:00
) : getUndo ( entry ) ? (
2026-06-27 16:45:07 -04:00
< 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-07-07 16:44:29 -04:00
const isDisplay = queryParams . get ( 'playerView' ) === 'true' || window . location . pathname === '/display' ;
if ( isDisplay ) {
2025-12-13 19:15:26 -05:00
setIsPlayerViewOnlyMode ( true );
2026-07-07 16:44:29 -04:00
// swap manifest so Android home-screen installs launch /display
const link = document . querySelector ( 'link[rel="manifest"]' );
if ( link ) link . href = ` ${ process . env . PUBLIC_URL || '' } /display-manifest.json` ;
2025-12-13 19:15:26 -05:00
}
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-29 12:36:16 -04:00
setError ( "Auth not initialized." );
2025-12-13 19:15:26 -05:00
setIsLoading ( false );
setIsAuthReady ( false );
return ;
}
2026-06-29 12:36:16 -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-29 12:36:16 -04:00
if ( ! isInitialized || ! auth ) {
2025-12-13 19:15:26 -05:00
return (
< ErrorDisplay
critical
2026-06-29 12:36:16 -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-07-04 22:27:32 -04:00
< UIFeedbackProvider >
< div className = "min-h-screen bg-stone-950 text-stone-100 font-garamond" >
{ isAuthReady && < DisplayView /> }
{ ! isAuthReady && ! error && < p > Authenticating for Player Display ... < /p>}
< /div>
< /UIFeedbackProvider>
2025-12-13 19:15:26 -05:00
);
}
2026-05-16 10:25:17 -04:00
if ( isLogsMode ) {
2026-07-04 22:27:32 -04:00
return (
< UIFeedbackProvider >
{ isAuthReady ? < LogsView /> : < LoadingSpinner message = "Authenticating..." /> }
< /UIFeedbackProvider>
);
2026-05-16 10:25:17 -04:00
}
2025-05-25 21:19:22 -04:00
return (
2026-07-04 22:27:32 -04:00
< UIFeedbackProvider >
< div className = "min-h-screen bg-stone-950 text-stone-100 font-garamond" >
2026-04-25 20:25:34 -04:00
< 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>
2026-07-04 22:27:32 -04:00
< /div>
< /UIFeedbackProvider>
2025-05-25 21:19:22 -04:00
);
}
2025-05-26 08:53:26 -04:00
export default App ;