2025-05-26 21:17:42 -04:00
import React , { useState , useEffect , useRef , useMemo } from 'react' ;
2025-05-25 21:19:22 -04:00
import { initializeApp } from 'firebase/app' ;
import { getAuth , signInAnonymously , onAuthStateChanged , signInWithCustomToken } from 'firebase/auth' ;
2025-05-26 21:17:42 -04:00
import { getFirestore , doc , setDoc , getDoc , getDocs , collection , onSnapshot , updateDoc , deleteDoc , query , writeBatch } from 'firebase/firestore' ;
2025-12-13 19:15:26 -05: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 ,
StopCircle as StopCircleIcon , Users2 , Dices , ChevronUp , ChevronDown
} from 'lucide-react' ;
// Custom CSS for death animation (player view only)
const deathAnimationStyles = `
@keyframes death-dissolve {
0% {
opacity: 1;
transform: scale(1) translateY(0);
}
50% {
opacity: 0.5;
transform: scale(0.95) translateY(-5px);
filter: blur(2px);
}
100% {
opacity: 0;
transform: scale(0.8) translateY(-10px);
filter: blur(4px);
}
}
.animate-death-dissolve {
animation: death-dissolve 2s ease-in-out forwards;
}
` ;
// Inject styles
if ( typeof document !== 'undefined' ) {
const styleElement = document . createElement ( 'style' );
styleElement . innerHTML = deathAnimationStyles ;
document . head . appendChild ( styleElement );
}
// ============================================================================
// CONSTANTS
// ============================================================================
const APP_VERSION = 'v0.2.2' ;
const DEFAULT_MAX_HP = 10 ;
const DEFAULT_INIT_MOD = 0 ;
const MONSTER_DEFAULT_INIT_MOD = 2 ;
const ROLL_DISPLAY_DURATION = 5000 ;
// ============================================================================
// 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 ;
2025-12-13 19:15:26 -05:00
// Initialize Firebase
const initializeFirebase = () => {
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 ;
}
2025-05-25 21:19:22 -04:00
2025-05-25 22:21:45 -04:00
try {
app = initializeApp ( firebaseConfig );
db = getFirestore ( app );
auth = getAuth ( app );
2025-12-13 19:15:26 -05:00
return true ;
2025-05-25 22:21:45 -04:00
} catch ( error ) {
console . error ( "Error initializing Firebase:" , error );
2025-12-13 19:15:26 -05:00
return false ;
2025-05-25 22:21:45 -04:00
}
2025-12-13 19:15:26 -05:00
};
2025-05-25 21:19:22 -04:00
2025-12-13 19:15:26 -05:00
const isFirebaseInitialized = initializeFirebase ();
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 } ` ,
activeDisplay : () => ` ${ PUBLIC_DATA_PATH } /activeDisplay/status`
};
// ============================================================================
// UTILITY FUNCTIONS
// ============================================================================
2025-05-25 21:19:22 -04:00
const generateId = () => crypto . randomUUID ();
2025-05-26 22:31:43 -04:00
const rollD20 = () => Math . floor ( Math . random () * 20 ) + 1 ;
2025-05-25 21:19:22 -04:00
2025-12-13 19:15:26 -05:00
const formatInitMod = ( mod ) => {
if ( mod === undefined || mod === null ) return 'N/A' ;
return mod >= 0 ? `+ ${ mod } ` : ` ${ mod } ` ;
};
const sortParticipantsByInitiative = ( participants , originalOrder ) => {
return [... participants ]. sort (( a , b ) => {
if ( a . initiative === b . initiative ) {
const indexA = originalOrder . findIndex ( p => p . id === a . id );
const indexB = originalOrder . findIndex ( p => p . id === b . id );
return indexA - indexB ;
}
return b . initiative - a . initiative ;
});
};
// ============================================================================
// 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 (() => {
if ( ! db || ! docPath ) {
setData ( null );
setIsLoading ( false );
setError ( docPath ? "Firestore not available." : "Document path not provided." );
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
2025-05-26 08:33:39 -04:00
const docRef = doc ( db , docPath );
2025-12-13 19:15:26 -05:00
const unsubscribe = onSnapshot (
docRef ,
( docSnap ) => {
setData ( docSnap . exists () ? { id : docSnap . id , ... docSnap . data () } : null );
setIsLoading ( false );
},
( err ) => {
console . error ( `Error fetching document ${ docPath } :` , err );
setError ( err . message || "Failed to fetch document." );
setIsLoading ( false );
setData ( null );
2025-05-26 08:33:39 -04:00
}
2025-12-13 19:15:26 -05:00
);
2025-05-26 08:33:39 -04:00
return () => unsubscribe ();
}, [ 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 (() => {
if ( ! db || ! collectionPath ) {
setData ([]);
setIsLoading ( false );
setError ( collectionPath ? "Firestore not available." : "Collection path not provided." );
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
const q = query ( collection ( db , collectionPath ), ... queryConstraints );
const unsubscribe = onSnapshot (
q ,
( snapshot ) => {
const items = snapshot . docs . map ( doc => ({ id : doc . id , ... doc . data () }));
setData ( items );
setIsLoading ( false );
},
( err ) => {
console . error ( `Error fetching collection ${ collectionPath } :` , err );
setError ( err . message || "Failed to fetch collection." );
setIsLoading ( false );
setData ([]);
}
);
2025-05-26 08:33:39 -04:00
return () => unsubscribe ();
2025-12-13 19:15:26 -05:00
// We use queryString instead of queryConstraints to avoid re-renders on array reference changes
// 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" >
< div className = "bg-slate-800 p-6 rounded-lg shadow-xl w-full max-w-md" >
< 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>
2025-12-13 19:15:26 -05:00
< button onClick = { onClose } className = "text-slate-400 hover:text-slate-200" >
< XCircle size = { 24 } />
< /button>
2025-05-25 22:21:45 -04:00
< /div>
2025-12-13 19:15:26 -05:00
{ children }
2025-05-26 07:59:05 -04:00
< /div>
2025-05-25 21:19:22 -04:00
< /div>
);
}
2025-05-26 09:41:50 -04:00
function ConfirmationModal ({ isOpen , onClose , onConfirm , title , message }) {
if ( ! isOpen ) return null ;
2025-12-13 19:15:26 -05:00
2025-05-26 09:41:50 -04:00
return (
2025-12-13 19:15:26 -05:00
< div className = "fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center p-4 z-50" >
< div className = "bg-slate-800 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>
< p className = "text-slate-300 mb-6" > { message || "Are you sure you want to proceed?" } < /p>
< div className = "flex justify-end space-x-3" >
2025-12-13 19:15:26 -05:00
< button
onClick = { onClose }
className = "px-4 py-2 text-sm font-medium text-slate-300 bg-slate-600 hover:bg-slate-500 rounded-md transition-colors"
>
Cancel
< /button>
< button
onClick = { onConfirm }
className = "px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-md transition-colors"
>
Confirm
< /button>
2025-05-26 09:41:50 -04:00
< /div>
< /div>
< /div>
);
}
2025-12-13 19:15:26 -05:00
function LoadingSpinner ({ message = "Loading..." }) {
return (
< div className = "min-h-screen bg-slate-900 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 (
2025-12-13 19:15:26 -05:00
< div className = { `min-h-screen ${ critical ? 'bg-red-900' : 'bg-slate-900' } text-white flex flex-col items-center justify-center p-4` } >
< h1 className = "text-3xl font-bold mb-4" >
{ critical ? 'Configuration Error' : 'Error' }
< /h1>
< p className = "text-xl text-center max-w-2xl" > { message } < /p>
< /div>
2025-05-25 21:19:22 -04:00
);
}
2025-12-13 19:15:26 -05:00
// ============================================================================
// FORM COMPONENTS
// ============================================================================
2025-05-25 21:19:22 -04:00
function CreateCampaignForm ({ onCreate , onCancel }) {
const [ name , setName ] = useState ( '' );
2025-12-13 19:15:26 -05:00
const [ backgroundUrl , setBackgroundUrl ] = useState ( '' );
const handleSubmit = ( e ) => {
e . preventDefault ();
if ( name . trim ()) {
onCreate ( name , backgroundUrl );
}
};
2025-05-25 21:19:22 -04:00
return (
2025-05-25 23:28:36 -04:00
< form onSubmit = { handleSubmit } className = "space-y-4" >
2025-05-25 21:19:22 -04:00
< div >
2025-12-13 19:15:26 -05:00
< label htmlFor = "campaignName" className = "block text-sm font-medium text-slate-300" >
Campaign Name
< /label>
< input
type = "text"
id = "campaignName"
value = { name }
onChange = {( e ) => setName ( e . target . value )}
2026-04-25 18:37:55 -04:00
className = "mt-1 block w-full px-3 py-2 bg-slate-700 border border-slate-600 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 >
2025-12-13 19:15:26 -05:00
< label htmlFor = "backgroundUrl" className = "block text-sm font-medium text-slate-300" >
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 18:37:55 -04:00
className = "mt-1 block w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
2025-12-13 19:15:26 -05:00
/>
2025-05-25 23:28:36 -04:00
< /div>
2025-05-25 21:19:22 -04:00
< div className = "flex justify-end space-x-3" >
2025-12-13 19:15:26 -05:00
< button
type = "button"
onClick = { onCancel }
className = "px-4 py-2 text-sm font-medium text-slate-300 bg-slate-600 hover:bg-slate-500 rounded-md transition-colors"
>
Cancel
< /button>
< button
type = "submit"
className = "px-4 py-2 text-sm font-medium text-white bg-green-500 hover:bg-green-600 rounded-md transition-colors"
>
Create
< /button>
2025-05-25 21:19:22 -04:00
< /div>
< /form>
);
}
2025-12-13 19:15:26 -05:00
function CreateEncounterForm ({ onCreate , onCancel }) {
const [ name , setName ] = useState ( '' );
2025-05-28 12:23:10 -04:00
2025-12-13 19:15:26 -05:00
const handleSubmit = ( e ) => {
e . preventDefault ();
if ( name . trim ()) {
onCreate ( name );
}
2025-05-25 21:19:22 -04:00
};
2025-12-13 19:15:26 -05:00
2025-05-25 21:19:22 -04:00
return (
2025-12-13 19:15:26 -05:00
< form onSubmit = { handleSubmit } className = "space-y-4" >
< div >
< label htmlFor = "encounterName" className = "block text-sm font-medium text-slate-300" >
Encounter Name
< /label>
< input
type = "text"
id = "encounterName"
value = { name }
onChange = {( e ) => setName ( e . target . value )}
2026-04-25 18:37:55 -04:00
className = "mt-1 block w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
2025-12-13 19:15:26 -05:00
required
/>
< /div>
< div className = "flex justify-end space-x-3" >
< button
type = "button"
onClick = { onCancel }
className = "px-4 py-2 text-sm font-medium text-slate-300 bg-slate-600 hover:bg-slate-500 rounded-md transition-colors"
>
Cancel
< /button>
< button
type = "submit"
className = "px-4 py-2 text-sm font-medium text-white bg-orange-500 hover:bg-orange-600 rounded-md transition-colors"
2025-05-28 14:58:54 -04:00
>
2025-12-13 19:15:26 -05:00
Create
2025-05-28 14:58:54 -04:00
< /button>
< /div>
2025-12-13 19:15:26 -05:00
< /form>
);
}
2025-05-28 14:58:54 -04:00
2025-12-13 19:15:26 -05:00
function EditParticipantModal ({ participant , onClose , onSave }) {
const [ name , setName ] = useState ( participant . name );
const [ initiative , setInitiative ] = useState ( participant . initiative );
const [ currentHp , setCurrentHp ] = useState ( participant . currentHp );
const [ maxHp , setMaxHp ] = useState ( participant . maxHp );
const [ isNpc , setIsNpc ] = useState ( participant . type === 'monster' ? ( participant . isNpc || false ) : false );
const handleSubmit = ( e ) => {
e . preventDefault ();
onSave ({
name : name . trim (),
initiative : parseInt ( initiative , 10 ),
currentHp : parseInt ( currentHp , 10 ),
maxHp : parseInt ( maxHp , 10 ),
isNpc : participant . type === 'monster' ? isNpc : false ,
});
};
return (
< Modal onClose = { onClose } title = { `Edit ${ participant . name } ` } >
< form onSubmit = { handleSubmit } className = "space-y-4" >
< div >
< label className = "block text-sm font-medium text-slate-300" > Name < /label>
< input
type = "text"
value = { name }
onChange = {( e ) => setName ( e . target . value )}
2026-04-25 18:37:55 -04:00
className = "mt-1 block w-full px-3 py-2 bg-slate-700 border border-slate-600 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 >
< label className = "block text-sm font-medium text-slate-300" > Initiative < /label>
< input
type = "number"
value = { initiative }
onChange = {( e ) => setInitiative ( e . target . value )}
2026-04-25 18:37:55 -04:00
className = "mt-1 block w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
2025-12-13 19:15:26 -05:00
/>
< /div>
< div className = "flex gap-4" >
< div className = "flex-1" >
< label className = "block text-sm font-medium text-slate-300" > Current HP < /label>
< input
type = "number"
value = { currentHp }
onChange = {( e ) => setCurrentHp ( e . target . value )}
2026-04-25 18:37:55 -04:00
className = "mt-1 block w-full px-3 py-2 bg-slate-700 border border-slate-600 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" >
< label className = "block text-sm font-medium text-slate-300" > Max HP < /label>
< input
type = "number"
value = { maxHp }
onChange = {( e ) => setMaxHp ( e . target . value )}
2026-04-25 18:37:55 -04:00
className = "mt-1 block w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
2025-12-13 19:15:26 -05:00
/>
< /div>
< /div>
{ participant . type === 'monster' && (
< div className = "flex items-center" >
< input
type = "checkbox"
id = "editIsNpc"
checked = { isNpc }
onChange = {( e ) => setIsNpc ( e . target . checked )}
className = "h-4 w-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500"
/>
< label htmlFor = "editIsNpc" className = "ml-2 block text-sm text-slate-300" >
Is NPC ?
< /label>
< /div>
)}
< div className = "flex justify-end space-x-3 pt-2" >
< button
type = "button"
onClick = { onClose }
className = "px-4 py-2 text-sm font-medium text-slate-300 bg-slate-600 hover:bg-slate-500 rounded-md transition-colors"
>
Cancel
< /button>
< button
type = "submit"
2026-04-25 18:37:55 -04:00
className = "px-4 py-2 text-sm font-medium text-white bg-amber-700 hover:bg-amber-800 rounded-md transition-colors"
2025-12-13 19:15:26 -05:00
>
< Save size = { 18 } className = "mr-1 inline-block" /> Save
< /button>
< /div>
< /form>
< /Modal>
2025-05-25 21:19:22 -04:00
);
}
2025-12-13 19:15:26 -05:00
// ============================================================================
// CHARACTER MANAGER COMPONENT
// ============================================================================
2025-05-28 14:58:54 -04:00
2025-12-13 19:15:26 -05:00
function CharacterManager ({ campaignId , campaignCharacters }) {
const [ characterName , setCharacterName ] = useState ( '' );
const [ defaultMaxHp , setDefaultMaxHp ] = useState ( DEFAULT_MAX_HP );
const [ defaultInitMod , setDefaultInitMod ] = useState ( DEFAULT_INIT_MOD );
const [ editingCharacter , setEditingCharacter ] = useState ( null );
const [ showDeleteConfirm , setShowDeleteConfirm ] = useState ( false );
const [ itemToDelete , setItemToDelete ] = useState ( null );
const [ isOpen , setIsOpen ] = useState ( true );
const handleAddCharacter = async () => {
if ( ! db || ! characterName . trim () || ! campaignId ) return ;
const hp = parseInt ( defaultMaxHp , 10 );
const initMod = parseInt ( defaultInitMod , 10 );
if ( isNaN ( hp ) || hp <= 0 ) {
alert ( "Please enter a valid positive number for Default Max HP." );
return ;
}
if ( isNaN ( initMod )) {
alert ( "Please enter a valid number for Default Initiative Modifier." );
return ;
}
const newCharacter = {
id : generateId (),
name : characterName . trim (),
defaultMaxHp : hp ,
defaultInitMod : initMod
};
try {
await updateDoc ( doc ( db , getPath . campaign ( campaignId )), {
players : [... campaignCharacters , newCharacter ]
});
setCharacterName ( '' );
setDefaultMaxHp ( DEFAULT_MAX_HP );
setDefaultInitMod ( DEFAULT_INIT_MOD );
} catch ( err ) {
console . error ( "Error adding character:" , err );
alert ( "Failed to add character. Please try again." );
}
2025-05-25 21:19:22 -04:00
};
2025-12-13 19:15:26 -05:00
const handleUpdateCharacter = async ( characterId , newName , newDefaultMaxHp , newDefaultInitMod ) => {
if ( ! db || ! newName . trim () || ! campaignId ) return ;
const hp = parseInt ( newDefaultMaxHp , 10 );
const initMod = parseInt ( newDefaultInitMod , 10 );
if ( isNaN ( hp ) || hp <= 0 ) {
alert ( "Please enter a valid positive number for Default Max HP." );
setEditingCharacter ( null );
return ;
}
if ( isNaN ( initMod )) {
alert ( "Please enter a valid number for Default Initiative Modifier." );
setEditingCharacter ( null );
return ;
}
const updatedCharacters = campaignCharacters . map ( c =>
c . id === characterId
? { ... c , name : newName . trim (), defaultMaxHp : hp , defaultInitMod : initMod }
: c
);
try {
await updateDoc ( doc ( db , getPath . campaign ( campaignId )), { players : updatedCharacters });
setEditingCharacter ( null );
} catch ( err ) {
console . error ( "Error updating character:" , err );
alert ( "Failed to update character. Please try again." );
}
2025-05-25 21:19:22 -04:00
};
2025-12-13 19:15:26 -05:00
const requestDeleteCharacter = ( characterId , charName ) => {
setItemToDelete ({ id : characterId , name : charName });
setShowDeleteConfirm ( true );
};
const confirmDeleteCharacter = async () => {
if ( ! db || ! itemToDelete ) return ;
const updatedCharacters = campaignCharacters . filter ( c => c . id !== itemToDelete . id );
2025-05-25 21:19:22 -04:00
try {
2025-12-13 19:15:26 -05:00
await updateDoc ( doc ( db , getPath . campaign ( campaignId )), { players : updatedCharacters });
} catch ( err ) {
console . error ( "Error deleting character:" , err );
alert ( "Failed to delete character. Please try again." );
}
setShowDeleteConfirm ( false );
setItemToDelete ( null );
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
<>
2025-12-13 19:15:26 -05:00
< div className = "p-4 bg-slate-800 rounded-lg shadow" >
< div className = "flex justify-between items-center mb-3" >
2026-04-25 18:37:55 -04:00
< h3 className = "text-xl font-semibold text-amber-300 font-cinzel tracking-wide flex items-center" >
2025-12-13 19:15:26 -05:00
< Users size = { 24 } className = "mr-2" /> Campaign Characters
< /h3>
< button
onClick = {() => setIsOpen ( ! isOpen )}
className = "p-1 text-slate-400 hover:text-slate-200"
aria - label = { isOpen ? "Collapse" : "Expand" }
>
{ isOpen ? < ChevronUp size = { 20 } /> : < ChevronDown size = { 20 } /> }
< /button>
< /div>
{ isOpen && (
<>
< form onSubmit = {( e ) => { e . preventDefault (); handleAddCharacter (); }} className = "grid grid-cols-1 sm:grid-cols-3 gap-2 mb-4 items-end" >
< div className = "sm:col-span-1" >
< label htmlFor = "characterName" className = "block text-xs font-medium text-slate-400" >
Name
< /label>
< input
type = "text"
id = "characterName"
value = { characterName }
onChange = {( e ) => setCharacterName ( e . target . value )}
placeholder = "Character name"
2026-04-25 18:37:55 -04:00
className = "w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
2025-12-13 19:15:26 -05:00
/>
2025-05-25 21:19:22 -04:00
< /div>
2025-12-13 19:15:26 -05:00
< div className = "w-full sm:w-auto" >
< label htmlFor = "defaultMaxHp" className = "block text-xs font-medium text-slate-400" >
Default HP
< /label>
< input
type = "number"
id = "defaultMaxHp"
value = { defaultMaxHp }
onChange = {( e ) => setDefaultMaxHp ( e . target . value )}
2026-04-25 18:37:55 -04:00
className = "w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
2025-12-13 19:15:26 -05:00
/>
< /div>
< div className = "w-full sm:w-auto" >
< label htmlFor = "defaultInitMod" className = "block text-xs font-medium text-slate-400" >
Init Mod
< /label>
< input
type = "number"
id = "defaultInitMod"
value = { defaultInitMod }
onChange = {( e ) => setDefaultInitMod ( e . target . value )}
2026-04-25 18:37:55 -04:00
className = "w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
2025-12-13 19:15:26 -05:00
/>
< /div>
< button
type = "submit"
2026-04-25 18:37:55 -04:00
className = "sm:col-span-3 sm:w-auto sm:justify-self-end px-4 py-2 text-sm font-medium text-white bg-amber-700 hover:bg-amber-800 rounded-md transition-colors flex items-center justify-center"
2025-12-13 19:15:26 -05:00
>
< PlusCircle size = { 18 } className = "mr-1" /> Add Character
< /button>
< /form>
{ campaignCharacters . length === 0 && (
< p className = "text-sm text-slate-400" > No characters added yet . < /p>
)}
< ul className = "space-y-2" >
{ campaignCharacters . map ( character => (
< li key = { character . id } className = "flex justify-between items-center p-3 bg-slate-700 rounded-md" >
{ editingCharacter && editingCharacter . id === character . id ? (
< form
onSubmit = {( e ) => {
e . preventDefault ();
handleUpdateCharacter (
character . id ,
editingCharacter . name ,
editingCharacter . defaultMaxHp ,
editingCharacter . defaultInitMod
);
}}
className = "flex-grow flex flex-wrap gap-2 items-center"
>
< input
type = "text"
value = { editingCharacter . name }
onChange = {( e ) => setEditingCharacter ({ ... editingCharacter , name : e . target . value })}
className = "flex-grow min-w-[100px] px-2 py-1 bg-slate-600 border border-slate-500 rounded-md text-white"
/>
< input
type = "number"
value = { editingCharacter . defaultMaxHp }
onChange = {( e ) => setEditingCharacter ({ ... editingCharacter , defaultMaxHp : e . target . value })}
className = "w-20 px-2 py-1 bg-slate-600 border border-slate-500 rounded-md text-white"
title = "Default Max HP"
/>
< input
type = "number"
value = { editingCharacter . defaultInitMod }
onChange = {( e ) => setEditingCharacter ({ ... editingCharacter , defaultInitMod : e . target . value })}
className = "w-20 px-2 py-1 bg-slate-600 border border-slate-500 rounded-md text-white"
title = "Default Init Mod"
/>
< button type = "submit" className = "p-1 text-green-400 hover:text-green-300" >
< Save size = { 18 } />
< /button>
< button
type = "button"
onClick = {() => setEditingCharacter ( null )}
className = "p-1 text-slate-400 hover:text-slate-200"
>
< XCircle size = { 18 } />
< /button>
< /form>
) : (
<>
< span className = "text-slate-100" >
{ character . name }{ ' ' }
< span className = "text-xs text-slate-400" >
( HP : { character . defaultMaxHp || 'N/A' }, Init Mod : { formatInitMod ( character . defaultInitMod )})
< /span>
< /span>
< div className = "flex space-x-2" >
< button
onClick = {() => setEditingCharacter ({
id : character . id ,
name : character . name ,
defaultMaxHp : character . defaultMaxHp || DEFAULT_MAX_HP ,
defaultInitMod : character . defaultInitMod || DEFAULT_INIT_MOD
})}
className = "p-1 rounded transition-colors text-yellow-400 hover:text-yellow-300 bg-slate-600 hover:bg-slate-500"
aria - label = "Edit character"
>
< Edit3 size = { 18 } />
< /button>
< button
onClick = {() => requestDeleteCharacter ( character . id , character . name )}
className = "p-1 rounded transition-colors text-red-400 hover:text-red-300 bg-slate-600 hover:bg-slate-500"
aria - label = "Delete character"
>
< Trash2 size = { 18 } />
< /button>
< /div>
< />
)}
< /li>
))}
< /ul>
< />
)}
2025-05-25 21:19:22 -04:00
< /div>
2025-12-13 19:15:26 -05:00
< ConfirmationModal
isOpen = { showDeleteConfirm }
onClose = {() => setShowDeleteConfirm ( false )}
onConfirm = { confirmDeleteCharacter }
title = "Delete Character?"
message = { `Are you sure you want to remove " ${ itemToDelete ? . name } " from this campaign?` }
/>
2025-05-26 09:41:50 -04:00
< />
2025-05-25 21:19:22 -04:00
);
}
2025-12-13 19:15:26 -05:00
// ============================================================================
// PARTICIPANT MANAGER COMPONENT
// ============================================================================
2025-05-25 21:19:22 -04:00
2025-12-13 19:15:26 -05:00
function ParticipantManager ({ encounter , encounterPath , campaignCharacters }) {
2025-05-25 21:19:22 -04:00
const [ participantName , setParticipantName ] = useState ( '' );
2025-12-13 19:15:26 -05:00
const [ participantType , setParticipantType ] = useState ( 'monster' );
const [ selectedCharacterId , setSelectedCharacterId ] = useState ( '' );
const [ monsterInitMod , setMonsterInitMod ] = useState ( MONSTER_DEFAULT_INIT_MOD );
const [ maxHp , setMaxHp ] = useState ( DEFAULT_MAX_HP );
const [ isNpc , setIsNpc ] = useState ( false );
const [ editingParticipant , setEditingParticipant ] = useState ( null );
const [ hpChangeValues , setHpChangeValues ] = useState ({});
2025-05-25 21:19:22 -04:00
const [ draggedItemId , setDraggedItemId ] = useState ( null );
2025-12-13 19:15:26 -05:00
const [ showDeleteConfirm , setShowDeleteConfirm ] = useState ( false );
const [ itemToDelete , setItemToDelete ] = useState ( null );
const [ lastRollDetails , setLastRollDetails ] = useState ( null );
2025-05-25 21:19:22 -04:00
const participants = encounter . participants || [];
2025-05-26 21:48:28 -04:00
useEffect (() => {
if ( participantType === 'character' && selectedCharacterId ) {
const selectedChar = campaignCharacters . find ( c => c . id === selectedCharacterId );
2025-12-13 19:15:26 -05:00
if ( selectedChar && selectedChar . defaultMaxHp ) {
setMaxHp ( selectedChar . defaultMaxHp );
} else {
setMaxHp ( DEFAULT_MAX_HP );
}
setIsNpc ( false );
} else if ( participantType === 'monster' ) {
setMaxHp ( DEFAULT_MAX_HP );
setMonsterInitMod ( MONSTER_DEFAULT_INIT_MOD );
2025-05-27 10:51:29 -04:00
}
2025-05-26 21:48:28 -04:00
}, [ selectedCharacterId , participantType , campaignCharacters ]);
2025-05-25 21:19:22 -04:00
const handleAddParticipant = async () => {
2025-12-13 19:15:26 -05:00
if ( ! db ) return ;
if ( participantType === 'monster' && ! participantName . trim ()) return ;
if ( participantType === 'character' && ! selectedCharacterId ) return ;
2025-05-25 21:19:22 -04:00
let nameToAdd = participantName . trim ();
2025-05-26 22:31:43 -04:00
const initiativeRoll = rollD20 ();
let modifier = 0 ;
2025-12-13 19:15:26 -05:00
let currentMaxHp = parseInt ( maxHp , 10 ) || DEFAULT_MAX_HP ;
2025-05-27 10:51:29 -04:00
let participantIsNpc = false ;
2025-05-26 21:48:28 -04:00
2025-05-25 21:19:22 -04:00
if ( participantType === 'character' ) {
const character = campaignCharacters . find ( c => c . id === selectedCharacterId );
2025-12-13 19:15:26 -05:00
if ( ! character ) {
console . error ( "Selected character not found" );
return ;
}
if ( participants . some ( p => p . type === 'character' && p . originalCharacterId === selectedCharacterId )) {
alert ( ` ${ character . name } is already in this encounter.` );
return ;
}
2025-05-25 21:19:22 -04:00
nameToAdd = character . name ;
2025-12-13 19:15:26 -05:00
currentMaxHp = character . defaultMaxHp || currentMaxHp ;
2025-05-26 22:31:43 -04:00
modifier = character . defaultInitMod || 0 ;
2025-12-13 19:15:26 -05:00
} else {
modifier = parseInt ( monsterInitMod , 10 ) || 0 ;
participantIsNpc = isNpc ;
2025-05-25 21:19:22 -04:00
}
2025-12-13 19:15:26 -05:00
const finalInitiative = initiativeRoll + modifier ;
const newParticipant = {
id : generateId (),
name : nameToAdd ,
type : participantType ,
originalCharacterId : participantType === 'character' ? selectedCharacterId : null ,
initiative : finalInitiative ,
maxHp : currentMaxHp ,
currentHp : currentMaxHp ,
isNpc : participantType === 'monster' ? participantIsNpc : false ,
conditions : [],
isActive : true ,
deathSaves : 0 , // Track failed death saves (0-3)
isDying : false , // For death animation on player display
2025-05-27 10:51:29 -04:00
};
2025-12-13 19:15:26 -05:00
try {
await updateDoc ( doc ( db , encounterPath ), {
participants : [... participants , newParticipant ]
});
setLastRollDetails ({
name : nameToAdd ,
roll : initiativeRoll ,
mod : modifier ,
total : finalInitiative ,
type : participantIsNpc ? 'NPC' : participantType
});
setTimeout (() => setLastRollDetails ( null ), ROLL_DISPLAY_DURATION );
// Reset form
setParticipantName ( '' );
setMaxHp ( DEFAULT_MAX_HP );
setSelectedCharacterId ( '' );
setMonsterInitMod ( MONSTER_DEFAULT_INIT_MOD );
setIsNpc ( false );
} catch ( err ) {
console . error ( "Error adding participant:" , err );
alert ( "Failed to add participant. Please try again." );
}
2025-05-25 21:19:22 -04:00
};
2025-05-26 21:48:28 -04:00
const handleAddAllCampaignCharacters = async () => {
if ( ! db || ! campaignCharacters || campaignCharacters . length === 0 ) return ;
2025-12-13 19:15:26 -05:00
const existingCharacterIds = participants
. filter ( p => p . type === 'character' && p . originalCharacterId )
. map ( p => p . originalCharacterId );
2025-05-26 21:48:28 -04:00
const newParticipants = campaignCharacters
2025-12-13 19:15:26 -05:00
. filter ( char => ! existingCharacterIds . includes ( char . id ))
. map ( char => {
const initiativeRoll = rollD20 ();
const modifier = char . defaultInitMod || 0 ;
const finalInitiative = initiativeRoll + modifier ;
return {
id : generateId (),
name : char . name ,
type : 'character' ,
originalCharacterId : char . id ,
initiative : finalInitiative ,
maxHp : char . defaultMaxHp || DEFAULT_MAX_HP ,
currentHp : char . defaultMaxHp || DEFAULT_MAX_HP ,
conditions : [],
isActive : true ,
isNpc : false ,
deathSaves : 0 ,
isDying : false ,
};
});
if ( newParticipants . length === 0 ) {
alert ( "All campaign characters are already in this encounter." );
return ;
}
try {
await updateDoc ( doc ( db , encounterPath ), {
participants : [... participants , ... newParticipants ]
});
console . log ( `Added ${ newParticipants . length } characters to the encounter.` );
} catch ( err ) {
console . error ( "Error adding all campaign characters:" , err );
alert ( "Failed to add all characters. Please try again." );
}
2025-05-26 21:48:28 -04:00
};
2025-05-25 21:19:22 -04:00
const handleUpdateParticipant = async ( updatedData ) => {
2025-05-25 22:21:45 -04:00
if ( ! db || ! editingParticipant ) return ;
2025-12-13 19:15:26 -05:00
const updatedParticipants = participants . map ( p =>
p . id === editingParticipant . id ? { ... p , ... updatedData } : p
);
try {
await updateDoc ( doc ( db , encounterPath ), { participants : updatedParticipants });
setEditingParticipant ( null );
} catch ( err ) {
console . error ( "Error updating participant:" , err );
alert ( "Failed to update participant. Please try again." );
}
};
const requestDeleteParticipant = ( participantId , participantName ) => {
setItemToDelete ({ id : participantId , name : participantName });
setShowDeleteConfirm ( true );
2025-05-25 21:19:22 -04:00
};
2025-12-13 19:15:26 -05:00
2025-05-26 09:41:50 -04:00
const confirmDeleteParticipant = async () => {
2025-12-13 19:15:26 -05:00
if ( ! db || ! itemToDelete ) return ;
const updatedParticipants = participants . filter ( p => p . id !== itemToDelete . id );
try {
await updateDoc ( doc ( db , encounterPath ), { participants : updatedParticipants });
} catch ( err ) {
console . error ( "Error deleting participant:" , err );
alert ( "Failed to delete participant. Please try again." );
}
setShowDeleteConfirm ( false );
setItemToDelete ( null );
2025-05-25 21:19:22 -04:00
};
2025-12-13 19:15:26 -05:00
2025-05-25 21:19:22 -04:00
const toggleParticipantActive = async ( participantId ) => {
2025-05-25 22:21:45 -04:00
if ( ! db ) return ;
2025-12-13 19:15:26 -05:00
const updatedParticipants = participants . map ( p =>
p . id === participantId ? { ... p , isActive : ! p . isActive } : p
);
try {
await updateDoc ( doc ( db , encounterPath ), { participants : updatedParticipants });
} catch ( err ) {
console . error ( "Error toggling active state:" , err );
}
2025-05-25 21:19:22 -04:00
};
2025-12-13 19:15:26 -05:00
2025-05-25 21:19:22 -04:00
const applyHpChange = async ( participantId , changeType ) => {
2025-05-25 22:21:45 -04:00
if ( ! db ) return ;
2025-12-13 19:15:26 -05:00
2025-05-25 21:19:22 -04:00
const amountStr = hpChangeValues [ participantId ];
2025-12-13 19:15:26 -05:00
if ( ! amountStr || amountStr . trim () === '' ) return ;
2025-05-25 21:19:22 -04:00
const amount = parseInt ( amountStr , 10 );
2025-12-13 19:15:26 -05:00
if ( isNaN ( amount ) || amount === 0 ) {
setHpChangeValues ( prev => ({ ... prev , [ participantId ] : '' }));
return ;
}
const participant = participants . find ( p => p . id === participantId );
if ( ! participant ) return ;
let newHp = participant . currentHp ;
if ( changeType === 'damage' ) {
newHp = Math . max ( 0 , participant . currentHp - amount );
} else if ( changeType === 'heal' ) {
newHp = Math . min ( participant . maxHp , participant . currentHp + amount );
}
// Determine if participant died or was resurrected
const wasDead = participant . currentHp === 0 ;
const isDead = newHp === 0 ;
const wasResurrected = wasDead && newHp > 0 ;
const updatedParticipants = participants . map ( p => {
if ( p . id === participantId ) {
const updates = { ... p , currentHp : newHp };
// Handle death - deactivate and start death saves
if ( isDead && ! wasDead ) {
updates . isActive = false ;
updates . deathSaves = p . deathSaves || 0 ;
updates . isDying = false ;
}
// Handle resurrection - reactivate and reset death saves
if ( wasResurrected ) {
updates . isActive = true ;
updates . deathSaves = 0 ;
updates . isDying = false ;
}
return updates ;
}
return p ;
});
try {
await updateDoc ( doc ( db , encounterPath ), { participants : updatedParticipants });
setHpChangeValues ( prev => ({ ... prev , [ participantId ] : '' }));
} catch ( err ) {
console . error ( "Error applying HP change:" , err );
}
};
const handleDeathSaveChange = async ( participantId , saveNumber ) => {
if ( ! db ) return ;
const participant = participants . find ( p => p . id === participantId );
if ( ! participant ) return ;
const currentSaves = participant . deathSaves || 0 ;
const newSaves = currentSaves === saveNumber ? saveNumber - 1 : saveNumber ;
// If clicking the third death save, mark as dying (for player view animation)
if ( newSaves === 3 ) {
const updatedParticipants = participants . map ( p =>
p . id === participantId ? { ... p , deathSaves : newSaves , isDying : true } : p
);
try {
await updateDoc ( doc ( db , encounterPath ), { participants : updatedParticipants });
// Wait for animation to complete on player display (2 seconds) then remove participant
setTimeout ( async () => {
const finalParticipants = participants . filter ( p => p . id !== participantId );
try {
await updateDoc ( doc ( db , encounterPath ), { participants : finalParticipants });
} catch ( err ) {
console . error ( "Error removing dead participant:" , err );
}
}, 2000 );
} catch ( err ) {
console . error ( "Error marking participant as dying:" , err );
}
} else {
// Normal death save update
const updatedParticipants = participants . map ( p =>
p . id === participantId ? { ... p , deathSaves : newSaves } : p
);
try {
await updateDoc ( doc ( db , encounterPath ), { participants : updatedParticipants });
} catch ( err ) {
console . error ( "Error updating death saves:" , err );
}
}
};
const handleDragStart = ( e , id ) => {
setDraggedItemId ( id );
e . dataTransfer . effectAllowed = 'move' ;
2025-05-25 21:19:22 -04:00
};
2025-12-13 19:15:26 -05:00
const handleDragOver = ( e ) => {
e . preventDefault ();
e . dataTransfer . dropEffect = 'move' ;
};
2025-05-25 21:19:22 -04:00
const handleDrop = async ( e , targetId ) => {
2025-12-13 19:15:26 -05:00
e . preventDefault ();
if ( ! db || draggedItemId === null || draggedItemId === targetId ) {
setDraggedItemId ( null );
return ;
}
const currentParticipants = [... participants ];
const draggedIndex = currentParticipants . findIndex ( p => p . id === draggedItemId );
const targetIndex = currentParticipants . findIndex ( p => p . id === targetId );
if ( draggedIndex === - 1 || targetIndex === - 1 ) {
console . error ( "Dragged or target item not found." );
setDraggedItemId ( null );
return ;
}
const draggedItem = currentParticipants [ draggedIndex ];
const targetItem = currentParticipants [ targetIndex ];
if ( draggedItem . initiative !== targetItem . initiative ) {
console . log ( "Drag-drop only allowed for participants with same initiative." );
setDraggedItemId ( null );
return ;
}
const [ removedItem ] = currentParticipants . splice ( draggedIndex , 1 );
currentParticipants . splice ( targetIndex , 0 , removedItem );
try {
await updateDoc ( doc ( db , encounterPath ), { participants : currentParticipants });
} catch ( err ) {
console . error ( "Error reordering participants:" , err );
}
setDraggedItemId ( null );
2025-05-25 21:19:22 -04:00
};
2025-12-13 19:15:26 -05:00
const sortedParticipants = sortParticipantsByInitiative ( participants , participants );
const initiativeGroups = participants . reduce (( acc , p ) => {
acc [ p . initiative ] = ( acc [ p . initiative ] || 0 ) + 1 ;
return acc ;
}, {});
const tiedInitiatives = Object . keys ( initiativeGroups )
. filter ( init => initiativeGroups [ init ] > 1 )
. map ( Number );
2025-05-25 21:19:22 -04:00
return (
2025-05-26 09:41:50 -04:00
<>
2025-12-13 19:15:26 -05:00
< div className = "p-3 bg-slate-800 rounded-md mt-4" >
< 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 }
className = "px-3 py-1.5 text-xs font-medium text-white bg-indigo-600 hover:bg-indigo-700 rounded-md transition-colors flex items-center"
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" >
Pause combat first to add participants . Participants added during active combat won 't be included in the turn order until you pause and resume.
</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();
}}
className="grid grid-cols-1 md:grid-cols-6 gap-x-4 gap-y-2 mb-4 p-3 bg-slate-700 rounded items-end"
>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-slate-300">Type</label>
<select
value={participantType}
onChange={(e) => {
setParticipantType(e.target.value);
setSelectedCharacterId('');
setIsNpc(false);
}}
className="mt-1 w-full px-3 py-2 bg-slate-600 border-slate-500 rounded text-white"
>
<option value="monster">Monster</option>
<option value="character">Character</option>
</select>
</div>
{participantType === ' monster ' ? (
<>
<div className="md:col-span-4">
<label htmlFor="monsterName" className="block text-sm font-medium text-slate-300">
Monster Name
</label>
<input
type="text"
id="monsterName"
value={participantName}
onChange={(e) => setParticipantName(e.target.value)}
placeholder="e.g., Dire Wolf"
2026-04-25 18:37:55 -04:00
className="mt-1 w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
2025-12-13 19:15:26 -05:00
/>
</div>
<div className="md:col-span-2">
<label htmlFor="monsterInitMod" className="block text-sm font-medium text-slate-300">
Init Mod
</label>
<input
type="number"
id="monsterInitMod"
value={monsterInitMod}
onChange={(e) => setMonsterInitMod(e.target.value)}
2026-04-25 18:37:55 -04:00
className="mt-1 w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
2025-12-13 19:15:26 -05:00
/>
</div>
<div className="md:col-span-2">
<label htmlFor="monsterMaxHp" className="block text-sm font-medium text-slate-300">
Max HP
</label>
<input
type="number"
id="monsterMaxHp"
value={maxHp}
onChange={(e) => setMaxHp(e.target.value)}
2026-04-25 18:37:55 -04:00
className="mt-1 w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
2025-12-13 19:15:26 -05:00
/>
</div>
<div className="md:col-span-2 flex items-center pt-5">
<input
type="checkbox"
id="isNpc"
checked={isNpc}
onChange={(e) => setIsNpc(e.target.checked)}
className="h-4 w-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500"
/>
<label htmlFor="isNpc" className="ml-2 block text-sm text-slate-300">
Is NPC?
</label>
</div>
</>
) : (
<>
<div className="md:col-span-4">
2025-05-27 11:02:03 -04:00
<label className="block text-sm font-medium text-slate-300">Select Character</label>
2025-12-13 19:15:26 -05:00
<select
value={selectedCharacterId}
onChange={(e) => setSelectedCharacterId(e.target.value)}
className="mt-1 w-full px-3 py-2 bg-slate-600 border-slate-500 rounded text-white"
>
<option value="">-- Select from Campaign --</option>
{campaignCharacters.map(c => (
<option key={c.id} value={c.id}>
{c.name} (HP: {c.defaultMaxHp || ' N / A '}, Mod: {formatInitMod(c.defaultInitMod)})
</option>
))}
2025-05-27 11:02:03 -04:00
</select>
2025-05-25 21:19:22 -04:00
</div>
2025-12-13 19:15:26 -05:00
<div className="md:col-span-2">
<label className="block text-sm font-medium text-slate-300">Max HP (Encounter)</label>
<input
type="number"
value={maxHp}
onChange={(e) => setMaxHp(e.target.value)}
2026-04-25 18:37:55 -04:00
className="mt-1 w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
2025-12-13 19:15:26 -05:00
/>
2025-05-25 21:19:22 -04:00
</div>
2025-12-13 19:15:26 -05:00
</>
)}
2025-05-25 21:19:22 -04:00
2025-12-13 19:15:26 -05:00
<div className="md:col-span-6 flex justify-end mt-2">
<button
type="submit"
disabled={encounter.isStarted && !encounter.isPaused}
className={`px-4 py-2 text-sm font-medium text-white rounded-md transition-colors flex items-center ${encounter.isStarted && !encounter.isPaused ? ' bg - slate - 500 cursor - not - allowed opacity - 50 ' : ' bg - green - 500 hover : bg - green - 600 '}`}
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">
{lastRollDetails.name} ({lastRollDetails.type === ' character ' ? ' Character ' : lastRollDetails.type})
: Rolled D20 ({lastRollDetails.roll}) {formatInitMod(lastRollDetails.mod)} = {lastRollDetails.total} Initiative
</p>
2025-05-27 10:51:29 -04:00
)}
2025-12-13 19:15:26 -05:00
{participants.length === 0 && <p className="text-sm text-slate-400">No participants added yet.</p>}
<ul className="space-y-2">
{sortedParticipants.map((p) => {
const isCurrentTurn = encounter.isStarted && p.id === encounter.currentTurnParticipantId;
const isDraggable = (!encounter.isStarted || encounter.isPaused) && tiedInitiatives.includes(Number(p.initiative));
const participantDisplayType = p.type === ' monster ' ? (p.isNpc ? ' NPC ' : ' Monster ') : ' Character ';
2026-04-25 18:37:55 -04:00
let bgColor = p.type === ' character ' ? ' bg - blue - 950 ' : (p.isNpc ? ' bg - slate - 600 ' : ' bg - [ # 8e351 c ] ');
2025-12-13 19:15:26 -05:00
if (isCurrentTurn && !encounter.isPaused) bgColor = ' bg - green - 600 ';
const isDead = p.currentHp === 0;
return (
<li
key={p.id}
draggable={isDraggable}
onDragStart={isDraggable ? (e) => handleDragStart(e, p.id) : undefined}
onDragOver={isDraggable ? handleDragOver : undefined}
onDrop={isDraggable ? (e) => handleDrop(e, p.id) : undefined}
onDragEnd={() => setDraggedItemId(null)}
className={`p-3 rounded-md flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 transition-all duration-300 ${bgColor} ${isCurrentTurn && !encounter.isPaused ? ' ring - 2 ring - green - 300 shadow - lg ' : ''} ${!p.isActive && !isDead ? ' opacity - 50 ' : ''} ${isDraggable ? ' cursor - grab ' : ''} ${draggedItemId === p.id ? ' opacity - 50 ring - 2 ring - yellow - 400 ' : ''}`}
>
<div className="flex-1 flex items-start sm:items-center">
{isDraggable && (
<ChevronsUpDown
size={18}
className="mr-2 text-slate-400 flex-shrink-0"
title="Drag to reorder in tie"
/>
)}
<div className="flex-1">
<p className="font-semibold text-lg text-white">
{isDead && <span className="mr-2">☠️</span>}
{p.name} <span className="text-xs">({participantDisplayType})</span>
{isCurrentTurn && !encounter.isPaused && (
<span className="ml-2 px-2 py-0.5 bg-yellow-400 text-black text-xs font-bold rounded-full inline-flex items-center">
<Zap size={12} className="mr-1" /> CURRENT
</span>
)}
{isDead && <span className="ml-2 text-xs text-red-300 font-semibold">(Unconscious)</span>}
</p>
<p className={`text-sm ${isCurrentTurn && !encounter.isPaused ? ' text - green - 100 ' : ' text - slate - 200 '}`}>
Init: {p.initiative} | HP: {p.currentHp}/{p.maxHp}
</p>
{/* Death Saves */}
{isDead && encounter.isStarted && (
<div className="mt-2 flex items-center space-x-2">
<span className="text-xs text-red-300 font-medium">Death Saves:</span>
{[1, 2, 3].map(saveNum => (
<button
key={saveNum}
onClick={() => handleDeathSaveChange(p.id, saveNum)}
className={`w-6 h-6 rounded border-2 transition-all ${(p.deathSaves || 0) >= saveNum ? ' bg - red - 600 border - red - 500 ' : ' bg - slate - 700 border - slate - 500 hover : border - red - 400 '} ${saveNum === 3 && (p.deathSaves || 0) === 3 ? ' animate - pulse ' : ''}`}
title={`Death save ${saveNum}`}
>
{(p.deathSaves || 0) >= saveNum && <span className="text-white text-sm">✕</span>}
</button>
))}
</div>
)}
</div>
</div>
<div className="flex flex-wrap items-center space-x-2 mt-2 sm:mt-0">
{encounter.isStarted && (
<div className="flex items-center space-x-1 bg-slate-700 p-1 rounded-md">
<input
type="number"
placeholder="HP"
value={hpChangeValues[p.id] || ''}
onChange={(e) => setHpChangeValues(prev => ({ ...prev, [p.id]: e.target.value }))}
2026-04-25 18:37:55 -04:00
className="w-16 p-1 text-sm bg-slate-600 border border-slate-500 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 ')}
className="p-1 bg-green-500 hover:bg-green-600 text-white rounded-md text-xs"
title={isDead ? "Heal / Revive" : "Heal"}
>
<HeartPulse size={16} />
</button>
</div>
)}
{!isDead && (
<button
onClick={() => toggleParticipantActive(p.id)}
className={`p-1 rounded transition-colors ${p.isActive ? ' text - yellow - 400 hover : text - yellow - 300 ' : ' text - slate - 400 hover : text - slate - 300 '} bg-slate-600 hover:bg-slate-500`}
title={p.isActive ? "Mark Inactive" : "Mark Active"}
>
{p.isActive ? <UserCheck size={18} /> : <UserX size={18} />}
</button>
)}
<button
onClick={() => setEditingParticipant(p)}
className="p-1 rounded transition-colors text-yellow-400 hover:text-yellow-300 bg-slate-600 hover:bg-slate-500"
title="Edit"
>
<Edit3 size={18} />
</button>
<button
onClick={() => requestDeleteParticipant(p.id, p.name)}
className="p-1 rounded transition-colors text-red-400 hover:text-red-300 bg-slate-600 hover:bg-slate-500"
title="Remove"
>
<Trash2 size={18} />
</button>
</div>
</li>
);
})}
</ul>
{editingParticipant && (
<EditParticipantModal
participant={editingParticipant}
onClose={() => setEditingParticipant(null)}
onSave={handleUpdateParticipant}
/>
)}
</div>
<ConfirmationModal
isOpen={showDeleteConfirm}
onClose={() => setShowDeleteConfirm(false)}
onConfirm={confirmDeleteParticipant}
title="Delete Participant?"
message={`Are you sure you want to remove "${itemToDelete?.name}" from this encounter?`}
/>
</>
2025-05-25 21:19:22 -04:00
);
}
2025-12-13 19:15:26 -05:00
// ============================================================================
// INITIATIVE CONTROLS COMPONENT
// ============================================================================
function InitiativeControls({ campaignId, encounter, encounterPath }) {
const [showEndConfirm, setShowEndConfirm] = useState(false);
2025-05-25 21:19:22 -04:00
const handleStartEncounter = async () => {
2025-12-13 19:15:26 -05:00
if (!db || !encounter.participants || encounter.participants.length === 0) {
alert("Add participants first.");
return;
}
const activeParticipants = encounter.participants.filter(p => p.isActive);
if (activeParticipants.length === 0) {
alert("No active participants.");
return;
}
const sortedParticipants = sortParticipantsByInitiative(activeParticipants, encounter.participants);
2025-05-25 21:19:22 -04:00
try {
2025-12-13 19:15:26 -05:00
await updateDoc(doc(db, encounterPath), {
isStarted: true,
isPaused: false,
round: 1,
currentTurnParticipantId: sortedParticipants[0].id,
turnOrderIds: sortedParticipants.map(p => p.id)
});
await setDoc(doc(db, getPath.activeDisplay()), {
activeCampaignId: campaignId,
activeEncounterId: encounter.id
}, { merge: true });
2025-05-25 21:19:22 -04:00
console.log("Encounter started and set as active display.");
2025-12-13 19:15:26 -05:00
} catch (err) {
console.error("Error starting encounter:", err);
alert("Failed to start encounter. Please try again.");
}
2025-05-25 21:19:22 -04:00
};
2025-12-13 19:15:26 -05:00
2025-05-26 21:34:37 -04:00
const handleTogglePause = async () => {
if (!db || !encounter || !encounter.isStarted) return;
2025-12-13 19:15:26 -05:00
2025-05-26 21:34:37 -04:00
const newPausedState = !encounter.isPaused;
let newTurnOrderIds = encounter.turnOrderIds;
2025-12-13 19:15:26 -05:00
if (!newPausedState && encounter.isPaused) {
const activeParticipants = encounter.participants.filter(p => p.isActive);
const sortedParticipants = sortParticipantsByInitiative(activeParticipants, encounter.participants);
newTurnOrderIds = sortedParticipants.map(p => p.id);
}
try {
await updateDoc(doc(db, encounterPath), {
isPaused: newPausedState,
turnOrderIds: newTurnOrderIds
});
} catch (err) {
console.error("Error toggling pause state:", err);
2025-05-26 21:34:37 -04:00
}
};
2025-12-13 19:15:26 -05:00
2025-05-25 21:19:22 -04:00
const handleNextTurn = async () => {
2025-12-13 19:15:26 -05:00
if (!db || !encounter.isStarted || encounter.isPaused) return;
if (!encounter.currentTurnParticipantId || !encounter.turnOrderIds || encounter.turnOrderIds.length === 0) return;
const activePsInOrder = encounter.turnOrderIds
.map(id => encounter.participants.find(p => p.id === id && p.isActive))
.filter(Boolean);
if (activePsInOrder.length === 0) {
alert("No active participants left.");
await updateDoc(doc(db, encounterPath), {
isStarted: false,
isPaused: false,
currentTurnParticipantId: null,
round: encounter.round
});
return;
}
2025-05-25 21:19:22 -04:00
const currentIndex = activePsInOrder.findIndex(p => p.id === encounter.currentTurnParticipantId);
let nextIndex = (currentIndex + 1) % activePsInOrder.length;
let nextRound = encounter.round;
2025-12-13 19:15:26 -05:00
if (nextIndex === 0 && currentIndex !== -1) {
nextRound += 1;
}
try {
await updateDoc(doc(db, encounterPath), {
currentTurnParticipantId: activePsInOrder[nextIndex].id,
round: nextRound
});
} catch (err) {
console.error("Error advancing turn:", err);
}
2025-05-25 21:19:22 -04:00
};
2025-12-13 19:15:26 -05:00
2025-05-26 09:41:50 -04:00
const confirmEndEncounter = async () => {
2025-05-25 22:21:45 -04:00
if (!db) return;
2025-12-13 19:15:26 -05:00
2025-05-25 21:19:22 -04:00
try {
2025-12-13 19:15:26 -05:00
await updateDoc(doc(db, encounterPath), {
isStarted: false,
isPaused: false,
currentTurnParticipantId: null,
round: 0,
turnOrderIds: []
});
await setDoc(doc(db, getPath.activeDisplay()), {
activeCampaignId: null,
activeEncounterId: null
}, { merge: true });
2025-05-26 07:59:05 -04:00
console.log("Encounter ended and deactivated from Player Display.");
2025-12-13 19:15:26 -05:00
} catch (err) {
console.error("Error ending encounter:", err);
}
setShowEndConfirm(false);
2025-05-25 21:19:22 -04:00
};
2025-12-13 19:15:26 -05:00
2025-05-25 21:19:22 -04:00
if (!encounter || !encounter.participants) return null;
2025-12-13 19:15:26 -05:00
2025-05-25 21:19:22 -04:00
return (
2025-05-26 09:41:50 -04:00
<>
2025-12-13 19:15:26 -05:00
<div className="lg:sticky lg:top-4 p-4 bg-slate-800 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}
className="w-full px-4 py-3 text-sm font-medium text-white bg-green-600 hover:bg-green-700 rounded-md transition-colors flex items-center justify-center"
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}
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 - green - 500 hover : bg - green - 600 ' : ' bg - yellow - 500 hover : bg - yellow - 600 '}`}
>
{encounter.isPaused ? <PlayIcon size={18} className="mr-2" /> : <PauseIcon size={18} className="mr-2" />}
{encounter.isPaused ? ' Resume ' : ' Pause '}
</button>
<button
onClick={handleNextTurn}
className="w-full px-4 py-3 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition-colors flex items-center justify-center"
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 */}
<div className="mt-2 pt-3 border-t border-slate-600">
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>
</div>
<ConfirmationModal
isOpen={showEndConfirm}
onClose={() => setShowEndConfirm(false)}
onConfirm={confirmEndEncounter}
title="End Encounter?"
message="Are you sure you want to end this encounter? Initiative order will be reset and it will be removed from the Player Display."
/>
</>
);
}
// ============================================================================
// ENCOUNTER MANAGER COMPONENT
// ============================================================================
function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharacters }) {
const { data: encountersData, isLoading: isLoadingEncounters } = useFirestoreCollection(
campaignId ? getPath.encounters(campaignId) : null
);
const { data: activeDisplayInfo } = useFirestoreDocument(getPath.activeDisplay());
const [encounters, setEncounters] = useState([]);
const [selectedEncounterId, setSelectedEncounterId] = useState(null);
const [showCreateModal, setShowCreateModal] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [itemToDelete, setItemToDelete] = useState(null);
const selectedEncounterIdRef = useRef(selectedEncounterId);
useEffect(() => {
if (encountersData) setEncounters(encountersData);
}, [encountersData]);
useEffect(() => {
selectedEncounterIdRef.current = selectedEncounterId;
}, [selectedEncounterId]);
useEffect(() => {
if (!campaignId) {
setSelectedEncounterId(null);
return;
}
if (encounters && encounters.length > 0) {
const currentSelection = selectedEncounterIdRef.current;
if (currentSelection === null || !encounters.some(e => e.id === currentSelection)) {
if (initialActiveEncounterId && encounters.some(e => e.id === initialActiveEncounterId)) {
setSelectedEncounterId(initialActiveEncounterId);
} else if (
activeDisplayInfo &&
activeDisplayInfo.activeCampaignId === campaignId &&
encounters.some(e => e.id === activeDisplayInfo.activeEncounterId)
) {
setSelectedEncounterId(activeDisplayInfo.activeEncounterId);
}
}
} else if (encounters && encounters.length === 0) {
setSelectedEncounterId(null);
}
}, [campaignId, initialActiveEncounterId, activeDisplayInfo, encounters]);
const handleCreateEncounter = async (name) => {
if (!db || !name.trim() || !campaignId) return;
const newEncounterId = generateId();
try {
await setDoc(doc(db, getPath.encounters(campaignId), newEncounterId), {
name: name.trim(),
createdAt: new Date().toISOString(),
participants: [],
round: 0,
currentTurnParticipantId: null,
isStarted: false,
isPaused: false
});
setShowCreateModal(false);
setSelectedEncounterId(newEncounterId);
} catch (err) {
console.error("Error creating encounter:", err);
alert("Failed to create encounter. Please try again.");
}
};
const requestDeleteEncounter = (encounterId, encounterName) => {
setItemToDelete({ id: encounterId, name: encounterName });
setShowDeleteConfirm(true);
};
const confirmDeleteEncounter = async () => {
if (!db || !itemToDelete) return;
const encounterId = itemToDelete.id;
try {
await deleteDoc(doc(db, getPath.encounter(campaignId, encounterId)));
if (selectedEncounterId === encounterId) {
setSelectedEncounterId(null);
}
if (activeDisplayInfo && activeDisplayInfo.activeEncounterId === encounterId) {
await updateDoc(doc(db, getPath.activeDisplay()), {
activeCampaignId: null,
activeEncounterId: null
});
}
} catch (err) {
console.error("Error deleting encounter:", err);
alert("Failed to delete encounter. Please try again.");
}
setShowDeleteConfirm(false);
setItemToDelete(null);
};
const handleTogglePlayerDisplay = async (encounterId) => {
if (!db) return;
try {
const currentActiveCampaign = activeDisplayInfo?.activeCampaignId;
const currentActiveEncounter = activeDisplayInfo?.activeEncounterId;
if (currentActiveCampaign === campaignId && currentActiveEncounter === encounterId) {
await setDoc(doc(db, getPath.activeDisplay()), {
activeCampaignId: null,
activeEncounterId: null,
}, { merge: true });
} else {
await setDoc(doc(db, getPath.activeDisplay()), {
activeCampaignId: campaignId,
activeEncounterId: encounterId,
}, { merge: true });
}
} catch (err) {
console.error("Error toggling Player Display:", err);
}
};
const selectedEncounter = encounters?.find(e => e.id === selectedEncounterId);
if (isLoadingEncounters && campaignId) {
return <p className="text-center text-slate-300 mt-4">Loading encounters...</p>;
}
return (
<>
<div className="mt-6 p-4 bg-slate-800 rounded-lg shadow">
<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)}
className="px-4 py-2 text-sm font-medium text-white bg-orange-500 hover:bg-orange-600 rounded-md transition-colors flex items-center"
>
<PlusCircle size={18} className="mr-1" /> Create Encounter
</button>
</div>
{(!encounters || encounters.length === 0) && (
<p className="text-sm text-slate-400">No encounters yet.</p>
)}
<div className="space-y-3">
{encounters?.map(encounter => {
const isLive = activeDisplayInfo &&
activeDisplayInfo.activeCampaignId === campaignId &&
activeDisplayInfo.activeEncounterId === encounter.id;
return (
<div
key={encounter.id}
2026-04-25 18:37:55 -04:00
className={`p-3 rounded-md shadow transition-all ${selectedEncounterId === encounter.id ? ' bg - amber - 900 ring - 2 ring - amber - 500 ' : ' bg - slate - 700 hover : bg - slate - 600 '} ${isLive ? ' ring - 2 ring - green - 500 shadow - md shadow - green - 500 / 30 ' : ''}`}
2025-12-13 19:15:26 -05:00
>
<div className="flex justify-between items-center">
<div onClick={() => setSelectedEncounterId(encounter.id)} className="cursor-pointer flex-grow">
<h4 className="font-medium text-white">{encounter.name}</h4>
<p className="text-xs text-slate-300">
Participants: {encounter.participants?.length || 0}
</p>
{isLive && (
<span className="text-xs text-green-400 font-semibold block mt-1">
LIVE ON PLAYER DISPLAY
</span>
)}
</div>
<div className="flex items-center space-x-2">
<button
onClick={() => handleTogglePlayerDisplay(encounter.id)}
2026-04-25 18:37:55 -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 - slate - 600 hover : bg - slate - 500 '}`}
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);
}}
className="p-1 rounded transition-colors text-red-400 hover:text-red-300 bg-slate-600 hover:bg-slate-500"
title="Delete Encounter"
>
<Trash2 size={18} />
</button>
</div>
</div>
</div>
);
})}
</div>
{showCreateModal && (
<Modal onClose={() => setShowCreateModal(false)} title="Create New Encounter">
<CreateEncounterForm
onCreate={handleCreateEncounter}
onCancel={() => setShowCreateModal(false)}
/>
</Modal>
)}
{selectedEncounter && (
2026-04-25 18:37:55 -04:00
<div className="mt-6 p-4 bg-slate-800 rounded-lg shadow-inner">
<h3 className="text-xl font-semibold text-amber-300 mb-3 font-cinzel tracking-wide">
2025-12-13 19:15:26 -05:00
Managing Encounter: {selectedEncounter.name}
</h3>
<div className="flex flex-col lg:flex-row gap-4">
{/* Combat Controls - Left Side (Sticky on large screens) */}
<div className="lg:w-64 flex-shrink-0">
<InitiativeControls
campaignId={campaignId}
encounter={selectedEncounter}
encounterPath={getPath.encounter(campaignId, selectedEncounter.id)}
/>
</div>
{/* Participant Manager - Right Side */}
<div className="flex-grow">
<ParticipantManager
encounter={selectedEncounter}
encounterPath={getPath.encounter(campaignId, selectedEncounter.id)}
campaignCharacters={campaignCharacters}
/>
</div>
</div>
</div>
2025-05-25 21:19:22 -04:00
)}
</div>
2025-12-13 19:15:26 -05:00
<ConfirmationModal
isOpen={showDeleteConfirm}
onClose={() => setShowDeleteConfirm(false)}
onConfirm={confirmDeleteEncounter}
title="Delete Encounter?"
message={`Are you sure you want to delete the encounter "${itemToDelete?.name}"? This action cannot be undone.`}
/>
2025-05-26 09:41:50 -04:00
</>
2025-05-25 21:19:22 -04:00
);
}
2025-12-13 19:15:26 -05:00
// ============================================================================
// ADMIN VIEW COMPONENT
// ============================================================================
function AdminView({ userId }) {
const { data: campaignsData, isLoading: isLoadingCampaigns, error: campaignsError } = useFirestoreCollection(
getPath.campaigns()
);
const { data: initialActiveInfo } = useFirestoreDocument(getPath.activeDisplay());
const [campaignsWithDetails, setCampaignsWithDetails] = useState([]);
const [selectedCampaignId, setSelectedCampaignId] = useState(null);
const [showCreateModal, setShowCreateModal] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [itemToDelete, setItemToDelete] = useState(null);
useEffect(() => {
if (campaignsData && db) {
const fetchDetails = async () => {
const detailedCampaigns = await Promise.all(
campaignsData.map(async (campaign) => {
const characters = campaign.players || [];
let encounterCount = 0;
try {
const encountersSnapshot = await getDocs(collection(db, getPath.encounters(campaign.id)));
encounterCount = encountersSnapshot.size;
} catch (err) {
console.error(`Failed to fetch encounters for campaign ${campaign.id}:`, err);
}
return { ...campaign, characters, encounterCount };
})
);
setCampaignsWithDetails(detailedCampaigns);
};
fetchDetails();
} else if (campaignsData) {
setCampaignsWithDetails(
campaignsData.map(c => ({ ...c, characters: c.players || [], encounterCount: 0 }))
);
}
}, [campaignsData]);
useEffect(() => {
if (
initialActiveInfo &&
initialActiveInfo.activeCampaignId &&
campaignsWithDetails.length > 0 &&
!selectedCampaignId
) {
const campaignExists = campaignsWithDetails.some(c => c.id === initialActiveInfo.activeCampaignId);
if (campaignExists) {
setSelectedCampaignId(initialActiveInfo.activeCampaignId);
}
}
}, [initialActiveInfo, campaignsWithDetails, selectedCampaignId]);
const handleCreateCampaign = async (name, backgroundUrl) => {
if (!db || !name.trim()) return;
const newCampaignId = generateId();
try {
await setDoc(doc(db, getPath.campaign(newCampaignId)), {
name: name.trim(),
playerDisplayBackgroundUrl: backgroundUrl.trim() || '',
ownerId: userId,
createdAt: new Date().toISOString(),
players: [],
});
setShowCreateModal(false);
setSelectedCampaignId(newCampaignId);
} catch (err) {
console.error("Error creating campaign:", err);
alert("Failed to create campaign. Please try again.");
}
};
const requestDeleteCampaign = (campaignId, campaignName) => {
setItemToDelete({ id: campaignId, name: campaignName });
setShowDeleteConfirm(true);
};
const confirmDeleteCampaign = async () => {
if (!db || !itemToDelete) return;
const campaignId = itemToDelete.id;
try {
const encountersPath = getPath.encounters(campaignId);
const encountersSnapshot = await getDocs(collection(db, encountersPath));
const batch = writeBatch(db);
encountersSnapshot.docs.forEach(encounterDoc => batch.delete(encounterDoc.ref));
await batch.commit();
await deleteDoc(doc(db, getPath.campaign(campaignId)));
if (selectedCampaignId === campaignId) {
setSelectedCampaignId(null);
}
const activeDisplayRef = doc(db, getPath.activeDisplay());
const activeDisplaySnap = await getDoc(activeDisplayRef);
if (activeDisplaySnap.exists() && activeDisplaySnap.data().activeCampaignId === campaignId) {
await updateDoc(activeDisplayRef, {
activeCampaignId: null,
activeEncounterId: null
});
}
} catch (err) {
console.error("Error deleting campaign:", err);
alert("Failed to delete campaign. Please try again.");
}
setShowDeleteConfirm(false);
setItemToDelete(null);
};
const selectedCampaign = campaignsWithDetails.find(c => c.id === selectedCampaignId);
if (isLoadingCampaigns) {
return <p className="text-center text-slate-300">Loading campaigns...</p>;
}
if (campaignsError) {
return (
<p className="text-center text-red-400">
Error loading campaigns: {campaignsError.message || String(campaignsError)}
</p>
);
}
return (
<>
<div className="space-y-6">
<div>
<div className="flex justify-between items-center mb-4">
2026-04-25 18:37:55 -04:00
<h2 className="text-2xl font-semibold text-amber-300 font-cinzel tracking-wide">Campaigns</h2>
2025-12-13 19:15:26 -05:00
<button
onClick={() => setShowCreateModal(true)}
className="bg-green-500 hover:bg-green-600 text-white font-bold py-2 px-4 rounded-lg flex items-center transition-colors"
>
<PlusCircle size={20} className="mr-2" /> Create Campaign
</button>
</div>
{campaignsWithDetails.length === 0 && !isLoadingCampaigns && (
<p className="text-slate-400">No campaigns yet. Create one to get started!</p>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{campaignsWithDetails.map(campaign => {
const cardStyle = campaign.playerDisplayBackgroundUrl
? { backgroundImage: `url(${campaign.playerDisplayBackgroundUrl})` }
: {};
2026-04-25 18:37:55 -04:00
const cardClasses = `h-40 flex flex-col justify-between rounded-lg shadow-md cursor-pointer transition-all relative overflow-hidden bg-cover bg-center ${selectedCampaignId === campaign.id ? ' ring - 4 ring - amber - 500 ' : ''} ${!campaign.playerDisplayBackgroundUrl ? ' bg - slate - 700 hover : bg - slate - 600 ' : ' hover : shadow - xl '}`;
2025-12-13 19:15:26 -05:00
return (
<div
key={campaign.id}
onClick={() => setSelectedCampaignId(campaign.id)}
className={cardClasses}
style={cardStyle}
>
<div
className={`relative z-10 flex flex-col justify-between h-full ${campaign.playerDisplayBackgroundUrl ? ' bg - black bg - opacity - 60 p - 3 ' : ' p - 4 '}`}
>
<div>
<h3 className="text-xl font-semibold text-white">{campaign.name}</h3>
<div className="text-xs text-slate-100 mt-1 space-x-3">
<span className="inline-flex items-center">
<Users size={12} className="mr-1" /> {campaign.characters?.length || 0} Characters
</span>
<span className="inline-flex items-center">
<Swords size={12} className="mr-1" /> {campaign.encounterCount === undefined ? ' ... ' : campaign.encounterCount} Encounters
</span>
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation();
requestDeleteCampaign(campaign.id, campaign.name);
}}
className="mt-auto text-red-300 hover:text-red-100 text-xs flex items-center self-start bg-black bg-opacity-50 hover:bg-opacity-70 px-2 py-1 rounded"
>
<Trash2 size={14} className="mr-1" /> Delete
</button>
</div>
</div>
);
})}
</div>
</div>
{showCreateModal && (
<Modal onClose={() => setShowCreateModal(false)} title="Create New Campaign">
<CreateCampaignForm
onCreate={handleCreateCampaign}
onCancel={() => setShowCreateModal(false)}
/>
</Modal>
)}
{selectedCampaign && (
2026-04-25 18:37:55 -04:00
<div className="mt-6 p-6 bg-slate-800 rounded-lg shadow-xl">
<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 || []}
/>
<hr className="my-6 border-slate-600" />
<EncounterManager
campaignId={selectedCampaignId}
initialActiveEncounterId={
initialActiveInfo && initialActiveInfo.activeCampaignId === selectedCampaignId
? initialActiveInfo.activeEncounterId
: null
}
campaignCharacters={selectedCampaign.characters || []}
/>
</div>
)}
</div>
<ConfirmationModal
isOpen={showDeleteConfirm}
onClose={() => setShowDeleteConfirm(false)}
onConfirm={confirmDeleteCampaign}
title="Delete Campaign?"
message={`Are you sure you want to delete the campaign "${itemToDelete?.name}" and all its encounters? This action cannot be undone.`}
/>
</>
);
}
// ============================================================================
// DISPLAY VIEW COMPONENT (Player View)
// ============================================================================
function DisplayView() {
const { data: activeDisplayData, isLoading: isLoadingActiveDisplay, error: activeDisplayError } = useFirestoreDocument(
getPath.activeDisplay()
);
2025-05-25 21:19:22 -04:00
const [activeEncounterData, setActiveEncounterData] = useState(null);
2025-12-13 19:15:26 -05:00
const [isLoadingEncounter, setIsLoadingEncounter] = useState(true);
2025-05-26 08:33:39 -04:00
const [encounterError, setEncounterError] = useState(null);
2025-05-25 23:28:36 -04:00
const [campaignBackgroundUrl, setCampaignBackgroundUrl] = useState('');
2025-12-13 19:15:26 -05:00
const [isPlayerDisplayActive, setIsPlayerDisplayActive] = useState(false);
const currentParticipantRef = useRef(null);
2025-05-25 21:19:22 -04:00
useEffect(() => {
2025-12-13 19:15:26 -05:00
if (!db) {
setEncounterError("Firestore not available.");
setIsLoadingEncounter(false);
setIsPlayerDisplayActive(false);
return;
}
2025-05-25 21:19:22 -04:00
let unsubscribeEncounter;
2025-05-25 23:28:36 -04:00
let unsubscribeCampaign;
2025-12-13 19:15:26 -05:00
2025-05-26 08:33:39 -04:00
if (activeDisplayData) {
2025-12-13 19:15:26 -05:00
const { activeCampaignId, activeEncounterId } = activeDisplayData;
if (activeCampaignId && activeEncounterId) {
setIsPlayerDisplayActive(true);
setIsLoadingEncounter(true);
setEncounterError(null);
const campaignDocRef = doc(db, getPath.campaign(activeCampaignId));
unsubscribeCampaign = onSnapshot(
campaignDocRef,
(campSnap) => {
if (campSnap.exists()) {
setCampaignBackgroundUrl(campSnap.data().playerDisplayBackgroundUrl || '');
} else {
setCampaignBackgroundUrl('');
}
},
(err) => console.error("Error fetching campaign background:", err)
);
const encounterPath = getPath.encounter(activeCampaignId, activeEncounterId);
unsubscribeEncounter = onSnapshot(
doc(db, encounterPath),
(encDocSnap) => {
if (encDocSnap.exists()) {
setActiveEncounterData({ id: encDocSnap.id, ...encDocSnap.data() });
} else {
setActiveEncounterData(null);
setEncounterError("Active encounter data not found.");
}
setIsLoadingEncounter(false);
},
(err) => {
console.error("Error fetching active encounter details:", err);
setEncounterError("Error loading active encounter data.");
setIsLoadingEncounter(false);
}
);
} else {
setActiveEncounterData(null);
setCampaignBackgroundUrl('');
setIsPlayerDisplayActive(false);
setIsLoadingEncounter(false);
}
} else if (!isLoadingActiveDisplay) {
setActiveEncounterData(null);
setCampaignBackgroundUrl('');
setIsPlayerDisplayActive(false);
setIsLoadingEncounter(false);
}
return () => {
if (unsubscribeEncounter) unsubscribeEncounter();
if (unsubscribeCampaign) unsubscribeCampaign();
};
}, [activeDisplayData, isLoadingActiveDisplay]);
// Auto-scroll current participant into view
useEffect(() => {
if (currentParticipantRef.current && activeEncounterData?.isStarted && !activeEncounterData?.isPaused) {
currentParticipantRef.current.scrollIntoView({
behavior: ' smooth ',
block: ' center ',
inline: ' nearest '
});
}
}, [activeEncounterData?.currentTurnParticipantId, activeEncounterData?.isStarted, activeEncounterData?.isPaused]);
if (isLoadingActiveDisplay || (isPlayerDisplayActive && isLoadingEncounter)) {
return <div className="text-center py-10 text-2xl text-slate-300">Loading Player Display...</div>;
}
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 (
<div className="min-h-screen bg-black text-slate-400 flex flex-col items-center justify-center p-4 text-center">
<EyeOff size={64} className="mb-4 text-slate-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;
let participantsToRender = [];
if (participants) {
// Show all participants, including dead ones (HP = 0)
participantsToRender = sortParticipantsByInitiative(participants, participants);
2025-05-25 21:19:22 -04:00
}
2025-12-13 19:15:26 -05:00
const displayStyles = campaignBackgroundUrl
? {
backgroundImage: `url(${campaignBackgroundUrl})`,
backgroundSize: ' cover ',
backgroundPosition: ' center center ',
backgroundRepeat: ' no - repeat ',
minHeight: ' 100 vh '
}
: { minHeight: ' 100 vh ' };
2025-05-25 21:19:22 -04:00
return (
2025-12-13 19:15:26 -05:00
<div
className={`p-4 md:p-8 rounded-xl shadow-2xl ${!campaignBackgroundUrl ? ' bg - slate - 900 ' : ''}`}
style={displayStyles}
>
<div className={campaignBackgroundUrl ? ' bg - slate - 900 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 && (
<p className="text-2xl text-center text-slate-400 mb-6">Awaiting Start</p>
)}
{!isStarted && (!participants || participants.length === 0) && (
<p className="text-2xl text-slate-500 mb-6">No participants.</p>
)}
{participantsToRender.length === 0 && isStarted && (
<p className="text-xl text-slate-400">No active participants.</p>
)}
<div className="space-y-4 max-w-3xl mx-auto">
2025-05-27 10:51:29 -04:00
{participantsToRender.map(p => {
2025-12-13 19:15:26 -05:00
const isDead = p.currentHp === 0;
const isDying = p.isDying || false;
let participantBgColor = p.type === ' monster '
? (p.isNpc ? ' bg - slate - 700 ' : ' bg - [ # 8e351 c ] ')
2026-04-25 18:37:55 -04:00
: ' bg - blue - 950 ';
2025-12-13 19:15:26 -05:00
const isCurrentTurn = p.id === currentTurnParticipantId && isStarted && !isPaused;
if (isCurrentTurn) {
2025-05-27 10:51:29 -04:00
participantBgColor = ' bg - green - 700 ring - 4 ring - green - 400 scale - 105 ';
} else if (isPaused && p.id === currentTurnParticipantId) {
participantBgColor += ' ring - 2 ring - yellow - 400 ';
}
return (
2025-12-13 19:15:26 -05:00
<div
key={p.id}
ref={isCurrentTurn ? currentParticipantRef : null}
className={`p-4 md:p-6 rounded-lg shadow-lg transition-all ${participantBgColor} ${isDead ? ' opacity - 40 grayscale ' : (!p.isActive ? ' opacity - 40 grayscale ' : '')} ${isDying ? ' animate - death - dissolve ' : ''}`}
>
<div className="flex justify-between items-center mb-2">
<h3
2026-04-25 18:37:55 -04:00
className={`text-2xl md:text-3xl font-bold font-cinzel ${isCurrentTurn ? ' text - white ' : (p.type === ' character ' ? ' text - amber - 100 ' : (p.isNpc ? ' text - slate - 100 ' : ' text - white '))}`}
2025-12-13 19:15:26 -05:00
>
{isDead && <span className="mr-2">☠️</span>}
{p.name}
{isCurrentTurn && (
<span className="text-yellow-300 animate-pulse ml-2">(Current)</span>
)}
{isDead && <span className="text-red-300 text-lg ml-2">(Unconscious)</span>}
</h3>
<span
className={`text-xl md:text-2xl font-semibold ${isCurrentTurn ? ' text - green - 200 ' : ' text - slate - 200 '}`}
>
Init: {p.initiative}
</span>
2025-05-25 23:28:36 -04:00
</div>
2025-12-13 19:15:26 -05:00
<div className="flex justify-between items-center">
<div className="w-full bg-slate-600 rounded-full h-6 md:h-8 relative overflow-hidden border-2 border-slate-500">
<div
className={`h-full rounded-full transition-all ${isDead ? ' bg - red - 900 ' : (p.currentHp <= p.maxHp / 4 ? ' bg - red - 500 ' : (p.currentHp <= p.maxHp / 2 ? ' bg - yellow - 500 ' : ' bg - green - 500 '))}`}
style={{ width: `${Math.max(0, (p.currentHp / p.maxHp) * 100)}%` }}
></div>
{p.type !== ' monster ' && (
<span className="absolute inset-0 flex items-center justify-center text-xs md:text-sm font-medium text-white px-2">
HP: {p.currentHp} / {p.maxHp}
</span>
)}
</div>
</div>
{p.conditions?.length > 0 && (
<p className="text-sm text-yellow-300 mt-2">Conditions: {p.conditions.join(' , ')}</p>
)}
{!p.isActive && !isDead && (
<p className="text-center text-lg font-semibold text-slate-300 mt-2">(Inactive)</p>
)}
2025-05-25 23:28:36 -04:00
</div>
2025-12-13 19:15:26 -05:00
);
2025-05-27 10:51:29 -04:00
})}
2025-05-25 23:28:36 -04:00
</div>
2025-05-25 21:19:22 -04:00
</div>
2025-12-13 19:15:26 -05:00
</div>
);
2025-05-25 21:19:22 -04:00
}
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);
2025-05-25 21:19:22 -04:00
useEffect(() => {
2025-12-13 19:15:26 -05:00
const queryParams = new URLSearchParams(window.location.search);
2026-04-25 18:37:55 -04:00
if (queryParams.get(' playerView ') === ' true ' || window.location.pathname === ' / display ') {
2025-12-13 19:15:26 -05:00
setIsPlayerViewOnlyMode(true);
}
if (!auth) {
setError("Firebase Auth not initialized. Check your Firebase configuration.");
setIsLoading(false);
setIsAuthReady(false);
return;
}
const initAuth = async () => {
try {
const token = window.__initial_auth_token;
if (token) {
await signInWithCustomToken(auth, token);
} else {
await signInAnonymously(auth);
}
} catch (err) {
console.error("Authentication error:", err);
setError("Failed to authenticate. Please try again later.");
}
};
const unsubscribe = onAuthStateChanged(auth, (user) => {
setUserId(user ? user.uid : null);
setIsAuthReady(true);
setIsLoading(false);
});
initAuth();
return () => unsubscribe();
}, []);
if (!isFirebaseInitialized || !db || !auth) {
return (
<ErrorDisplay
critical
message="Firebase is not properly configured or initialized. Please check your .env.local file and ensure all REACT_APP_FIREBASE_... variables are correctly set."
/>
);
}
if (isLoading || !isAuthReady) {
return <LoadingSpinner message="Loading Initiative Tracker..." />;
}
if (error) {
return <ErrorDisplay message={error} />;
}
const openPlayerWindow = () => {
2026-04-25 18:37:55 -04:00
const playerViewUrl = window.location.origin + ' / display ';
2025-12-13 19:15:26 -05:00
window.open(playerViewUrl, ' _blank ', ' noopener , noreferrer , width = 1024 , height = 768 ' );
};
if ( isPlayerViewOnlyMode ) {
return (
2026-04-25 18:37:55 -04:00
< div className = "min-h-screen bg-slate-900 text-slate-100 font-garamond" >
2025-12-13 19:15:26 -05:00
{ isAuthReady && < DisplayView /> }
{ ! isAuthReady && ! error && < p > Authenticating for Player Display ... < /p>}
< /div>
);
}
2025-05-25 21:19:22 -04:00
return (
2026-04-25 18:37:55 -04:00
< div className = "min-h-screen bg-slate-900 text-slate-100 font-garamond" >
< header className = "bg-slate-900 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>
2025-12-13 19:15:26 -05:00
< button
onClick = { openPlayerWindow }
2026-04-25 18:37:55 -04:00
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"
2025-12-13 19:15:26 -05:00
>
< ExternalLink size = { 16 } className = "mr-2" /> Open Player Window
< /button>
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>
< footer className = "bg-slate-900 p-4 text-center text-sm text-slate-400 mt-8" >
TTRPG Initiative Tracker { APP_VERSION }
< /footer>
2025-05-25 21:19:22 -04:00
< /div>
);
}
2025-05-26 08:53:26 -04:00
export default App ;