2025-05-25 22:21:45 -04:00
import React , { useState , useEffect , useRef } from 'react' ; // Added useRef
2025-05-25 21:19:22 -04:00
import { initializeApp } from 'firebase/app' ;
import { getAuth , signInAnonymously , onAuthStateChanged , signInWithCustomToken } from 'firebase/auth' ;
2025-05-25 22:21:45 -04:00
import { getFirestore , doc , setDoc , addDoc , getDoc , getDocs , collection , onSnapshot , updateDoc , deleteDoc , query , writeBatch } from 'firebase/firestore' ; // Removed where as it's not used
2025-05-25 21:19:22 -04:00
import { ArrowLeft , PlusCircle , Users , Swords , Shield , Trash2 , Eye , Edit3 , Save , XCircle , ChevronsUpDown , ChevronDown , ChevronUp , UserCheck , UserX , HeartCrack , HeartPulse , Zap , Share2 , Copy as CopyIcon } from 'lucide-react' ;
// --- Firebase Configuration ---
const firebaseConfig = {
apiKey : process . env . REACT _APP _FIREBASE _API _KEY ,
authDomain : process . env . REACT _APP _FIREBASE _AUTH _DOMAIN ,
projectId : process . env . REACT _APP _FIREBASE _PROJECT _ID ,
storageBucket : process . env . REACT _APP _FIREBASE _STORAGE _BUCKET ,
messagingSenderId : process . env . REACT _APP _FIREBASE _MESSAGING _SENDER _ID ,
appId : process . env . REACT _APP _FIREBASE _APP _ID
} ;
2025-05-25 22:21:45 -04:00
let app ;
let db ;
let auth ;
const requiredFirebaseConfigKeys = [ 'apiKey' , 'authDomain' , 'projectId' , 'appId' ] ;
2025-05-25 21:19:22 -04:00
const missingKeys = requiredFirebaseConfigKeys . filter ( key => ! firebaseConfig [ key ] ) ;
if ( missingKeys . length > 0 ) {
2025-05-25 22:21:45 -04:00
console . error ( ` CRITICAL: Missing Firebase config values from environment variables: ${ missingKeys . join ( ', ' ) } ` ) ;
console . error ( "Firebase cannot be initialized. Please ensure all REACT_APP_FIREBASE_... variables are set in your .env.local file and accessible during the build." ) ;
// Fallback: Render a message or allow app to break if Firebase is critical
2025-05-25 21:19:22 -04:00
} else {
2025-05-25 22:21:45 -04:00
try {
app = initializeApp ( firebaseConfig ) ;
db = getFirestore ( app ) ;
auth = getAuth ( app ) ;
} catch ( error ) {
console . error ( "Error initializing Firebase:" , error ) ;
// Handle initialization error, perhaps by setting db and auth to null or showing an error UI
}
2025-05-25 21:19:22 -04:00
}
// --- Firestore Paths ---
const APP _ID = process . env . REACT _APP _TRACKER _APP _ID || 'ttrpg-initiative-tracker-default' ;
2025-05-25 22:21:45 -04:00
const PUBLIC _DATA _PATH = ` artifacts/ ${ APP _ID } /public/data ` ;
const CAMPAIGNS _COLLECTION = ` ${ PUBLIC _DATA _PATH } /campaigns ` ;
const ACTIVE _DISPLAY _DOC = ` ${ PUBLIC _DATA _PATH } /activeDisplay/status ` ;
2025-05-25 21:19:22 -04:00
// --- Helper Functions ---
const generateId = ( ) => crypto . randomUUID ( ) ;
function getShareableLinkBase ( ) {
return window . location . origin + window . location . pathname ;
}
// --- Main App Component ---
function App ( ) {
const [ userId , setUserId ] = useState ( null ) ;
const [ isAuthReady , setIsAuthReady ] = useState ( false ) ;
const [ viewMode , setViewMode ] = useState ( 'admin' ) ;
const [ isLoading , setIsLoading ] = useState ( true ) ;
const [ error , setError ] = useState ( null ) ;
const [ directDisplayParams , setDirectDisplayParams ] = useState ( null ) ;
useEffect ( ( ) => {
2025-05-25 22:21:45 -04:00
if ( ! auth ) { // Check if Firebase auth was initialized
setError ( "Firebase Auth not initialized. Check your Firebase configuration." ) ;
setIsLoading ( false ) ;
setIsAuthReady ( false ) ; // Explicitly set auth not ready
return ;
}
2025-05-25 21:19:22 -04:00
const handleHashChange = ( ) => {
const hash = window . location . hash ;
if ( hash . startsWith ( '#/display/' ) ) {
const parts = hash . substring ( '#/display/' . length ) . split ( '/' ) ;
if ( parts . length === 2 && parts [ 0 ] && parts [ 1 ] ) {
setDirectDisplayParams ( { campaignId : parts [ 0 ] , encounterId : parts [ 1 ] } ) ;
setViewMode ( 'display' ) ;
} else {
setDirectDisplayParams ( null ) ;
}
} else {
setDirectDisplayParams ( null ) ;
}
} ;
window . addEventListener ( 'hashchange' , handleHashChange ) ;
handleHashChange ( ) ;
2025-05-25 22:21:45 -04:00
2025-05-25 21:19:22 -04:00
const initAuth = async ( ) => {
try {
2025-05-25 22:21:45 -04:00
const token = window . _ _initial _auth _token ;
if ( token ) {
await signInWithCustomToken ( auth , token ) ;
2025-05-25 21:19:22 -04:00
} else {
await signInAnonymously ( auth ) ;
}
} catch ( err ) {
console . error ( "Authentication error:" , err ) ;
setError ( "Failed to authenticate. Please try again later." ) ;
}
} ;
2025-05-25 22:21:45 -04:00
2025-05-25 21:19:22 -04:00
const unsubscribe = onAuthStateChanged ( auth , ( user ) => {
setUserId ( user ? user . uid : null ) ;
setIsAuthReady ( true ) ;
setIsLoading ( false ) ;
} ) ;
initAuth ( ) ;
2025-05-25 22:21:45 -04:00
return ( ) => {
window . removeEventListener ( 'hashchange' , handleHashChange ) ;
unsubscribe ( ) ;
} ;
2025-05-25 21:19:22 -04:00
} , [ ] ) ;
2025-05-25 22:21:45 -04:00
if ( ! db || ! auth ) { // If Firebase failed to init
return (
< div className = "min-h-screen bg-red-900 text-white flex flex-col items-center justify-center p-4" >
< h1 className = "text-3xl font-bold" > Configuration Error < / h 1 >
< p className = "mt-4 text-xl" > Firebase is not properly configured or initialized . < / p >
< p > Please check your ` .env.local ` file and ensure all ` REACT_APP_FIREBASE_... ` variables are correctly set . < / p >
< p > Also , check the browser console for more specific error messages . < / p >
{ error && < p className = "mt-2 text-yellow-300" > { error } < / p > }
< / d i v >
) ;
}
2025-05-25 21:19:22 -04:00
if ( isLoading || ! isAuthReady ) {
return (
< div className = "min-h-screen bg-slate-900 text-white flex flex-col items-center justify-center p-4" >
< div className = "animate-spin rounded-full h-16 w-16 border-t-4 border-blue-500 border-solid" > < / d i v >
< p className = "mt-4 text-xl" > Loading Initiative Tracker ... < / p >
{ error && < p className = "mt-2 text-red-400" > { error } < / p > }
< / d i v >
) ;
}
return (
< div className = "min-h-screen bg-slate-800 text-slate-100 font-sans" >
{ ! directDisplayParams && (
< header className = "bg-slate-900 p-4 shadow-lg" >
< div className = "container mx-auto flex justify-between items-center" >
< h1 className = "text-3xl font-bold text-sky-400" > TTRPG Initiative Tracker < / h 1 >
< div className = "flex items-center space-x-4" >
{ userId && < span className = "text-xs text-slate-400" > UID : { userId } < / s p a n > }
2025-05-25 22:21:45 -04:00
{ /* Show Admin View button only if not in display mode */ }
{ viewMode !== 'display' && (
2025-05-25 21:19:22 -04:00
< button
onClick = { ( ) => { setViewMode ( 'admin' ) ; setDirectDisplayParams ( null ) ; window . location . hash = '' ; } }
2025-05-25 22:21:45 -04:00
className = { ` px-4 py-2 rounded-md text-sm font-medium transition-colors ${ viewMode === 'admin' ? 'bg-sky-500 text-white' : 'bg-slate-700 hover:bg-slate-600' } ` }
2025-05-25 21:19:22 -04:00
>
Admin View
< / b u t t o n >
) }
2025-05-25 22:21:45 -04:00
{ /* Show Player Display button only if not in admin mode (or always if header is visible and it's the only option left) */ }
{ viewMode !== 'admin' && (
2025-05-25 21:19:22 -04:00
< button
onClick = { ( ) => { setViewMode ( 'display' ) ; setDirectDisplayParams ( null ) ; window . location . hash = '' ; } }
2025-05-25 22:21:45 -04:00
className = { ` px-4 py-2 rounded-md text-sm font-medium transition-colors ${ viewMode === 'display' ? 'bg-teal-500 text-white' : 'bg-slate-700 hover:bg-slate-600' } ` }
2025-05-25 21:19:22 -04:00
>
Player Display
< / b u t t o n >
) }
2025-05-25 22:21:45 -04:00
{ /* Simpler toggle: always show both if header is visible and style the active one */ }
{ /* The above logic is slightly off for two buttons always present. Let's fix: */ }
{ /* Corrected Header Buttons for Toggling */ }
< button
onClick = { ( ) => { setViewMode ( 'admin' ) ; setDirectDisplayParams ( null ) ; window . location . hash = '' ; } }
className = { ` px-4 py-2 rounded-md text-sm font-medium transition-colors ${ viewMode === 'admin' ? 'bg-sky-500 text-white' : 'bg-slate-700 hover:bg-slate-600' } ` }
// Hide if current view is display, but allow access from admin
style = { viewMode === 'display' ? { display : 'none' } : { } }
>
Admin View
< / b u t t o n >
< button
onClick = { ( ) => { setViewMode ( 'display' ) ; setDirectDisplayParams ( null ) ; window . location . hash = '' ; } }
className = { ` px-4 py-2 rounded-md text-sm font-medium transition-colors ${ viewMode === 'display' ? 'bg-teal-500 text-white' : 'bg-slate-700 hover:bg-slate-600' } ` }
>
Player Display
< / b u t t o n >
2025-05-25 21:19:22 -04:00
< / d i v >
< / d i v >
< / h e a d e r >
) }
< main className = { ` container mx-auto p-4 md:p-8 ${ directDisplayParams ? 'pt-8' : '' } ` } >
{ directDisplayParams && isAuthReady && (
< DisplayView campaignIdFromUrl = { directDisplayParams . campaignId } encounterIdFromUrl = { directDisplayParams . encounterId } / >
) }
{ ! directDisplayParams && viewMode === 'admin' && isAuthReady && userId && < AdminView userId = { userId } / > }
{ ! directDisplayParams && viewMode === 'display' && isAuthReady && < DisplayView / > }
2025-05-25 22:21:45 -04:00
{ ! isAuthReady && ! error && < p > Authenticating ... < /p>} {/ * Show auth message only if no other error * / }
2025-05-25 21:19:22 -04:00
< / m a i n >
{ ! directDisplayParams && (
< footer className = "bg-slate-900 p-4 text-center text-sm text-slate-400 mt-8" >
2025-05-25 22:21:45 -04:00
TTRPG Initiative Tracker v0 . 1.13
2025-05-25 21:19:22 -04:00
< / f o o t e r >
) }
< / d i v >
) ;
}
// --- Admin View Component ---
function AdminView ( { userId } ) {
const [ campaigns , setCampaigns ] = useState ( [ ] ) ;
const [ selectedCampaignId , setSelectedCampaignId ] = useState ( null ) ;
const [ showCreateCampaignModal , setShowCreateCampaignModal ] = useState ( false ) ;
const [ initialActiveInfo , setInitialActiveInfo ] = useState ( null ) ;
useEffect ( ( ) => {
2025-05-25 22:21:45 -04:00
if ( ! db ) return ; // Guard against Firebase not initialized
2025-05-25 21:19:22 -04:00
const campaignsCollectionRef = collection ( db , CAMPAIGNS _COLLECTION ) ;
const q = query ( campaignsCollectionRef ) ;
const unsubscribeCampaigns = onSnapshot ( q , ( snapshot ) => {
setCampaigns ( snapshot . docs . map ( doc => ( { id : doc . id , ... doc . data ( ) , characters : doc . data ( ) . players || [ ] } ) ) ) ;
} , ( err ) => console . error ( "Error fetching campaigns:" , err ) ) ;
const activeDisplayRef = doc ( db , ACTIVE _DISPLAY _DOC ) ;
const unsubscribeActiveDisplay = onSnapshot ( activeDisplayRef , ( docSnap ) => {
if ( docSnap . exists ( ) ) {
setInitialActiveInfo ( docSnap . data ( ) ) ;
} else {
setInitialActiveInfo ( null ) ;
}
} , ( err ) => {
console . error ( "Error fetching initial active display info for AdminView:" , err ) ;
} ) ;
return ( ) => {
unsubscribeCampaigns ( ) ;
unsubscribeActiveDisplay ( ) ;
} ;
} , [ ] ) ;
useEffect ( ( ) => {
if ( initialActiveInfo && initialActiveInfo . activeCampaignId && campaigns . length > 0 && ! selectedCampaignId ) {
const campaignExists = campaigns . some ( c => c . id === initialActiveInfo . activeCampaignId ) ;
if ( campaignExists ) {
setSelectedCampaignId ( initialActiveInfo . activeCampaignId ) ;
}
}
} , [ initialActiveInfo , campaigns , selectedCampaignId ] ) ;
const handleCreateCampaign = async ( name ) => {
2025-05-25 22:21:45 -04:00
if ( ! db || ! name . trim ( ) ) return ;
2025-05-25 21:19:22 -04:00
const newCampaignId = generateId ( ) ;
try {
await setDoc ( doc ( db , CAMPAIGNS _COLLECTION , newCampaignId ) , {
name : name . trim ( ) , ownerId : userId , createdAt : new Date ( ) . toISOString ( ) , players : [ ] ,
} ) ;
setShowCreateCampaignModal ( false ) ;
setSelectedCampaignId ( newCampaignId ) ;
} catch ( err ) { console . error ( "Error creating campaign:" , err ) ; }
} ;
const handleDeleteCampaign = async ( campaignId ) => {
2025-05-25 22:21:45 -04:00
if ( ! db ) return ;
2025-05-25 21:19:22 -04:00
// TODO: Implement custom confirmation modal for deleting campaigns
console . warn ( "Attempting to delete campaign without confirmation:" , campaignId ) ;
try {
const encountersPath = ` ${ CAMPAIGNS _COLLECTION } / ${ campaignId } /encounters ` ;
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 , CAMPAIGNS _COLLECTION , campaignId ) ) ;
if ( selectedCampaignId === campaignId ) setSelectedCampaignId ( null ) ;
const activeDisplayRef = doc ( db , ACTIVE _DISPLAY _DOC ) ;
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 ) ; }
} ;
const selectedCampaign = campaigns . find ( c => c . id === selectedCampaignId ) ;
return (
< div className = "space-y-6" >
< div >
< div className = "flex justify-between items-center mb-4" >
< h2 className = "text-2xl font-semibold text-sky-300" > Campaigns < / h 2 >
< button onClick = { ( ) => setShowCreateCampaignModal ( 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
< / b u t t o n >
< / d i v >
{ campaigns . length === 0 && < p className = "text-slate-400" > No campaigns yet . < / p > }
< div className = "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4" >
{ campaigns . map ( campaign => (
< div
key = { campaign . id }
onClick = { ( ) => setSelectedCampaignId ( campaign . id ) }
className = { ` p-4 rounded-lg shadow-md cursor-pointer transition-all ${ selectedCampaignId === campaign . id ? 'bg-sky-700 ring-2 ring-sky-400' : 'bg-slate-700 hover:bg-slate-600' } ` }
>
< h3 className = "text-xl font-semibold text-white" > { campaign . name } < / h 3 >
< p className = "text-xs text-slate-400" > ID : { campaign . id } < / p >
< button
onClick = { ( e ) => {
e . stopPropagation ( ) ;
handleDeleteCampaign ( campaign . id ) ;
} }
className = "mt-2 text-red-400 hover:text-red-300 text-xs flex items-center"
>
< Trash2 size = { 14 } className = "mr-1" / > Delete
< / b u t t o n >
< / d i v >
) ) }
< / d i v >
< / d i v >
{ showCreateCampaignModal && < Modal onClose = { ( ) => setShowCreateCampaignModal ( false ) } title = "Create New Campaign" > < CreateCampaignForm onCreate = { handleCreateCampaign } onCancel = { ( ) => setShowCreateCampaignModal ( false ) } / > < / M o d a l > }
{ selectedCampaign && (
< div className = "mt-6 p-6 bg-slate-750 rounded-lg shadow-xl" >
< h2 className = "text-2xl font-semibold text-amber-300 mb-4" > Managing : { selectedCampaign . name } < / h 2 >
< CharacterManager campaignId = { selectedCampaignId } campaignCharacters = { selectedCampaign . players || [ ] } / >
< hr className = "my-6 border-slate-600" / >
< EncounterManager
campaignId = { selectedCampaignId }
initialActiveEncounterId = { initialActiveInfo && initialActiveInfo . activeCampaignId === selectedCampaignId ? initialActiveInfo . activeEncounterId : null }
campaignCharacters = { selectedCampaign . players || [ ] }
/ >
< / d i v >
) }
< / d i v >
) ;
}
function CreateCampaignForm ( { onCreate , onCancel } ) {
const [ name , setName ] = useState ( '' ) ;
return (
< form onSubmit = { ( e ) => { e . preventDefault ( ) ; onCreate ( name ) ; } } className = "space-y-4" >
< div >
< label htmlFor = "campaignName" className = "block text-sm font-medium text-slate-300" > Campaign Name < / l a b e l >
< input type = "text" id = "campaignName" value = { name } onChange = { ( e ) => setName ( e . target . value ) } 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-sky-500 focus:border-sky-500 sm:text-sm text-white" required / >
< / d i v >
< 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 < / b u t t o n >
< 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 < / b u t t o n >
< / d i v >
< / f o r m >
) ;
}
function CharacterManager ( { campaignId , campaignCharacters } ) {
const [ characterName , setCharacterName ] = useState ( '' ) ;
const [ editingCharacter , setEditingCharacter ] = useState ( null ) ;
const handleAddCharacter = async ( ) => {
2025-05-25 22:21:45 -04:00
if ( ! db || ! characterName . trim ( ) || ! campaignId ) return ;
2025-05-25 21:19:22 -04:00
const newCharacter = { id : generateId ( ) , name : characterName . trim ( ) } ;
try {
await updateDoc ( doc ( db , CAMPAIGNS _COLLECTION , campaignId ) , { players : [ ... campaignCharacters , newCharacter ] } ) ;
setCharacterName ( '' ) ;
} catch ( err ) { console . error ( "Error adding character:" , err ) ; }
} ;
const handleUpdateCharacter = async ( characterId , newName ) => {
2025-05-25 22:21:45 -04:00
if ( ! db || ! newName . trim ( ) || ! campaignId ) return ;
2025-05-25 21:19:22 -04:00
const updatedCharacters = campaignCharacters . map ( c => c . id === characterId ? { ... c , name : newName . trim ( ) } : c ) ;
try {
await updateDoc ( doc ( db , CAMPAIGNS _COLLECTION , campaignId ) , { players : updatedCharacters } ) ;
setEditingCharacter ( null ) ;
} catch ( err ) { console . error ( "Error updating character:" , err ) ; }
} ;
const handleDeleteCharacter = async ( characterId ) => {
2025-05-25 22:21:45 -04:00
if ( ! db ) return ;
2025-05-25 21:19:22 -04:00
// TODO: Implement custom confirmation modal for deleting characters
console . warn ( "Attempting to delete character without confirmation:" , characterId ) ;
const updatedCharacters = campaignCharacters . filter ( c => c . id !== characterId ) ;
try {
await updateDoc ( doc ( db , CAMPAIGNS _COLLECTION , campaignId ) , { players : updatedCharacters } ) ;
} catch ( err ) { console . error ( "Error deleting character:" , err ) ; }
} ;
return (
< div className = "p-4 bg-slate-800 rounded-lg shadow" >
< h3 className = "text-xl font-semibold text-sky-300 mb-3 flex items-center" > < Users size = { 24 } className = "mr-2" / > Campaign Characters < / h 3 >
< form onSubmit = { ( e ) => { e . preventDefault ( ) ; handleAddCharacter ( ) ; } } className = "flex gap-2 mb-4" >
< input type = "text" value = { characterName } onChange = { ( e ) => setCharacterName ( e . target . value ) } placeholder = "New character name" className = "flex-grow px-3 py-2 bg-slate-700 border border-slate-600 rounded-md shadow-sm focus:outline-none focus:ring-sky-500 focus:border-sky-500 sm:text-sm text-white" / >
< button type = "submit" className = "px-4 py-2 text-sm font-medium text-white bg-sky-500 hover:bg-sky-600 rounded-md transition-colors flex items-center" > < PlusCircle size = { 18 } className = "mr-1" / > Add Character < / b u t t o n >
< / f o r m >
{ campaignCharacters . length === 0 && < p className = "text-sm text-slate-400" > No characters added . < / 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 ? (
< input type = "text" defaultValue = { character . name } onBlur = { ( e ) => handleUpdateCharacter ( character . id , e . target . value ) } onKeyDown = { ( e ) => { if ( e . key === 'Enter' ) handleUpdateCharacter ( character . id , e . target . value ) ; if ( e . key === 'Escape' ) setEditingCharacter ( null ) ; } } autoFocus className = "flex-grow px-3 py-2 bg-slate-600 border border-slate-500 rounded-md shadow-sm focus:outline-none focus:ring-sky-500 focus:border-sky-500 sm:text-sm text-white" / >
) : ( < span className = "text-slate-100" > { character . name } < / s p a n > ) }
< div className = "flex space-x-2" >
< button onClick = { ( ) => setEditingCharacter ( character ) } 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 } / > < / b u t t o n >
< button onClick = { ( ) => handleDeleteCharacter ( character . id ) } 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 } / > < / b u t t o n >
< / d i v >
< / l i >
) ) }
< / u l >
< / d i v >
) ;
}
function EncounterManager ( { campaignId , initialActiveEncounterId , campaignCharacters } ) {
const [ encounters , setEncounters ] = useState ( [ ] ) ;
const [ selectedEncounterId , setSelectedEncounterId ] = useState ( null ) ;
const [ showCreateEncounterModal , setShowCreateEncounterModal ] = useState ( false ) ;
const [ activeDisplayInfo , setActiveDisplayInfo ] = useState ( null ) ;
const [ copiedLinkEncounterId , setCopiedLinkEncounterId ] = useState ( null ) ;
const encountersPath = ` ${ CAMPAIGNS _COLLECTION } / ${ campaignId } /encounters ` ;
2025-05-25 22:21:45 -04:00
const selectedEncounterIdRef = useRef ( selectedEncounterId ) ;
2025-05-25 21:19:22 -04:00
useEffect ( ( ) => {
selectedEncounterIdRef . current = selectedEncounterId ;
} , [ selectedEncounterId ] ) ;
useEffect ( ( ) => {
2025-05-25 22:21:45 -04:00
if ( ! db || ! campaignId ) {
2025-05-25 21:19:22 -04:00
setEncounters ( [ ] ) ;
setSelectedEncounterId ( null ) ;
return ;
}
const unsubEncounters = onSnapshot ( query ( collection ( db , encountersPath ) ) , ( snapshot ) => {
const fetchedEncounters = snapshot . docs . map ( doc => ( { id : doc . id , ... doc . data ( ) } ) ) ;
setEncounters ( fetchedEncounters ) ;
2025-05-25 22:21:45 -04:00
const currentSelection = selectedEncounterIdRef . current ;
if ( currentSelection === null || ! fetchedEncounters . some ( e => e . id === currentSelection ) ) {
2025-05-25 21:19:22 -04:00
if ( initialActiveEncounterId && fetchedEncounters . some ( e => e . id === initialActiveEncounterId ) ) {
setSelectedEncounterId ( initialActiveEncounterId ) ;
} else if ( activeDisplayInfo && activeDisplayInfo . activeCampaignId === campaignId &&
fetchedEncounters . some ( e => e . id === activeDisplayInfo . activeEncounterId ) ) {
setSelectedEncounterId ( activeDisplayInfo . activeEncounterId ) ;
}
}
} , ( err ) => console . error ( ` Error fetching encounters for campaign ${ campaignId } : ` , err ) ) ;
return ( ) => unsubEncounters ( ) ;
} , [ campaignId , initialActiveEncounterId , activeDisplayInfo ] ) ;
useEffect ( ( ) => {
2025-05-25 22:21:45 -04:00
if ( ! db ) return ;
2025-05-25 21:19:22 -04:00
const unsub = onSnapshot ( doc ( db , ACTIVE _DISPLAY _DOC ) , ( docSnap ) => {
setActiveDisplayInfo ( docSnap . exists ( ) ? docSnap . data ( ) : null ) ;
} , ( err ) => { console . error ( "Error fetching active display info:" , err ) ; setActiveDisplayInfo ( null ) ; } ) ;
return ( ) => unsub ( ) ;
} , [ ] ) ;
const handleCreateEncounter = async ( name ) => {
2025-05-25 22:21:45 -04:00
if ( ! db || ! name . trim ( ) || ! campaignId ) return ;
2025-05-25 21:19:22 -04:00
const newEncounterId = generateId ( ) ;
try {
await setDoc ( doc ( db , encountersPath , newEncounterId ) , {
name : name . trim ( ) , createdAt : new Date ( ) . toISOString ( ) , participants : [ ] , round : 0 , currentTurnParticipantId : null , isStarted : false ,
} ) ;
setShowCreateEncounterModal ( false ) ;
setSelectedEncounterId ( newEncounterId ) ;
} catch ( err ) { console . error ( "Error creating encounter:" , err ) ; }
} ;
const handleDeleteEncounter = async ( encounterId ) => {
2025-05-25 22:21:45 -04:00
if ( ! db ) return ;
2025-05-25 21:19:22 -04:00
// TODO: Implement custom confirmation modal for deleting encounters
console . warn ( "Attempting to delete encounter without confirmation:" , encounterId ) ;
try {
await deleteDoc ( doc ( db , encountersPath , encounterId ) ) ;
if ( selectedEncounterId === encounterId ) setSelectedEncounterId ( null ) ;
if ( activeDisplayInfo && activeDisplayInfo . activeEncounterId === encounterId ) {
await updateDoc ( doc ( db , ACTIVE _DISPLAY _DOC ) , { activeEncounterId : null } ) ;
}
} catch ( err ) { console . error ( "Error deleting encounter:" , err ) ; }
} ;
const handleSetEncounterAsActiveDisplay = async ( encounterId ) => {
2025-05-25 22:21:45 -04:00
if ( ! db ) return ;
2025-05-25 21:19:22 -04:00
try {
await setDoc ( doc ( db , ACTIVE _DISPLAY _DOC ) , { activeCampaignId : campaignId , activeEncounterId : encounterId } , { merge : true } ) ;
console . log ( "Encounter set as active for DM's main display!" ) ;
} catch ( err ) { console . error ( "Error setting active display:" , err ) ; }
} ;
const handleCopyToClipboard = ( encounterId ) => {
const link = ` ${ getShareableLinkBase ( ) } #/display/ ${ campaignId } / ${ encounterId } ` ;
navigator . clipboard . writeText ( link ) . then ( ( ) => {
setCopiedLinkEncounterId ( encounterId ) ;
setTimeout ( ( ) => setCopiedLinkEncounterId ( null ) , 2000 ) ;
} ) . catch ( err => console . error ( 'Failed to copy link: ' , err ) ) ;
} ;
const selectedEncounter = encounters . find ( e => e . id === selectedEncounterId ) ;
return (
< div className = "mt-6 p-4 bg-slate-800 rounded-lg shadow" >
< div className = "flex justify-between items-center mb-3" >
< h3 className = "text-xl font-semibold text-sky-300 flex items-center" > < Swords size = { 24 } className = "mr-2" / > Encounters < / h 3 >
< button onClick = { ( ) => setShowCreateEncounterModal ( 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 < / b u t t o n >
< / d i v >
{ encounters . length === 0 && < p className = "text-sm text-slate-400" > No encounters yet . < / p > }
< div className = "space-y-3" >
{ encounters . map ( encounter => {
const isDmActiveDisplay = activeDisplayInfo && activeDisplayInfo . activeCampaignId === campaignId && activeDisplayInfo . activeEncounterId === encounter . id ;
return (
< div key = { encounter . id } className = { ` p-3 rounded-md shadow transition-all ${ selectedEncounterId === encounter . id ? 'bg-sky-600 ring-2 ring-sky-400' : 'bg-slate-700 hover:bg-slate-650' } ${ isDmActiveDisplay ? 'ring-2 ring-green-500 shadow-md shadow-green-500/30' : '' } ` } >
< 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 } < / h 4 >
< p className = "text-xs text-slate-300" > Participants : { encounter . participants ? . length || 0 } < / p >
{ isDmActiveDisplay && < span className = "text-xs text-green-400 font-semibold block mt-1" > LIVE ON DM DISPLAY < / s p a n > }
< / d i v >
< div className = "flex items-center space-x-2" >
< button onClick = { ( ) => handleSetEncounterAsActiveDisplay ( encounter . id ) } className = { ` p-1 rounded transition-colors ${ isDmActiveDisplay ? 'bg-green-500 hover:bg-green-600 text-white' : 'text-teal-400 hover:text-teal-300 bg-slate-600 hover:bg-slate-500' } ` } title = "Set as DM's Active Display" > < Eye size = { 18 } / > < / b u t t o n >
< button onClick = { ( ) => handleCopyToClipboard ( encounter . id ) } className = "p-1 rounded transition-colors text-sky-400 hover:text-sky-300 bg-slate-600 hover:bg-slate-500" title = "Copy Share Link for Players" > < Share2 size = { 18 } / > < / b u t t o n >
{ copiedLinkEncounterId === encounter . id && < span className = "text-xs text-green-400" > Copied ! < / s p a n > }
< button onClick = { ( e ) => { e . stopPropagation ( ) ; handleDeleteEncounter ( encounter . id ) ; } } 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 } / > < / b u t t o n >
< / d i v >
< / d i v >
{ selectedEncounterId === encounter . id && (
< div className = "mt-2 pt-2 border-t border-slate-600" >
< p className = "text-xs text-slate-400" > Shareable Link for Players : < / p >
< div className = "flex items-center gap-2" >
< input type = "text" readOnly value = { ` ${ getShareableLinkBase ( ) } #/display/ ${ campaignId } / ${ encounter . id } ` } className = "text-xs w-full bg-slate-600 px-3 py-2 border border-slate-500 rounded-md shadow-sm focus:outline-none focus:ring-sky-500 focus:border-sky-500 sm:text-sm text-white" / >
< button onClick = { ( ) => handleCopyToClipboard ( encounter . id ) } className = "px-4 py-2 text-xs font-medium text-slate-300 bg-slate-500 hover:bg-slate-400 rounded-md transition-colors p-1" >
{ copiedLinkEncounterId === encounter . id ? 'Copied!' : < CopyIcon size = { 14 } / > }
< / b u t t o n >
< / d i v >
< / d i v >
) }
< / d i v >
) ;
} ) }
< / d i v >
{ showCreateEncounterModal && < Modal onClose = { ( ) => setShowCreateEncounterModal ( false ) } title = "Create New Encounter" > < CreateEncounterForm onCreate = { handleCreateEncounter } onCancel = { ( ) => setShowCreateEncounterModal ( false ) } / > < / M o d a l > }
{ selectedEncounter && (
< div className = "mt-6 p-4 bg-slate-750 rounded-lg shadow-inner" >
< h3 className = "text-xl font-semibold text-amber-300 mb-3" > Managing Encounter : { selectedEncounter . name } < / h 3 >
< ParticipantManager encounter = { selectedEncounter } encounterPath = { ` ${ encountersPath } / ${ selectedEncounterId } ` } campaignCharacters = { campaignCharacters } / >
< InitiativeControls campaignId = { campaignId } encounter = { selectedEncounter } encounterPath = { ` ${ encountersPath } / ${ selectedEncounterId } ` } / >
< / d i v >
) }
< / d i v >
) ;
}
function CreateEncounterForm ( { onCreate , onCancel } ) {
const [ name , setName ] = useState ( '' ) ;
return (
< form onSubmit = { ( e ) => { e . preventDefault ( ) ; onCreate ( name ) ; } } className = "space-y-4" >
< div >
< label htmlFor = "encounterName" className = "block text-sm font-medium text-slate-300" > Encounter Name < / l a b e l >
< input type = "text" id = "encounterName" value = { name } onChange = { ( e ) => setName ( e . target . value ) } 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-sky-500 focus:border-sky-500 sm:text-sm text-white" required / >
< / d i v >
< 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 < / b u t t o n >
< 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" > Create < / b u t t o n >
< / d i v >
< / f o r m >
) ;
}
function ParticipantManager ( { encounter , encounterPath , campaignCharacters } ) {
const [ participantName , setParticipantName ] = useState ( '' ) ;
const [ participantType , setParticipantType ] = useState ( 'monster' ) ;
const [ selectedCharacterId , setSelectedCharacterId ] = useState ( '' ) ;
const [ initiative , setInitiative ] = useState ( 10 ) ;
const [ maxHp , setMaxHp ] = useState ( 10 ) ;
const [ editingParticipant , setEditingParticipant ] = useState ( null ) ;
const [ hpChangeValues , setHpChangeValues ] = useState ( { } ) ;
const [ draggedItemId , setDraggedItemId ] = useState ( null ) ;
const participants = encounter . participants || [ ] ;
const handleAddParticipant = async ( ) => {
2025-05-25 22:21:45 -04:00
if ( ! db || ( participantType === 'monster' && ! participantName . trim ( ) ) || ( participantType === 'character' && ! selectedCharacterId ) ) return ;
2025-05-25 21:19:22 -04:00
let nameToAdd = participantName . trim ( ) ;
if ( participantType === 'character' ) {
const character = campaignCharacters . find ( c => c . id === selectedCharacterId ) ;
if ( ! character ) { console . error ( "Selected character not found" ) ; return ; }
if ( participants . some ( p => p . type === 'character' && p . originalCharacterId === selectedCharacterId ) ) {
alert ( ` ${ character . name } is already in this encounter. ` ) ; return ;
}
nameToAdd = character . name ;
}
const newParticipant = {
id : generateId ( ) , name : nameToAdd , type : participantType ,
originalCharacterId : participantType === 'character' ? selectedCharacterId : null ,
initiative : parseInt ( initiative , 10 ) || 0 , maxHp : parseInt ( maxHp , 10 ) || 1 , currentHp : parseInt ( maxHp , 10 ) || 1 ,
conditions : [ ] , isActive : true ,
} ;
try {
await updateDoc ( doc ( db , encounterPath ) , { participants : [ ... participants , newParticipant ] } ) ;
setParticipantName ( '' ) ; setInitiative ( 10 ) ; setMaxHp ( 10 ) ; setSelectedCharacterId ( '' ) ;
} catch ( err ) { console . error ( "Error adding participant:" , err ) ; }
} ;
const handleUpdateParticipant = async ( updatedData ) => {
2025-05-25 22:21:45 -04:00
if ( ! db || ! editingParticipant ) return ;
2025-05-25 21:19:22 -04:00
const { flavorText , ... restOfData } = updatedData ;
const updatedParticipants = participants . map ( p => p . id === editingParticipant . id ? { ... p , ... restOfData } : p ) ;
try {
await updateDoc ( doc ( db , encounterPath ) , { participants : updatedParticipants } ) ;
setEditingParticipant ( null ) ;
} catch ( err ) { console . error ( "Error updating participant:" , err ) ; }
} ;
const handleDeleteParticipant = async ( participantId ) => {
2025-05-25 22:21:45 -04:00
if ( ! db ) return ;
2025-05-25 21:19:22 -04:00
// TODO: Implement custom confirmation modal for deleting participants
console . warn ( "Attempting to delete participant without confirmation:" , participantId ) ;
const updatedParticipants = participants . filter ( p => p . id !== participantId ) ;
try {
await updateDoc ( doc ( db , encounterPath ) , { participants : updatedParticipants } ) ;
} catch ( err ) { console . error ( "Error deleting participant:" , err ) ; }
} ;
const toggleParticipantActive = async ( participantId ) => {
2025-05-25 22:21:45 -04:00
if ( ! db ) return ;
2025-05-25 21:19:22 -04:00
const pToToggle = participants . find ( p => p . id === participantId ) ;
if ( ! pToToggle ) return ;
const updatedPs = participants . map ( p => p . id === participantId ? { ... p , isActive : ! p . isActive } : p ) ;
try { await updateDoc ( doc ( db , encounterPath ) , { participants : updatedPs } ) ; }
catch ( err ) { console . error ( "Error toggling active state:" , err ) ; }
} ;
const handleHpInputChange = ( participantId , value ) => setHpChangeValues ( prev => ( { ... prev , [ participantId ] : value } ) ) ;
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 ] ;
if ( amountStr === undefined || amountStr . trim ( ) === '' ) return ;
const amount = parseInt ( amountStr , 10 ) ;
if ( isNaN ( amount ) || amount === 0 ) { setHpChangeValues ( prev => ( { ... prev , [ participantId ] : '' } ) ) ; return ; }
const pToChange = participants . find ( p => p . id === participantId ) ;
if ( ! pToChange ) return ;
let newHp = pToChange . currentHp ;
if ( changeType === 'damage' ) newHp = Math . max ( 0 , pToChange . currentHp - amount ) ;
else if ( changeType === 'heal' ) newHp = Math . min ( pToChange . maxHp , pToChange . currentHp + amount ) ;
const updatedPs = participants . map ( p => p . id === participantId ? { ... p , currentHp : newHp } : p ) ;
try {
await updateDoc ( doc ( db , encounterPath ) , { participants : updatedPs } ) ;
setHpChangeValues ( prev => ( { ... prev , [ participantId ] : '' } ) ) ;
} catch ( err ) { console . error ( "Error applying HP change:" , err ) ; }
} ;
const handleDragStart = ( e , id ) => {
setDraggedItemId ( id ) ;
e . dataTransfer . effectAllowed = 'move' ;
} ;
const handleDragOver = ( e ) => {
e . preventDefault ( ) ;
e . dataTransfer . dropEffect = 'move' ;
} ;
const handleDrop = async ( e , targetId ) => {
e . preventDefault ( ) ;
2025-05-25 22:21:45 -04:00
if ( ! db || draggedItemId === null || draggedItemId === targetId ) {
setDraggedItemId ( null ) ; return ;
2025-05-25 21:19:22 -04:00
}
const currentParticipants = [ ... participants ] ;
const draggedItemIndex = currentParticipants . findIndex ( p => p . id === draggedItemId ) ;
const targetItemIndex = currentParticipants . findIndex ( p => p . id === targetId ) ;
if ( draggedItemIndex === - 1 || targetItemIndex === - 1 ) {
setDraggedItemId ( null ) ; return ;
}
const draggedItem = currentParticipants [ draggedItemIndex ] ;
const targetItem = currentParticipants [ targetItemIndex ] ;
if ( draggedItem . initiative !== targetItem . initiative ) {
setDraggedItemId ( null ) ; return ;
}
const reorderedParticipants = [ ... currentParticipants ] ;
const [ removedItem ] = reorderedParticipants . splice ( draggedItemIndex , 1 ) ;
reorderedParticipants . splice ( targetItemIndex , 0 , removedItem ) ;
try {
await updateDoc ( doc ( db , encounterPath ) , { participants : reorderedParticipants } ) ;
} catch ( err ) { console . error ( "Error updating participants after drag-drop:" , err ) ; }
setDraggedItemId ( null ) ;
} ;
const handleDragEnd = ( ) => {
setDraggedItemId ( null ) ;
} ;
const sortedAdminParticipants = [ ... participants ] . sort ( ( a , b ) => {
if ( a . initiative === b . initiative ) {
const indexA = participants . findIndex ( p => p . id === a . id ) ;
const indexB = participants . findIndex ( p => p . id === b . id ) ;
return indexA - indexB ;
}
return b . initiative - a . initiative ;
} ) ;
const initiativeGroups = participants . reduce ( ( acc , p ) => {
acc [ p . initiative ] = ( acc [ p . initiative ] || 0 ) + 1 ;
return acc ;
} , { } ) ;
const tiedInitiatives = Object . keys ( initiativeGroups ) . filter ( init => initiativeGroups [ init ] > 1 ) . map ( Number ) ;
return (
< div className = "p-3 bg-slate-800 rounded-md mt-4" >
< h4 className = "text-lg font-medium text-sky-200 mb-3" > Participants < / h 4 >
< form onSubmit = { ( e ) => { e . preventDefault ( ) ; handleAddParticipant ( ) ; } } className = "grid grid-cols-1 md:grid-cols-2 gap-4 mb-4 p-3 bg-slate-700 rounded" >
< div >
< label className = "block text-sm font-medium text-slate-300" > Type < / l a b e l >
< select value = { participantType } onChange = { ( e ) => setParticipantType ( e . target . value ) } className = "mt-1 w-full px-3 py-2 bg-slate-600 border-slate-500 rounded text-white" >
< option value = "monster" > Monster < / o p t i o n >
< option value = "character" > Character < / o p t i o n >
< / s e l e c t >
< / d i v >
{ participantType === 'monster' && (
< div className = "md:col-span-2" >
< label className = "block text-sm font-medium text-slate-300" > Monster Name < / l a b e l >
< input type = "text" value = { participantName } onChange = { ( e ) => setParticipantName ( e . target . value ) } placeholder = "e.g., Dire Wolf" 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-sky-500 focus:border-sky-500 sm:text-sm text-white" / >
< / d i v >
) }
{ participantType === 'character' && ( < div > < label className = "block text-sm font-medium text-slate-300" > Select Character < / l a b e l > < s e l e c t v a l u e = { s e l e c t e d C h a r a c t e r I d } o n C h a n g e = { ( e ) = > s e t S e l e c t e d C h a r a c t e r I d ( e . t a r g e t . v a l u e ) } c l a s s N a m e = " m t - 1 w - f u l l p x - 3 p y - 2 b g - s l a t e - 6 0 0 b o r d e r - s l a t e - 5 0 0 r o u n d e d t e x t - w h i t e " > < o p t i o n v a l u e = " " > - - S e l e c t f r o m C a m p a i g n - - < / o p t i o n > { c a m p a i g n C h a r a c t e r s . m a p ( c = > < o p t i o n k e y = { c . i d } v a l u e = { c . i d } > { c . n a m e } < / o p t i o n > ) } < / s e l e c t > < / d i v > ) }
< div > < label className = "block text-sm font-medium text-slate-300" > Initiative < /label><input type="number" value={initiative} onChange={(e) => setInitiative(e.target.value)} 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-sky-500 focus:border-sky-500 sm:text-sm text-white" / > < / d i v >
< div > < label className = "block text-sm font-medium text-slate-300" > Max HP < /label><input type="number" value={maxHp} onChange={(e) => setMaxHp(e.target.value)} 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-sky-500 focus:border-sky-500 sm:text-sm text-white" / > < / d i v >
< div className = "md:col-span-2 flex justify-end" >
< 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 flex items-center" >
< PlusCircle size = { 18 } className = "mr-1" / > Add to Encounter
< / b u t t o n >
< / d i v >
< / f o r m >
{ participants . length === 0 && < p className = "text-sm text-slate-400" > No participants . < / p > }
< ul className = "space-y-2" >
{ sortedAdminParticipants . map ( ( p , index ) => {
const isCurrentTurn = encounter . isStarted && p . id === encounter . currentTurnParticipantId ;
2025-05-25 22:21:45 -04:00
const isDraggable = ! encounter . isStarted && tiedInitiatives . includes ( Number ( p . initiative ) ) ; // Ensure p.initiative is number for comparison
2025-05-25 21:19:22 -04: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 = { isDraggable ? handleDragEnd : undefined }
className = { ` p-3 rounded-md flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 transition-all
$ { isCurrentTurn ? 'bg-green-600 ring-2 ring-green-300 shadow-lg' : ( p . type === 'character' ? 'bg-sky-800' : 'bg-red-800' ) }
$ { ! p . isActive ? 'opacity-50' : '' }
$ { isDraggable ? 'cursor-grab' : '' }
$ { draggedItemId === p . id ? 'opacity-50 ring-2 ring-yellow-400' : '' }
` }
>
< div className = "flex-1 flex items-center" >
2025-05-25 22:21:45 -04:00
{ isDraggable && < ChevronsUpDown size = { 18 } className = "mr-2 text-slate-400 flex-shrink-0" title = "Drag to reorder in tie" / > }
2025-05-25 21:19:22 -04:00
< div >
< p className = { ` font-semibold text-lg ${ isCurrentTurn ? 'text-white' : 'text-white' } ` } > { p . name } < span className = "text-xs" > ( { p . type } ) < /span>{isCurrentTurn && <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 < / s p a n > } < / p >
< p className = { ` text-sm ${ isCurrentTurn ? 'text-green-100' : 'text-slate-200' } ` } > Init : { p . initiative } | HP : { p . currentHp } / { p . maxHp } < / p >
< / d i v >
< / d i v >
< div className = "flex flex-wrap items-center space-x-2 mt-2 sm:mt-0" >
{ encounter . isStarted && p . isActive && ( < 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 ) => handleHpInputChange ( p . id , e . target . value ) } className = "w-16 p-1 text-sm bg-slate-600 border border-slate-500 rounded-md text-white focus:ring-sky-500 focus:border-sky-500" aria - label = { ` HP change for ${ p . name } ` } / > < 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="Heal"><HeartPulse size={16}/ > < / b u t t o n > < / d i v > ) }
< button onClick = { ( ) => toggleParticipantActive ( p . id ) } className = { ` p-1 rounded transition-colors ${ p . isActive ? 'text-yellow-400 hover:text-yellow-300 bg-slate-600 hover:bg-slate-500' : '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 } / > } < / b u t t o n >
< 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 } / > < / b u t t o n >
< button onClick = { ( ) => handleDeleteParticipant ( p . id ) } 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 } / > < / b u t t o n >
< / d i v >
< / l i >
) ;
} ) }
< / u l >
{ editingParticipant && < EditParticipantModal participant = { editingParticipant } onClose = { ( ) => setEditingParticipant ( null ) } onSave = { handleUpdateParticipant } / > }
< / d i v >
) ;
}
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 handleSubmit = ( e ) => {
e . preventDefault ( ) ;
onSave ( {
name : name . trim ( ) , initiative : parseInt ( initiative , 10 ) ,
currentHp : parseInt ( currentHp , 10 ) , maxHp : parseInt ( maxHp , 10 ) ,
} ) ;
} ;
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)} 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-sky-500 focus:border-sky-500 sm:text-sm text-white" / > < / d i v >
< div > < label className = "block text-sm font-medium text-slate-300" > Initiative < /label><input type="number" value={initiative} onChange={(e) => setInitiative(e.target.value)} 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-sky-500 focus:border-sky-500 sm:text-sm text-white" / > < / d i v >
< 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)} 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-sky-500 focus:border-sky-500 sm:text-sm text-white" / > < / d i v >
< 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)} 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-sky-500 focus:border-sky-500 sm:text-sm text-white" / > < / d i v >
< / d i v >
< 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 < / b u t t o n >
< button type = "submit" className = "px-4 py-2 text-sm font-medium text-white bg-sky-500 hover:bg-sky-600 rounded-md transition-colors" > < Save size = { 18 } className = "mr-1 inline-block" / > Save < / b u t t o n >
< / d i v >
< / f o r m >
< / M o d a l >
) ;
}
function InitiativeControls ( { campaignId , encounter , encounterPath } ) {
const handleStartEncounter = async ( ) => {
2025-05-25 22:21:45 -04:00
if ( ! db || ! encounter . participants || encounter . participants . length === 0 ) { alert ( "Add participants first." ) ; return ; }
2025-05-25 21:19:22 -04:00
const activePs = encounter . participants . filter ( p => p . isActive ) ;
if ( activePs . length === 0 ) { alert ( "No active participants." ) ; return ; }
const sortedPs = [ ... activePs ] . sort ( ( a , b ) => {
if ( a . initiative === b . initiative ) {
const indexA = encounter . participants . findIndex ( p => p . id === a . id ) ;
const indexB = encounter . participants . findIndex ( p => p . id === b . id ) ;
return indexA - indexB ;
}
return b . initiative - a . initiative ;
} ) ;
try {
await updateDoc ( doc ( db , encounterPath ) , {
isStarted : true , round : 1 , currentTurnParticipantId : sortedPs [ 0 ] . id , turnOrderIds : sortedPs . map ( p => p . id )
} ) ;
await setDoc ( doc ( db , ACTIVE _DISPLAY _DOC ) , {
activeCampaignId : campaignId ,
activeEncounterId : encounter . id
} , { merge : true } ) ;
console . log ( "Encounter started and set as active display." ) ;
} catch ( err ) { console . error ( "Error starting encounter:" , err ) ; }
} ;
const handleNextTurn = async ( ) => {
2025-05-25 22:21:45 -04:00
if ( ! db || ! encounter . isStarted || ! encounter . currentTurnParticipantId || ! encounter . turnOrderIds || encounter . turnOrderIds . length === 0 ) return ;
2025-05-25 21:19:22 -04:00
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 , currentTurnParticipantId : null , round : encounter . round } ) ; return ;
}
const currentIndex = activePsInOrder . findIndex ( p => p . id === encounter . currentTurnParticipantId ) ;
let nextIndex = ( currentIndex + 1 ) % activePsInOrder . length ;
let nextRound = encounter . round ;
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 ) ; }
} ;
const handleEndEncounter = async ( ) => {
2025-05-25 22:21:45 -04:00
if ( ! db ) return ;
2025-05-25 21:19:22 -04:00
// TODO: Implement custom confirmation modal for ending encounter
console . warn ( "Attempting to end encounter without confirmation" ) ;
try {
await updateDoc ( doc ( db , encounterPath ) , { isStarted : false , currentTurnParticipantId : null , round : 0 , turnOrderIds : [ ] } ) ;
} catch ( err ) { console . error ( "Error ending encounter:" , err ) ; }
} ;
if ( ! encounter || ! encounter . participants ) return null ;
return (
< div className = "mt-6 p-3 bg-slate-800 rounded-md" >
< h4 className = "text-lg font-medium text-sky-200 mb-3" > Combat Controls < / h 4 >
< div className = "flex flex-wrap gap-3" >
{ ! encounter . isStarted ? (
< button onClick = { handleStartEncounter } className = "px-4 py-2 text-sm font-medium text-white bg-green-600 hover:bg-green-700 rounded-md transition-colors flex items-center" disabled = { ! encounter . participants || encounter . participants . filter ( p => p . isActive ) . length === 0 } > < PlayIcon size = { 18 } className = "mr-2" / > Start < / b u t t o n >
) : (
< >
< button onClick = { handleNextTurn } className = "px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition-colors flex items-center" disabled = { ! encounter . currentTurnParticipantId } > < SkipForwardIcon size = { 18 } className = "mr-2" / > Next Turn < / b u t t o n >
< button onClick = { handleEndEncounter } className = "px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-md transition-colors flex items-center" > < StopCircleIcon size = { 18 } className = "mr-2" / > End < / b u t t o n >
< p className = "text-slate-300 self-center" > Round : { encounter . round } < / p >
< / >
) }
< / d i v >
< / d i v >
) ;
}
function DisplayView ( { campaignIdFromUrl , encounterIdFromUrl } ) {
const [ activeEncounterData , setActiveEncounterData ] = useState ( null ) ;
const [ isLoading , setIsLoading ] = useState ( true ) ;
const [ error , setError ] = useState ( null ) ;
useEffect ( ( ) => {
2025-05-25 22:21:45 -04:00
if ( ! db ) {
setError ( "Firestore not available." ) ; setIsLoading ( false ) ; return ;
}
2025-05-25 21:19:22 -04:00
setIsLoading ( true ) ; setError ( null ) ; setActiveEncounterData ( null ) ;
let unsubscribeEncounter ;
if ( campaignIdFromUrl && encounterIdFromUrl ) {
const encounterPath = ` ${ CAMPAIGNS _COLLECTION } / ${ campaignIdFromUrl } /encounters/ ${ encounterIdFromUrl } ` ;
unsubscribeEncounter = onSnapshot ( doc ( db , encounterPath ) , ( encDocSnap ) => {
if ( encDocSnap . exists ( ) ) setActiveEncounterData ( { id : encDocSnap . id , ... encDocSnap . data ( ) } ) ;
else setError ( "The requested encounter was not found or is not accessible." ) ;
setIsLoading ( false ) ;
} , ( err ) => { console . error ( "Error fetching specific encounter for display:" , err ) ; setError ( "Error loading encounter data from link." ) ; setIsLoading ( false ) ; } ) ;
} else {
const activeDisplayRef = doc ( db , ACTIVE _DISPLAY _DOC ) ;
const unsubDisplayConfig = onSnapshot ( activeDisplayRef , async ( docSnap ) => {
if ( docSnap . exists ( ) ) {
const { activeCampaignId , activeEncounterId } = docSnap . data ( ) ;
if ( activeCampaignId && activeEncounterId ) {
const encounterPath = ` ${ CAMPAIGNS _COLLECTION } / ${ activeCampaignId } /encounters/ ${ activeEncounterId } ` ;
if ( unsubscribeEncounter ) unsubscribeEncounter ( ) ;
unsubscribeEncounter = onSnapshot ( doc ( db , encounterPath ) , ( encDocSnap ) => {
if ( encDocSnap . exists ( ) ) setActiveEncounterData ( { id : encDocSnap . id , ... encDocSnap . data ( ) } ) ;
else { setActiveEncounterData ( null ) ; setError ( "Active encounter not found. The DM might have deleted it." ) ; }
setIsLoading ( false ) ;
} , ( err ) => { console . error ( "Error fetching active encounter details:" , err ) ; setError ( "Error loading active encounter data." ) ; setIsLoading ( false ) ; } ) ;
} else { setActiveEncounterData ( null ) ; setIsLoading ( false ) ; }
} else { setActiveEncounterData ( null ) ; setIsLoading ( false ) ; }
} , ( err ) => { console . error ( "Error fetching active display config:" , err ) ; setError ( "Could not load display configuration." ) ; setIsLoading ( false ) ; } ) ;
return ( ) => { unsubDisplayConfig ( ) ; if ( unsubscribeEncounter ) unsubscribeEncounter ( ) ; } ;
}
return ( ) => { if ( unsubscribeEncounter ) unsubscribeEncounter ( ) ; } ;
} , [ campaignIdFromUrl , encounterIdFromUrl ] ) ;
if ( isLoading ) return < div className = "text-center py-10 text-2xl text-slate-300" > Loading Player Display ... < / d i v > ;
if ( error ) return < div className = "text-center py-10 text-2xl text-red-400" > { error } < / d i v > ;
if ( ! activeEncounterData ) return < div className = "text-center py-10 text-3xl text-slate-400" > No active encounter to display . < / d i v > ;
const { name , participants , round , currentTurnParticipantId , isStarted } = activeEncounterData ;
let displayParticipants = [ ] ;
2025-05-25 22:21:45 -04:00
if ( participants ) { // Ensure participants array exists before trying to sort/filter
if ( isStarted && activeEncounterData . turnOrderIds ? . length > 0 ) {
displayParticipants = activeEncounterData . turnOrderIds
. map ( id => participants . find ( p => p . id === id ) ) . filter ( p => p && p . isActive ) ;
} else {
displayParticipants = [ ... participants ] . filter ( p => p . isActive )
. sort ( ( a , b ) => {
if ( a . initiative === b . initiative ) {
const indexA = participants . findIndex ( p => p . id === a . id ) ;
const indexB = participants . findIndex ( p => p . id === b . id ) ;
return indexA - indexB ;
}
return b . initiative - a . initiative ;
} ) ;
}
2025-05-25 21:19:22 -04:00
}
2025-05-25 22:21:45 -04:00
2025-05-25 21:19:22 -04:00
return (
< div className = "p-4 md:p-8 bg-slate-900 rounded-xl shadow-2xl" >
< h2 className = "text-4xl md:text-5xl font-bold text-center text-amber-400 mb-2" > { name } < / h 2 >
{ isStarted && < p className = "text-2xl text-center text-sky-300 mb-6" > Round : { round } < / 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 > }
{ displayParticipants . length === 0 && isStarted && < p className = "text-xl text-slate-400" > No active participants . < / p > }
< div className = "space-y-4 max-w-3xl mx-auto" >
{ displayParticipants . map ( p => (
< div key = { p . id } className = { ` p-4 md:p-6 rounded-lg shadow-lg transition-all ${ p . id === currentTurnParticipantId && isStarted ? 'bg-green-700 ring-4 ring-green-400 scale-105' : ( p . type === 'character' ? 'bg-sky-700' : 'bg-red-700' ) } ${ ! p . isActive ? 'opacity-40 grayscale' : '' } ` } >
< div className = "flex justify-between items-center mb-2" >
< h3 className = { ` text-2xl md:text-3xl font-bold ${ p . id === currentTurnParticipantId && isStarted ? 'text-white' : ( p . type === 'character' ? 'text-sky-100' : 'text-red-100' ) } ` } > { p . name } { p . id === currentTurnParticipantId && isStarted && < span className = "text-yellow-300 animate-pulse ml-2" > ( Current ) < / s p a n > } < / h 3 >
< span className = { ` text-xl md:text-2xl font-semibold ${ p . id === currentTurnParticipantId && isStarted ? 'text-green-200' : 'text-slate-200' } ` } > Init : { p . initiative } < / s p a n >
< / d i v >
< 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 ${ 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 ) } % ` } } > < / d i v >
2025-05-25 22:21:45 -04:00
{ p . type !== 'monster' && (
2025-05-25 21:19:22 -04:00
< span className = "absolute inset-0 flex items-center justify-center text-xs md:text-sm font-medium text-white mix-blend-difference px-2" >
HP : { p . currentHp } / { p . maxHp }
< / s p a n >
) }
< / d i v >
< / d i v >
{ p . conditions ? . length > 0 && < p className = "text-sm text-yellow-300 mt-2" > Conditions : { p . conditions . join ( ', ' ) } < / p > }
{ ! p . isActive && < p className = "text-center text-lg font-semibold text-slate-300 mt-2" > ( Inactive ) < / p > }
< / d i v >
) ) }
< / d i v >
< / d i v >
) ;
}
function Modal ( { onClose , title , children } ) {
useEffect ( ( ) => {
const handleEsc = ( event ) => { if ( event . key === 'Escape' ) onClose ( ) ; } ;
window . addEventListener ( 'keydown' , handleEsc ) ;
return ( ) => window . removeEventListener ( 'keydown' , handleEsc ) ;
} , [ onClose ] ) ;
return (
< 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" >
< h2 className = "text-xl font-semibold text-sky-300" > { title } < / h 2 >
< button onClick = { onClose } className = "text-slate-400 hover:text-slate-200" > < XCircle size = { 24 } / > < / b u t t o n >
< / d i v >
{ children }
< / d i v >
< / d i v >
) ;
}
const PlayIcon = ( { size = 24 , className = '' } ) => < svg xmlns = "http://www.w3.org/2000/svg" width = { size } height = { size } viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" strokeWidth = "2" strokeLinecap = "round" strokeLinejoin = "round" className = { className } > < polygon points = "5 3 19 12 5 21 5 3" > < / p o l y g o n > < / s v g > ;
const SkipForwardIcon = ( { size = 24 , className = '' } ) => < svg xmlns = "http://www.w3.org/2000/svg" width = { size } height = { size } viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" strokeWidth = "2" strokeLinecap = "round" strokeLinejoin = "round" className = { className } > < polygon points = "5 4 15 12 5 20 5 4" > < / p o l y g o n > < l i n e x 1 = " 1 9 " y 1 = " 5 " x 2 = " 1 9 " y 2 = " 1 9 " > < / l i n e > < / s v g > ;
const StopCircleIcon = ( { size = 24 , className = '' } ) => < svg xmlns = "http://www.w3.org/2000/svg" width = { size } height = { size } viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" strokeWidth = "2" strokeLinecap = "round" strokeLinejoin = "round" className = { className } > < circle cx = "12" cy = "12" r = "10" > < / c i r c l e > < r e c t x = " 9 " y = " 9 " w i d t h = " 6 " h e i g h t = " 6 " > < / r e c t > < / s v g > ;
2025-05-25 22:21:45 -04:00
export default App ;