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-05-26 22:42:37 -04:00
import { PlusCircle , Users , Swords , Trash2 , Eye , Edit3 , Save , XCircle , ChevronsUpDown , UserCheck , UserX , HeartCrack , HeartPulse , Zap , EyeOff , ExternalLink , AlertTriangle , Play as PlayIcon , Pause as PauseIcon , SkipForward as SkipForwardIcon , StopCircle as StopCircleIcon , Users2 , Dices } from 'lucide-react' ; // ImageIcon was already removed, ensuring it stays removed.
2025-05-25 21:19:22 -04:00
// --- 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." ) ;
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 ) ;
}
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 ` ;
2025-05-26 08:33:39 -04:00
// --- Firestore Path Helpers ---
const getCampaignsCollectionPath = ( ) => ` ${ PUBLIC _DATA _PATH } /campaigns ` ;
const getCampaignDocPath = ( campaignId ) => ` ${ PUBLIC _DATA _PATH } /campaigns/ ${ campaignId } ` ;
const getEncountersCollectionPath = ( campaignId ) => ` ${ PUBLIC _DATA _PATH } /campaigns/ ${ campaignId } /encounters ` ;
const getEncounterDocPath = ( campaignId , encounterId ) => ` ${ PUBLIC _DATA _PATH } /campaigns/ ${ campaignId } /encounters/ ${ encounterId } ` ;
const getActiveDisplayDocPath = ( ) => ` ${ PUBLIC _DATA _PATH } /activeDisplay/status ` ;
2025-05-25 21:19:22 -04:00
// --- Helper Functions ---
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-05-26 08:33:39 -04:00
// --- Custom Hooks for Firestore ---
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 ;
}
setIsLoading ( true ) ;
setError ( null ) ;
const docRef = doc ( db , docPath ) ;
const unsubscribe = onSnapshot ( docRef , ( docSnap ) => {
if ( docSnap . exists ( ) ) {
setData ( { id : docSnap . id , ... docSnap . data ( ) } ) ;
} else {
setData ( null ) ;
}
setIsLoading ( false ) ;
} , ( err ) => {
console . error ( ` Error fetching document ${ docPath } : ` , err ) ;
setError ( err . message || "Failed to fetch document." ) ;
setIsLoading ( false ) ;
setData ( null ) ;
} ) ;
return ( ) => unsubscribe ( ) ;
} , [ docPath ] ) ;
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 22:42:37 -04:00
// Memoize the stringified queryConstraints. This is the key to stabilizing the effect's dependency.
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 ;
}
setIsLoading ( true ) ;
setError ( null ) ;
2025-05-26 22:42:37 -04:00
// queryConstraints is used here to build the query.
2025-05-26 21:17:42 -04:00
const q = query ( collection ( db , collectionPath ) , ... queryConstraints ) ;
2025-05-26 22:42:37 -04:00
2025-05-26 08:33:39 -04:00
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 22:42:37 -04:00
2025-05-26 08:33:39 -04:00
return ( ) => unsubscribe ( ) ;
2025-05-26 22:42:37 -04:00
// The effect depends on collectionPath and the memoized queryString.
// This prevents re-running the effect if queryConstraints is a new array reference
// but its content (and thus queryString) is the same.
// eslint-disable-next-line react-hooks/exhaustive-deps
2025-05-26 21:17:42 -04:00
} , [ collectionPath , queryString ] ) ;
2025-05-26 22:42:37 -04:00
2025-05-26 08:33:39 -04:00
return { data , isLoading , error } ;
}
2025-05-25 21:19:22 -04:00
// --- Main App Component ---
function App ( ) {
2025-05-26 09:52:53 -04:00
const [ userId , setUserId ] = useState ( null ) ;
2025-05-25 21:19:22 -04:00
const [ isAuthReady , setIsAuthReady ] = useState ( false ) ;
const [ isLoading , setIsLoading ] = useState ( true ) ;
const [ error , setError ] = useState ( null ) ;
2025-05-26 07:59:05 -04:00
const [ isPlayerViewOnlyMode , setIsPlayerViewOnlyMode ] = useState ( false ) ;
2025-05-25 21:19:22 -04:00
useEffect ( ( ) => {
2025-05-26 07:59:05 -04:00
const queryParams = new URLSearchParams ( window . location . search ) ;
if ( queryParams . get ( 'playerView' ) === 'true' ) {
setIsPlayerViewOnlyMode ( true ) ;
}
2025-05-25 23:28:36 -04:00
if ( ! auth ) {
2025-05-25 22:21:45 -04:00
setError ( "Firebase Auth not initialized. Check your Firebase configuration." ) ;
setIsLoading ( false ) ;
2025-05-25 23:28:36 -04:00
setIsAuthReady ( false ) ;
2025-05-25 22:21:45 -04:00
return ;
}
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." ) ;
}
} ;
const unsubscribe = onAuthStateChanged ( auth , ( user ) => {
setUserId ( user ? user . uid : null ) ;
setIsAuthReady ( true ) ;
setIsLoading ( false ) ;
} ) ;
initAuth ( ) ;
2025-05-26 21:34:37 -04:00
return ( ) => unsubscribe ( ) ;
2025-05-25 21:19:22 -04:00
} , [ ] ) ;
2025-05-25 23:28:36 -04:00
if ( ! db || ! auth ) {
2025-05-25 22:21:45 -04:00
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 >
) ;
}
2025-05-26 07:59:05 -04:00
const openPlayerWindow = ( ) => {
const playerViewUrl = window . location . origin + window . location . pathname + '?playerView=true' ;
2025-05-26 08:18:13 -04:00
window . open ( playerViewUrl , '_blank' , 'noopener,noreferrer,width=1024,height=768' ) ;
2025-05-25 23:28:36 -04:00
} ;
2025-05-26 07:59:05 -04:00
if ( isPlayerViewOnlyMode ) {
return (
< div className = "min-h-screen bg-slate-800 text-slate-100 font-sans" >
{ isAuthReady && < DisplayView / > }
{ ! isAuthReady && ! error && < p > Authenticating for Player Display ... < / p > }
< / d i v >
) ;
}
2025-05-25 21:19:22 -04:00
return (
< div className = "min-h-screen bg-slate-800 text-slate-100 font-sans" >
< header className = "bg-slate-900 p-4 shadow-lg" >
< div className = "container mx-auto flex justify-between items-center" >
2025-05-26 21:34:37 -04:00
< h1 className = "text-3xl font-bold text-sky-400" > TTRPG Initiative Tracker < / h 1 >
2025-05-25 21:19:22 -04:00
< div className = "flex items-center space-x-4" >
2025-05-25 22:21:45 -04:00
< button
2025-05-26 07:59:05 -04:00
onClick = { openPlayerWindow }
className = { ` px-4 py-2 rounded-md text-sm font-medium transition-colors bg-teal-500 hover:bg-teal-600 text-white flex items-center ` }
2025-05-25 22:21:45 -04:00
>
2025-05-26 07:59:05 -04:00
< ExternalLink size = { 16 } className = "mr-2" / > Open Player Window
2025-05-25 22:21:45 -04:00
< / 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 >
2025-05-26 07:50:24 -04:00
< main className = { ` container mx-auto p-4 md:p-8 ` } >
2025-05-26 07:59:05 -04:00
{ isAuthReady && userId && < AdminView userId = { userId } / > }
2025-05-25 23:28:36 -04:00
{ ! isAuthReady && ! error && < p > Authenticating ... < / p > }
2025-05-25 21:19:22 -04:00
< / m a i n >
2025-05-26 07:50:24 -04:00
< footer className = "bg-slate-900 p-4 text-center text-sm text-slate-400 mt-8" >
2025-05-26 22:42:37 -04:00
TTRPG Initiative Tracker v0 . 1.30
2025-05-26 07:50:24 -04:00
< / f o o t e r >
2025-05-25 21:19:22 -04:00
< / d i v >
) ;
}
2025-05-26 09:41:50 -04:00
// --- Confirmation Modal Component ---
2025-05-26 22:42:37 -04:00
// ... (ConfirmationModal remains the same)
2025-05-26 09:41:50 -04:00
function ConfirmationModal ( { isOpen , onClose , onConfirm , title , message } ) {
if ( ! isOpen ) return null ;
return (
< div className = "fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center p-4 z-50 transition-opacity duration-300 ease-in-out" >
< div className = "bg-slate-800 p-6 rounded-lg shadow-xl w-full max-w-md transform transition-all duration-300 ease-in-out scale-100 opacity-100" >
< 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" } < / h 2 >
< / d i v >
< 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-05-26 21:34:37 -04: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 < / b u t t o n >
< 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 < / b u t t o n >
2025-05-26 09:41:50 -04:00
< / d i v >
< / d i v >
< / d i v >
) ;
}
2025-05-25 21:19:22 -04:00
// --- Admin View Component ---
2025-05-26 22:42:37 -04:00
// ... (AdminView remains the same)
2025-05-25 21:19:22 -04:00
function AdminView ( { userId } ) {
2025-05-26 08:33:39 -04:00
const { data : campaignsData , isLoading : isLoadingCampaigns } = useFirestoreCollection ( getCampaignsCollectionPath ( ) ) ;
const { data : initialActiveInfoData } = useFirestoreDocument ( getActiveDisplayDocPath ( ) ) ;
2025-05-25 21:19:22 -04:00
const [ campaigns , setCampaigns ] = useState ( [ ] ) ;
const [ selectedCampaignId , setSelectedCampaignId ] = useState ( null ) ;
const [ showCreateCampaignModal , setShowCreateCampaignModal ] = useState ( false ) ;
2025-05-26 09:41:50 -04:00
const [ showDeleteCampaignConfirm , setShowDeleteCampaignConfirm ] = useState ( false ) ;
2025-05-26 09:52:53 -04:00
const [ itemToDelete , setItemToDelete ] = useState ( null ) ;
2025-05-25 21:19:22 -04:00
useEffect ( ( ) => {
2025-05-26 08:33:39 -04:00
if ( campaignsData ) {
setCampaigns ( campaignsData . map ( c => ( { ... c , characters : c . players || [ ] } ) ) ) ;
}
} , [ campaignsData ] ) ;
2025-05-25 21:19:22 -04:00
useEffect ( ( ) => {
2025-05-26 08:33:39 -04:00
if ( initialActiveInfoData && initialActiveInfoData . activeCampaignId && campaigns . length > 0 && ! selectedCampaignId ) {
const campaignExists = campaigns . some ( c => c . id === initialActiveInfoData . activeCampaignId ) ;
2025-05-25 21:19:22 -04:00
if ( campaignExists ) {
2025-05-26 08:33:39 -04:00
setSelectedCampaignId ( initialActiveInfoData . activeCampaignId ) ;
2025-05-25 21:19:22 -04:00
}
}
2025-05-26 08:33:39 -04:00
} , [ initialActiveInfoData , campaigns , selectedCampaignId ] ) ;
2025-05-25 21:19:22 -04:00
2025-05-25 23:28:36 -04:00
const handleCreateCampaign = async ( name , backgroundUrl ) => {
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 {
2025-05-26 08:33:39 -04:00
await setDoc ( doc ( db , getCampaignDocPath ( newCampaignId ) ) , {
2025-05-25 23:28:36 -04:00
name : name . trim ( ) ,
playerDisplayBackgroundUrl : backgroundUrl . trim ( ) || '' ,
ownerId : userId ,
createdAt : new Date ( ) . toISOString ( ) ,
players : [ ] ,
2025-05-25 21:19:22 -04:00
} ) ;
setShowCreateCampaignModal ( false ) ;
setSelectedCampaignId ( newCampaignId ) ;
} catch ( err ) { console . error ( "Error creating campaign:" , err ) ; }
} ;
2025-05-26 09:41:50 -04:00
const requestDeleteCampaign = ( campaignId , campaignName ) => {
setItemToDelete ( { id : campaignId , name : campaignName , type : 'campaign' } ) ;
setShowDeleteCampaignConfirm ( true ) ;
} ;
const confirmDeleteCampaign = async ( ) => {
if ( ! db || ! itemToDelete || itemToDelete . type !== 'campaign' ) return ;
const campaignId = itemToDelete . id ;
2025-05-25 21:19:22 -04:00
try {
2025-05-26 08:33:39 -04:00
const encountersPath = getEncountersCollectionPath ( campaignId ) ;
2025-05-25 21:19:22 -04:00
const encountersSnapshot = await getDocs ( collection ( db , encountersPath ) ) ;
const batch = writeBatch ( db ) ;
encountersSnapshot . docs . forEach ( encounterDoc => batch . delete ( encounterDoc . ref ) ) ;
await batch . commit ( ) ;
2025-05-26 08:33:39 -04:00
await deleteDoc ( doc ( db , getCampaignDocPath ( campaignId ) ) ) ;
2025-05-25 21:19:22 -04:00
if ( selectedCampaignId === campaignId ) setSelectedCampaignId ( null ) ;
2025-05-26 08:33:39 -04:00
const activeDisplayRef = doc ( db , getActiveDisplayDocPath ( ) ) ;
2025-05-25 21:19:22 -04:00
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 ) ; }
2025-05-26 09:41:50 -04:00
setShowDeleteCampaignConfirm ( false ) ;
setItemToDelete ( null ) ;
2025-05-25 21:19:22 -04:00
} ;
const selectedCampaign = campaigns . find ( c => c . id === selectedCampaignId ) ;
2025-05-26 08:33:39 -04:00
if ( isLoadingCampaigns ) {
return < p className = "text-center text-slate-300" > Loading campaigns ... < / p > ;
}
2025-05-25 21:19:22 -04:00
return (
2025-05-26 09:41:50 -04:00
< >
< 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" >
2025-05-26 09:52:53 -04:00
{ campaigns . map ( campaign => {
2025-05-26 21:34:37 -04:00
const cardStyle = campaign . playerDisplayBackgroundUrl ? { backgroundImage : ` url( ${ campaign . playerDisplayBackgroundUrl } ) ` } : { } ;
2025-05-26 09:52:53 -04:00
const cardClasses = ` p-4 rounded-lg shadow-md cursor-pointer transition-all relative overflow-hidden bg-cover bg-center ${ selectedCampaignId === campaign . id ? 'ring-4 ring-sky-400' : '' } ${ ! campaign . playerDisplayBackgroundUrl ? 'bg-slate-700 hover:bg-slate-600' : 'hover:shadow-xl' } ` ;
return (
2025-05-26 21:34:37 -04:00
< div key = { campaign . id } onClick = { ( ) => setSelectedCampaignId ( campaign . id ) } className = { cardClasses } style = { cardStyle } >
2025-05-26 09:52:53 -04:00
< div className = { ` relative z-10 ${ campaign . playerDisplayBackgroundUrl ? 'bg-black bg-opacity-60 p-3 rounded-md' : '' } ` } >
< h3 className = "text-xl font-semibold text-white" > { campaign . name } < / h 3 >
2025-05-26 22:42:37 -04:00
{ /* ImageIcon display removed from here */ }
2025-05-26 21:34:37 -04:00
< button onClick = { ( e ) => { e . stopPropagation ( ) ; requestDeleteCampaign ( campaign . id , campaign . name ) ; } } className = "mt-2 text-red-300 hover:text-red-100 text-xs flex items-center bg-black bg-opacity-50 hover:bg-opacity-70 px-2 py-1 rounded" > < Trash2 size = { 14 } className = "mr-1" / > Delete < / b u t t o n >
2025-05-26 09:52:53 -04:00
< / d i v >
2025-05-26 21:34:37 -04:00
< / d i v > ) ;
2025-05-26 09:52:53 -04:00
} ) }
2025-05-26 09:41:50 -04:00
< / d i v >
2025-05-25 21:19:22 -04:00
< / d i v >
2025-05-26 09:41:50 -04:00
{ 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" / >
2025-05-26 21:34:37 -04:00
< EncounterManager campaignId = { selectedCampaignId } initialActiveEncounterId = { initialActiveInfoData && initialActiveInfoData . activeCampaignId === selectedCampaignId ? initialActiveInfoData . activeEncounterId : null } campaignCharacters = { selectedCampaign . players || [ ] } / >
2025-05-26 09:41:50 -04:00
< / d i v >
) }
2025-05-25 21:19:22 -04:00
< / d i v >
2025-05-26 21:34:37 -04:00
< ConfirmationModal isOpen = { showDeleteCampaignConfirm } onClose = { ( ) => setShowDeleteCampaignConfirm ( 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. ` } / >
2025-05-26 09:41:50 -04:00
< / >
2025-05-25 21:19:22 -04:00
) ;
}
2025-05-26 22:42:37 -04:00
// --- CreateCampaignForm, CharacterManager, EncounterManager, CreateEncounterForm, ParticipantManager, EditParticipantModal, InitiativeControls, DisplayView, Modal, Icons ---
// These components are identical to the previous version (v0.1.24) and are included below for completeness.
// The changes for ESLint warnings were primarily in the imports at the top of App.js and in the useFirestoreCollection hook.
2025-05-25 21:19:22 -04:00
function CreateCampaignForm ( { onCreate , onCancel } ) {
const [ name , setName ] = useState ( '' ) ;
2025-05-25 23:28:36 -04:00
const [ backgroundUrl , setBackgroundUrl ] = useState ( '' ) ;
2025-05-26 21:34:37 -04:00
const handleSubmit = ( e ) => { e . preventDefault ( ) ; 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 >
< 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 >
2025-05-25 23:28:36 -04:00
< div >
< label htmlFor = "backgroundUrl" className = "block text-sm font-medium text-slate-300" > Player Display Background URL ( Optional ) < / l a b e l >
< input type = "url" id = "backgroundUrl" value = { backgroundUrl } onChange = { ( e ) => setBackgroundUrl ( e . target . value ) } placeholder = "https://example.com/image.jpg" 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 >
2025-05-25 21:19:22 -04:00
< 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 >
) ;
}
2025-05-26 22:42:37 -04:00
2025-05-25 21:19:22 -04:00
function CharacterManager ( { campaignId , campaignCharacters } ) {
const [ characterName , setCharacterName ] = useState ( '' ) ;
2025-05-26 22:31:43 -04:00
const [ defaultMaxHp , setDefaultMaxHp ] = useState ( 10 ) ;
const [ defaultInitMod , setDefaultInitMod ] = useState ( 0 ) ;
const [ editingCharacter , setEditingCharacter ] = useState ( null ) ;
2025-05-26 09:41:50 -04:00
const [ showDeleteCharConfirm , setShowDeleteCharConfirm ] = useState ( false ) ;
2025-05-26 09:52:53 -04:00
const [ itemToDelete , setItemToDelete ] = useState ( null ) ;
2025-05-25 21:19:22 -04:00
const handleAddCharacter = async ( ) => {
2025-05-25 22:21:45 -04:00
if ( ! db || ! characterName . trim ( ) || ! campaignId ) return ;
2025-05-26 21:48:28 -04:00
const hp = parseInt ( defaultMaxHp , 10 ) ;
2025-05-26 22:31:43 -04:00
const initMod = parseInt ( defaultInitMod , 10 ) ;
2025-05-26 22:42:37 -04:00
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 ; }
2025-05-26 22:31:43 -04:00
const newCharacter = { id : generateId ( ) , name : characterName . trim ( ) , defaultMaxHp : hp , defaultInitMod : initMod } ;
2025-05-26 22:42:37 -04:00
try { await updateDoc ( doc ( db , getCampaignDocPath ( campaignId ) ) , { players : [ ... campaignCharacters , newCharacter ] } ) ; setCharacterName ( '' ) ; setDefaultMaxHp ( 10 ) ; setDefaultInitMod ( 0 ) ; } catch ( err ) { console . error ( "Error adding character:" , err ) ; }
2025-05-25 21:19:22 -04:00
} ;
2025-05-26 22:31:43 -04:00
const handleUpdateCharacter = async ( characterId , newName , newDefaultMaxHp , newDefaultInitMod ) => {
2025-05-25 22:21:45 -04:00
if ( ! db || ! newName . trim ( ) || ! campaignId ) return ;
2025-05-26 21:48:28 -04:00
const hp = parseInt ( newDefaultMaxHp , 10 ) ;
2025-05-26 22:31:43 -04:00
const initMod = parseInt ( newDefaultInitMod , 10 ) ;
2025-05-26 22:42:37 -04:00
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 ; }
2025-05-26 22:31:43 -04:00
const updatedCharacters = campaignCharacters . map ( c => c . id === characterId ? { ... c , name : newName . trim ( ) , defaultMaxHp : hp , defaultInitMod : initMod } : c ) ;
2025-05-26 22:42:37 -04:00
try { await updateDoc ( doc ( db , getCampaignDocPath ( campaignId ) ) , { players : updatedCharacters } ) ; setEditingCharacter ( null ) ; } catch ( err ) { console . error ( "Error updating character:" , err ) ; }
2025-05-25 21:19:22 -04:00
} ;
2025-05-26 21:34:37 -04:00
const requestDeleteCharacter = ( characterId , charName ) => { setItemToDelete ( { id : characterId , name : charName , type : 'character' } ) ; setShowDeleteCharConfirm ( true ) ; } ;
2025-05-26 09:41:50 -04:00
const confirmDeleteCharacter = async ( ) => {
if ( ! db || ! itemToDelete || itemToDelete . type !== 'character' ) return ;
const characterId = itemToDelete . id ;
2025-05-25 21:19:22 -04:00
const updatedCharacters = campaignCharacters . filter ( c => c . id !== characterId ) ;
2025-05-26 21:34:37 -04:00
try { await updateDoc ( doc ( db , getCampaignDocPath ( campaignId ) ) , { players : updatedCharacters } ) ; } catch ( err ) { console . error ( "Error deleting character:" , err ) ; }
setShowDeleteCharConfirm ( false ) ; setItemToDelete ( null ) ;
2025-05-25 21:19:22 -04:00
} ;
return (
2025-05-26 09:41:50 -04:00
< >
2025-05-25 21:19:22 -04:00
< 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 >
2025-05-26 22:31:43 -04:00
< 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" >
2025-05-26 21:48:28 -04:00
< label htmlFor = "characterName" className = "block text-xs font-medium text-slate-400" > Name < / l a b e l >
< input type = "text" id = "characterName" value = { characterName } onChange = { ( e ) => setCharacterName ( e . target . value ) } placeholder = "New character name" className = "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 >
2025-05-26 22:31:43 -04:00
< div className = "w-full sm:w-auto" >
2025-05-26 21:48:28 -04:00
< label htmlFor = "defaultMaxHp" className = "block text-xs font-medium text-slate-400" > Default HP < / l a b e l >
< input type = "number" id = "defaultMaxHp" value = { defaultMaxHp } onChange = { ( e ) => setDefaultMaxHp ( e . target . value ) } placeholder = "HP" className = "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 >
2025-05-26 22:31:43 -04:00
< div className = "w-full sm:w-auto" >
< label htmlFor = "defaultInitMod" className = "block text-xs font-medium text-slate-400" > Init Mod < / l a b e l >
< input type = "number" id = "defaultInitMod" value = { defaultInitMod } onChange = { ( e ) => setDefaultInitMod ( e . target . value ) } placeholder = "+0" className = "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 >
< button type = "submit" className = "sm:col-span-3 sm:w-auto sm:justify-self-end px-4 py-2 text-sm font-medium text-white bg-sky-500 hover:bg-sky-600 rounded-md transition-colors flex items-center justify-center" > < PlusCircle size = { 18 } className = "mr-1" / > Add Character < / b u t t o n >
2025-05-25 21:19:22 -04:00
< / 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 ? (
2025-05-26 22:31:43 -04:00
< 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" / >
2025-05-26 21:48:28 -04:00
< button type = "submit" className = "p-1 text-green-400 hover:text-green-300" > < Save size = { 18 } / > < / b u t t o n >
< button type = "button" onClick = { ( ) => setEditingCharacter ( null ) } className = "p-1 text-slate-400 hover:text-slate-200" > < XCircle size = { 18 } / > < / b u t t o n >
< / f o r m >
) : (
< >
2025-05-26 22:31:43 -04:00
< span className = "text-slate-100" > { character . name } < span className = "text-xs text-slate-400" > ( HP : { character . defaultMaxHp || 'N/A' } , Init Mod : { character . defaultInitMod !== undefined ? ( character . defaultInitMod >= 0 ? ` + ${ character . defaultInitMod } ` : character . defaultInitMod ) : 'N/A' } ) < / s p a n > < / s p a n >
2025-05-26 21:48:28 -04:00
< div className = "flex space-x-2" >
2025-05-26 22:31:43 -04:00
< button onClick = { ( ) => setEditingCharacter ( { id : character . id , name : character . name , defaultMaxHp : character . defaultMaxHp || 10 , defaultInitMod : character . defaultInitMod || 0 } ) } 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 >
2025-05-26 21:48:28 -04:00
< 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 } / > < / b u t t o n >
< / d i v >
< / >
) }
2025-05-25 21:19:22 -04:00
< / l i >
) ) }
< / u l >
< / d i v >
2025-05-26 21:34:37 -04:00
< ConfirmationModal isOpen = { showDeleteCharConfirm } onClose = { ( ) => setShowDeleteCharConfirm ( false ) } onConfirm = { confirmDeleteCharacter } title = "Delete Character?" message = { ` Are you sure you want to remove the character " ${ itemToDelete ? . name } " from this campaign? ` } / >
2025-05-26 09:41:50 -04:00
< / >
2025-05-25 21:19:22 -04:00
) ;
}
function EncounterManager ( { campaignId , initialActiveEncounterId , campaignCharacters } ) {
2025-05-26 09:41:50 -04:00
const { data : encountersData , isLoading : isLoadingEncounters } = useFirestoreCollection ( campaignId ? getEncountersCollectionPath ( campaignId ) : null ) ;
2025-05-26 21:34:37 -04:00
const { data : activeDisplayInfoFromHook } = useFirestoreDocument ( getActiveDisplayDocPath ( ) ) ;
2025-05-26 09:41:50 -04:00
const [ encounters , setEncounters ] = useState ( [ ] ) ;
2025-05-25 21:19:22 -04:00
const [ selectedEncounterId , setSelectedEncounterId ] = useState ( null ) ;
const [ showCreateEncounterModal , setShowCreateEncounterModal ] = useState ( false ) ;
2025-05-26 09:41:50 -04:00
const [ showDeleteEncounterConfirm , setShowDeleteEncounterConfirm ] = useState ( false ) ;
2025-05-26 09:52:53 -04:00
const [ itemToDelete , setItemToDelete ] = useState ( null ) ;
2025-05-25 22:21:45 -04:00
const selectedEncounterIdRef = useRef ( selectedEncounterId ) ;
2025-05-26 21:34:37 -04:00
useEffect ( ( ) => { if ( encountersData ) setEncounters ( encountersData ) ; } , [ encountersData ] ) ;
useEffect ( ( ) => { selectedEncounterIdRef . current = selectedEncounterId ; } , [ selectedEncounterId ] ) ;
2025-05-25 21:19:22 -04:00
useEffect ( ( ) => {
2025-05-26 21:34:37 -04:00
if ( ! campaignId ) { setSelectedEncounterId ( null ) ; return ; }
2025-05-26 08:33:39 -04:00
if ( encounters && encounters . length > 0 ) {
const currentSelection = selectedEncounterIdRef . current ;
if ( currentSelection === null || ! encounters . some ( e => e . id === currentSelection ) ) {
2025-05-26 21:34:37 -04:00
if ( initialActiveEncounterId && encounters . some ( e => e . id === initialActiveEncounterId ) ) { setSelectedEncounterId ( initialActiveEncounterId ) ; }
else if ( activeDisplayInfoFromHook && activeDisplayInfoFromHook . activeCampaignId === campaignId && encounters . some ( e => e . id === activeDisplayInfoFromHook . activeEncounterId ) ) { setSelectedEncounterId ( activeDisplayInfoFromHook . activeEncounterId ) ; }
2025-05-25 21:19:22 -04:00
}
2025-05-26 21:34:37 -04:00
} else if ( encounters && encounters . length === 0 ) { setSelectedEncounterId ( null ) ; }
} , [ campaignId , initialActiveEncounterId , activeDisplayInfoFromHook , encounters ] ) ;
2025-05-25 21:19:22 -04:00
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 ( ) ;
2025-05-26 21:34:37 -04:00
try { await setDoc ( doc ( db , getEncountersCollectionPath ( campaignId ) , newEncounterId ) , { name : name . trim ( ) , createdAt : new Date ( ) . toISOString ( ) , participants : [ ] , round : 0 , currentTurnParticipantId : null , isStarted : false , isPaused : false } ) ; setShowCreateEncounterModal ( false ) ; setSelectedEncounterId ( newEncounterId ) ; } catch ( err ) { console . error ( "Error creating encounter:" , err ) ; }
2025-05-25 21:19:22 -04:00
} ;
2025-05-26 21:34:37 -04:00
const requestDeleteEncounter = ( encounterId , encounterName ) => { setItemToDelete ( { id : encounterId , name : encounterName , type : 'encounter' } ) ; setShowDeleteEncounterConfirm ( true ) ; } ;
2025-05-26 09:41:50 -04:00
const confirmDeleteEncounter = async ( ) => {
if ( ! db || ! itemToDelete || itemToDelete . type !== 'encounter' ) return ;
const encounterId = itemToDelete . id ;
2025-05-26 21:34:37 -04:00
try { await deleteDoc ( doc ( db , getEncounterDocPath ( campaignId , encounterId ) ) ) ; if ( selectedEncounterId === encounterId ) setSelectedEncounterId ( null ) ; if ( activeDisplayInfoFromHook && activeDisplayInfoFromHook . activeEncounterId === encounterId ) { await updateDoc ( doc ( db , getActiveDisplayDocPath ( ) ) , { activeCampaignId : null , activeEncounterId : null } ) ; } } catch ( err ) { console . error ( "Error deleting encounter:" , err ) ; }
setShowDeleteEncounterConfirm ( false ) ; setItemToDelete ( null ) ;
2025-05-25 21:19:22 -04:00
} ;
2025-05-26 07:50:24 -04:00
const handleTogglePlayerDisplayForEncounter = async ( encounterId ) => {
2025-05-25 22:21:45 -04:00
if ( ! db ) return ;
2025-05-25 21:19:22 -04:00
try {
2025-05-26 21:34:37 -04:00
const currentActiveCampaign = activeDisplayInfoFromHook ? . activeCampaignId ;
const currentActiveEncounter = activeDisplayInfoFromHook ? . activeEncounterId ;
if ( currentActiveCampaign === campaignId && currentActiveEncounter === encounterId ) { await setDoc ( doc ( db , getActiveDisplayDocPath ( ) ) , { activeCampaignId : null , activeEncounterId : null , } , { merge : true } ) ; console . log ( "Player Display for this encounter turned OFF." ) ; }
else { await setDoc ( doc ( db , getActiveDisplayDocPath ( ) ) , { activeCampaignId : campaignId , activeEncounterId : encounterId , } , { merge : true } ) ; console . log ( "Encounter set as active for Player Display!" ) ; }
} catch ( err ) { console . error ( "Error toggling Player Display for encounter:" , err ) ; }
2025-05-25 21:19:22 -04:00
} ;
2025-05-26 08:33:39 -04:00
const selectedEncounter = encounters ? . find ( e => e . id === selectedEncounterId ) ;
2025-05-26 21:34:37 -04:00
if ( isLoadingEncounters && campaignId ) { return < p className = "text-center text-slate-300 mt-4" > Loading encounters ... < / p > ; }
2025-05-25 21:19:22 -04:00
return (
2025-05-26 09:41:50 -04:00
< >
2025-05-25 21:19:22 -04:00
< 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 >
2025-05-26 08:33:39 -04:00
{ ( ! encounters || encounters . length === 0 ) && < p className = "text-sm text-slate-400" > No encounters yet . < / p > }
2025-05-25 21:19:22 -04:00
< div className = "space-y-3" >
2025-05-26 08:33:39 -04:00
{ encounters ? . map ( encounter => {
2025-05-26 21:34:37 -04:00
const isLiveOnPlayerDisplay = activeDisplayInfoFromHook && activeDisplayInfoFromHook . activeCampaignId === campaignId && activeDisplayInfoFromHook . activeEncounterId === encounter . id ;
2025-05-25 21:19:22 -04:00
return (
2025-05-26 07:50:24 -04:00
< 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' } ${ isLiveOnPlayerDisplay ? 'ring-2 ring-green-500 shadow-md shadow-green-500/30' : '' } ` } >
2025-05-25 21:19:22 -04: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 } < / h 4 >
< p className = "text-xs text-slate-300" > Participants : { encounter . participants ? . length || 0 } < / p >
2025-05-26 07:50:24 -04:00
{ isLiveOnPlayerDisplay && < span className = "text-xs text-green-400 font-semibold block mt-1" > LIVE ON PLAYER DISPLAY < / s p a n > }
2025-05-25 21:19:22 -04:00
< / d i v >
< div className = "flex items-center space-x-2" >
2025-05-26 21:34:37 -04:00
< button onClick = { ( ) => handleTogglePlayerDisplayForEncounter ( encounter . id ) } className = { ` p-1 rounded transition-colors ${ isLiveOnPlayerDisplay ? 'bg-red-500 hover:bg-red-600 text-white' : 'text-teal-400 hover:text-teal-300 bg-slate-600 hover:bg-slate-500' } ` } title = { isLiveOnPlayerDisplay ? "Deactivate for Player Display" : "Activate for Player Display" } > { isLiveOnPlayerDisplay ? < EyeOff size = { 18 } / > : < Eye size = { 18 } / > } < / b u t t o n >
2025-05-26 09:41:50 -04:00
< 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 } / > < / b u t t o n >
2025-05-25 21:19:22 -04:00
< / d i v >
< / d i v >
2025-05-26 21:34:37 -04:00
< / d i v > ) ;
2025-05-25 21:19:22 -04:00
} ) }
< / 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 >
2025-05-26 08:33:39 -04:00
< ParticipantManager encounter = { selectedEncounter } encounterPath = { getEncounterDocPath ( campaignId , selectedEncounter . id ) } campaignCharacters = { campaignCharacters } / >
< InitiativeControls campaignId = { campaignId } encounter = { selectedEncounter } encounterPath = { getEncounterDocPath ( campaignId , selectedEncounter . id ) } / >
2025-05-25 21:19:22 -04:00
< / d i v >
) }
< / d i v >
2025-05-26 21:34:37 -04:00
< ConfirmationModal isOpen = { showDeleteEncounterConfirm } onClose = { ( ) => setShowDeleteEncounterConfirm ( 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
) ;
}
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 ( '' ) ;
2025-05-26 22:42:37 -04:00
const [ monsterInitMod , setMonsterInitMod ] = useState ( 2 ) ;
2025-05-25 21:19:22 -04:00
const [ maxHp , setMaxHp ] = useState ( 10 ) ;
const [ editingParticipant , setEditingParticipant ] = useState ( null ) ;
const [ hpChangeValues , setHpChangeValues ] = useState ( { } ) ;
const [ draggedItemId , setDraggedItemId ] = useState ( null ) ;
2025-05-26 09:41:50 -04:00
const [ showDeleteParticipantConfirm , setShowDeleteParticipantConfirm ] = useState ( false ) ;
2025-05-26 09:52:53 -04:00
const [ itemToDelete , setItemToDelete ] = useState ( null ) ;
2025-05-26 22:42:37 -04:00
const [ lastRollDetails , setLastRollDetails ] = useState ( null ) ;
2025-05-25 21:19:22 -04:00
const participants = encounter . participants || [ ] ;
2025-05-26 22:42:37 -04:00
const MONSTER _DEFAULT _INIT _MOD = 2 ;
2025-05-26 21:48:28 -04:00
useEffect ( ( ) => {
if ( participantType === 'character' && selectedCharacterId ) {
const selectedChar = campaignCharacters . find ( c => c . id === selectedCharacterId ) ;
2025-05-26 22:42:37 -04:00
if ( selectedChar && selectedChar . defaultMaxHp ) { setMaxHp ( selectedChar . defaultMaxHp ) ; } else { setMaxHp ( 10 ) ; }
} else if ( participantType === 'monster' ) { setMaxHp ( 10 ) ; setMonsterInitMod ( MONSTER _DEFAULT _INIT _MOD ) ; }
2025-05-26 21:48:28 -04:00
} , [ selectedCharacterId , participantType , campaignCharacters ] ) ;
2025-05-25 21:19:22 -04:00
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 ( ) ;
2025-05-26 22:31:43 -04:00
const initiativeRoll = rollD20 ( ) ;
let modifier = 0 ;
let finalInitiative ;
let currentMaxHp = parseInt ( maxHp , 10 ) || 10 ;
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 ) ;
if ( ! character ) { console . error ( "Selected character not found" ) ; return ; }
2025-05-26 21:34:37 -04:00
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-05-26 22:31:43 -04:00
currentMaxHp = character . defaultMaxHp || currentMaxHp ;
modifier = character . defaultInitMod || 0 ;
finalInitiative = initiativeRoll + modifier ;
2025-05-26 22:42:37 -04:00
} else {
modifier = parseInt ( monsterInitMod , 10 ) || 0 ;
2025-05-26 22:31:43 -04:00
finalInitiative = initiativeRoll + modifier ;
2025-05-25 21:19:22 -04:00
}
2025-05-26 22:31:43 -04:00
const newParticipant = { id : generateId ( ) , name : nameToAdd , type : participantType , originalCharacterId : participantType === 'character' ? selectedCharacterId : null , initiative : finalInitiative , maxHp : currentMaxHp , currentHp : currentMaxHp , conditions : [ ] , isActive : true , } ;
try {
await updateDoc ( doc ( db , encounterPath ) , { participants : [ ... participants , newParticipant ] } ) ;
setLastRollDetails ( { name : nameToAdd , roll : initiativeRoll , mod : modifier , total : finalInitiative } ) ;
2025-05-26 22:42:37 -04:00
setTimeout ( ( ) => setLastRollDetails ( null ) , 5000 ) ;
setParticipantName ( '' ) ; setMaxHp ( 10 ) ; setSelectedCharacterId ( '' ) ; setMonsterInitMod ( MONSTER _DEFAULT _INIT _MOD ) ;
2025-05-26 22:31:43 -04:00
} catch ( err ) { console . error ( "Error adding participant:" , err ) ; }
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-05-26 22:31:43 -04:00
const existingParticipantOriginalIds = participants . filter ( p => p . type === 'character' && p . originalCharacterId ) . map ( p => p . originalCharacterId ) ;
2025-05-26 21:48:28 -04:00
const newParticipants = campaignCharacters
2025-05-26 22:31:43 -04:00
. filter ( char => ! existingParticipantOriginalIds . includes ( char . id ) )
. map ( char => {
const initiativeRoll = rollD20 ( ) ;
const modifier = char . defaultInitMod || 0 ;
const finalInitiative = initiativeRoll + modifier ;
console . log ( ` Adding ${ char . name } : Rolled ${ initiativeRoll } + ${ modifier } (mod) = ${ finalInitiative } init ` ) ;
return { id : generateId ( ) , name : char . name , type : 'character' , originalCharacterId : char . id , initiative : finalInitiative , maxHp : char . defaultMaxHp || 10 , currentHp : char . defaultMaxHp || 10 , conditions : [ ] , isActive : true , } ;
2025-05-26 21:48:28 -04:00
} ) ;
2025-05-26 22:31:43 -04:00
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 ) ; }
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-05-25 21:19:22 -04:00
const { flavorText , ... restOfData } = updatedData ;
const updatedParticipants = participants . map ( p => p . id === editingParticipant . id ? { ... p , ... restOfData } : p ) ;
2025-05-26 21:34:37 -04:00
try { await updateDoc ( doc ( db , encounterPath ) , { participants : updatedParticipants } ) ; setEditingParticipant ( null ) ; } catch ( err ) { console . error ( "Error updating participant:" , err ) ; }
2025-05-25 21:19:22 -04:00
} ;
2025-05-26 21:34:37 -04:00
const requestDeleteParticipant = ( participantId , participantName ) => { setItemToDelete ( { id : participantId , name : participantName , type : 'participant' } ) ; setShowDeleteParticipantConfirm ( true ) ; } ;
2025-05-26 09:41:50 -04:00
const confirmDeleteParticipant = async ( ) => {
if ( ! db || ! itemToDelete || itemToDelete . type !== 'participant' ) return ;
const participantId = itemToDelete . id ;
2025-05-25 21:19:22 -04:00
const updatedParticipants = participants . filter ( p => p . id !== participantId ) ;
2025-05-26 21:34:37 -04:00
try { await updateDoc ( doc ( db , encounterPath ) , { participants : updatedParticipants } ) ; } catch ( err ) { console . error ( "Error deleting participant:" , err ) ; }
setShowDeleteParticipantConfirm ( false ) ; setItemToDelete ( null ) ;
2025-05-25 21:19:22 -04:00
} ;
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 ) ;
2025-05-26 21:34:37 -04:00
try { await updateDoc ( doc ( db , encounterPath ) , { participants : updatedPs } ) ; } catch ( err ) { console . error ( "Error toggling active state:" , err ) ; }
2025-05-25 21:19:22 -04:00
} ;
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 ) ;
2025-05-26 21:34:37 -04:00
try { await updateDoc ( doc ( db , encounterPath ) , { participants : updatedPs } ) ; setHpChangeValues ( prev => ( { ... prev , [ participantId ] : '' } ) ) ; } catch ( err ) { console . error ( "Error applying HP change:" , err ) ; }
2025-05-25 21:19:22 -04:00
} ;
2025-05-26 21:34:37 -04:00
const handleDragStart = ( e , id ) => { setDraggedItemId ( id ) ; e . dataTransfer . effectAllowed = 'move' ; } ;
const handleDragOver = ( e ) => { e . preventDefault ( ) ; e . dataTransfer . dropEffect = 'move' ; } ;
2025-05-25 21:19:22 -04:00
const handleDrop = async ( e , targetId ) => {
2025-05-26 08:53:26 -04:00
e . preventDefault ( ) ;
2025-05-26 21:34:37 -04:00
if ( ! db || draggedItemId === null || draggedItemId === targetId ) { setDraggedItemId ( null ) ; return ; }
2025-05-26 08:53:26 -04:00
const currentParticipants = [ ... participants ] ;
2025-05-25 21:19:22 -04:00
const draggedItemIndex = currentParticipants . findIndex ( p => p . id === draggedItemId ) ;
const targetItemIndex = currentParticipants . findIndex ( p => p . id === targetId ) ;
2025-05-26 21:34:37 -04:00
if ( draggedItemIndex === - 1 || targetItemIndex === - 1 ) { console . error ( "Dragged or target item not found." ) ; setDraggedItemId ( null ) ; return ; }
2025-05-25 21:19:22 -04:00
const draggedItem = currentParticipants [ draggedItemIndex ] ;
const targetItem = currentParticipants [ targetItemIndex ] ;
2025-05-26 21:34:37 -04:00
if ( draggedItem . initiative !== targetItem . initiative ) { console . log ( "Drag-drop only for same initiative." ) ; setDraggedItemId ( null ) ; return ; }
2025-05-26 08:53:26 -04:00
const [ removedItem ] = currentParticipants . splice ( draggedItemIndex , 1 ) ;
currentParticipants . splice ( targetItemIndex , 0 , removedItem ) ;
2025-05-26 21:34:37 -04:00
try { await updateDoc ( doc ( db , encounterPath ) , { participants : currentParticipants } ) ; console . log ( "Participants reordered." ) ; } catch ( err ) { console . error ( "Error D&D update:" , err ) ; }
2025-05-26 08:53:26 -04:00
setDraggedItemId ( null ) ;
2025-05-25 21:19:22 -04:00
} ;
2025-05-26 21:34:37 -04:00
const handleDragEnd = ( ) => { setDraggedItemId ( null ) ; } ;
2025-05-25 21:19:22 -04:00
const sortedAdminParticipants = [ ... participants ] . sort ( ( a , b ) => {
2025-05-26 21:34:37 -04:00
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 ; }
2025-05-26 08:53:26 -04:00
return b . initiative - a . initiative ;
2025-05-25 21:19:22 -04:00
} ) ;
2025-05-26 21:34:37 -04:00
const initiativeGroups = participants . reduce ( ( acc , p ) => { acc [ p . initiative ] = ( acc [ p . initiative ] || 0 ) + 1 ; return acc ; } , { } ) ;
2025-05-25 21:19:22 -04:00
const tiedInitiatives = Object . keys ( initiativeGroups ) . filter ( init => initiativeGroups [ init ] > 1 ) . map ( Number ) ;
return (
2025-05-26 09:41:50 -04:00
< >
2025-05-25 21:19:22 -04:00
< div className = "p-3 bg-slate-800 rounded-md mt-4" >
2025-05-26 21:48:28 -04:00
< div className = "flex justify-between items-center mb-3" >
< h4 className = "text-lg font-medium text-sky-200" > Add Participants < / h 4 >
< 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 } >
2025-05-26 22:31:43 -04:00
< Users2 size = { 16 } className = "mr-1.5" / > < Dices size = { 16 } className = "mr-1.5" / > Add All ( Roll Init )
2025-05-26 21:48:28 -04:00
< / b u t t o n >
< / d i v >
2025-05-26 22:42:37 -04:00
< form onSubmit = { ( e ) => { e . preventDefault ( ) ; handleAddParticipant ( ) ; } } className = "grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-2 mb-4 p-3 bg-slate-700 rounded" >
2025-05-25 21:19:22 -04:00
< div >
< label className = "block text-sm font-medium text-slate-300" > Type < / l a b e l >
2025-05-26 22:31:43 -04:00
< select value = { participantType } onChange = { ( e ) => { setParticipantType ( e . target . value ) ; setSelectedCharacterId ( '' ) ; } } className = "mt-1 w-full px-3 py-2 bg-slate-600 border-slate-500 rounded text-white" >
2025-05-25 21:19:22 -04:00
< 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 >
2025-05-26 22:31:43 -04:00
{ participantType === 'monster' && (
< >
< div >
< 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 >
< div >
< label className = "block text-sm font-medium text-slate-300" > Monster Init Mod < / l a b e l >
< input type = "number" value = { monsterInitMod } onChange = { ( e ) => setMonsterInitMod ( 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 >
< / >
) }
{ 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 } ( H P : { c . d e f a u l t M a x H p | | ' N / A ' } , M o d : { c . d e f a u l t I n i t M o d > = 0 ? ` + $ { c . d e f a u l t I n i t M o d } ` : c . d e f a u l t I n i t M o d } ) < / o p t i o n > ) } < / s e l e c t > < / d i v > ) }
2025-05-26 22:42:37 -04:00
< div className = { participantType === 'monster' ? 'md:col-span-2' : '' } > < 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 >
2025-05-26 22:31:43 -04:00
< 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" > < Dices size = { 18 } className = "mr-1.5" / > Add to Encounter ( Roll Init ) < / b u t t o n > < / d i v >
2025-05-25 21:19:22 -04:00
< / f o r m >
2025-05-26 22:31:43 -04:00
{ lastRollDetails && (
< p className = "text-sm text-green-400 mt-2 mb-2 text-center" >
{ lastRollDetails . name } : Rolled D20 ( { lastRollDetails . roll } ) { lastRollDetails . mod >= 0 ? '+' : '' } { lastRollDetails . mod } ( mod ) = { lastRollDetails . total } Initiative
< / p >
) }
2025-05-25 21:19:22 -04:00
{ participants . length === 0 && < p className = "text-sm text-slate-400" > No participants . < / p > }
< ul className = "space-y-2" >
2025-05-26 21:34:37 -04:00
{ sortedAdminParticipants . map ( ( p ) => {
2025-05-25 21:19:22 -04:00
const isCurrentTurn = encounter . isStarted && p . id === encounter . currentTurnParticipantId ;
2025-05-26 21:34:37 -04:00
const isDraggable = ( ! encounter . isStarted || encounter . isPaused ) && tiedInitiatives . includes ( Number ( p . initiative ) ) ;
2025-05-25 21:19:22 -04:00
return (
2025-05-26 21:34:37 -04:00
< 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 && ! encounter . isPaused ? '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' : '' } ` } >
2025-05-25 21:19:22 -04:00
< 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 >
2025-05-26 21:34:37 -04:00
< p className = { ` font-semibold text-lg ${ isCurrentTurn && ! encounter . isPaused ? 'text-white' : 'text-white' } ` } > { p . name } < span className = "text-xs" > ( { p . type } ) < /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 < / s p a n > } < / p >
< p className = { ` text-sm ${ isCurrentTurn && ! encounter . isPaused ? 'text-green-100' : 'text-slate-200' } ` } > Init : { p . initiative } | HP : { p . currentHp } / { p . maxHp } < / p >
2025-05-25 21:19:22 -04:00
< / 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 >
2025-05-26 09:41:50 -04:00
< 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 } / > < / b u t t o n >
2025-05-25 21:19:22 -04:00
< / d i v >
2025-05-26 21:34:37 -04:00
< / l i > ) ;
2025-05-25 21:19:22 -04:00
} ) }
< / u l >
{ editingParticipant && < EditParticipantModal participant = { editingParticipant } onClose = { ( ) => setEditingParticipant ( null ) } onSave = { handleUpdateParticipant } / > }
< / d i v >
2025-05-26 21:34:37 -04:00
< ConfirmationModal isOpen = { showDeleteParticipantConfirm } onClose = { ( ) => setShowDeleteParticipantConfirm ( false ) } onConfirm = { confirmDeleteParticipant } title = "Delete Participant?" message = { ` Are you sure you want to remove " ${ itemToDelete ? . name } " from this encounter? ` } / >
2025-05-26 09:41:50 -04:00
< / >
2025-05-25 21:19:22 -04:00
) ;
}
2025-05-26 22:42:37 -04:00
// ... (EditParticipantModal, InitiativeControls, DisplayView, Modal, Icons)
// The rest of the components are assumed to be the same as v0.1.25 for this update.
2025-05-25 21:19:22 -04:00
function EditParticipantModal ( { participant , onClose , onSave } ) {
const [ name , setName ] = useState ( participant . name ) ;
2025-05-26 22:31:43 -04:00
const [ initiative , setInitiative ] = useState ( participant . initiative ) ;
2025-05-25 21:19:22 -04:00
const [ currentHp , setCurrentHp ] = useState ( participant . currentHp ) ;
const [ maxHp , setMaxHp ] = useState ( participant . maxHp ) ;
2025-05-26 21:34:37 -04:00
const handleSubmit = ( e ) => { e . preventDefault ( ) ; onSave ( { name : name . trim ( ) , initiative : parseInt ( initiative , 10 ) , currentHp : parseInt ( currentHp , 10 ) , maxHp : parseInt ( maxHp , 10 ) , } ) ; } ;
2025-05-25 21:19:22 -04:00
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 } ) {
2025-05-26 09:41:50 -04:00
const [ showEndEncounterConfirm , setShowEndEncounterConfirm ] = useState ( false ) ;
2025-05-25 21:19:22 -04:00
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 ) => {
2025-05-26 21:34:37 -04:00
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 ; }
2025-05-25 21:19:22 -04:00
return b . initiative - a . initiative ;
} ) ;
try {
2025-05-26 21:34:37 -04:00
await updateDoc ( doc ( db , encounterPath ) , { isStarted : true , isPaused : false , round : 1 , currentTurnParticipantId : sortedPs [ 0 ] . id , turnOrderIds : sortedPs . map ( p => p . id ) } ) ;
await setDoc ( doc ( db , getActiveDisplayDocPath ( ) ) , { activeCampaignId : campaignId , activeEncounterId : encounter . id } , { merge : true } ) ;
2025-05-25 21:19:22 -04:00
console . log ( "Encounter started and set as active display." ) ;
} catch ( err ) { console . error ( "Error starting encounter:" , err ) ; }
} ;
2025-05-26 21:34:37 -04:00
const handleTogglePause = async ( ) => {
if ( ! db || ! encounter || ! encounter . isStarted ) return ;
const newPausedState = ! encounter . isPaused ;
let newTurnOrderIds = encounter . turnOrderIds ;
if ( ! newPausedState && encounter . isPaused ) {
const activeParticipants = encounter . participants . filter ( p => p . isActive ) ;
const sortedActiveParticipants = [ ... activeParticipants ] . sort ( ( a , b ) => {
2025-05-26 21:48:28 -04:00
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 ; }
2025-05-26 21:34:37 -04:00
return b . initiative - a . initiative ;
} ) ;
newTurnOrderIds = sortedActiveParticipants . map ( p => p . id ) ;
}
2025-05-26 21:48:28 -04:00
try { await updateDoc ( doc ( db , encounterPath ) , { isPaused : newPausedState , turnOrderIds : newTurnOrderIds } ) ; console . log ( ` Encounter ${ newPausedState ? 'paused' : 'resumed' } . ` ) ; } catch ( err ) { console . error ( "Error toggling pause state:" , err ) ; }
2025-05-26 21:34:37 -04:00
} ;
2025-05-25 21:19:22 -04:00
const handleNextTurn = async ( ) => {
2025-05-26 21:34:37 -04:00
if ( ! db || ! encounter . isStarted || encounter . isPaused || ! 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 ) ;
2025-05-26 21:34:37 -04:00
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 ;
if ( nextIndex === 0 && currentIndex !== - 1 ) nextRound += 1 ;
2025-05-26 21:34:37 -04:00
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-05-26 21:34:37 -04:00
const requestEndEncounter = ( ) => { setShowEndEncounterConfirm ( true ) ; } ;
2025-05-26 09:41:50 -04:00
const confirmEndEncounter = async ( ) => {
2025-05-25 22:21:45 -04:00
if ( ! db ) return ;
2025-05-25 21:19:22 -04:00
try {
2025-05-26 21:34:37 -04:00
await updateDoc ( doc ( db , encounterPath ) , { isStarted : false , isPaused : false , currentTurnParticipantId : null , round : 0 , turnOrderIds : [ ] } ) ;
await setDoc ( doc ( db , getActiveDisplayDocPath ( ) ) , { activeCampaignId : null , activeEncounterId : null } , { merge : true } ) ;
2025-05-26 07:59:05 -04:00
console . log ( "Encounter ended and deactivated from Player Display." ) ;
2025-05-25 21:19:22 -04:00
} catch ( err ) { console . error ( "Error ending encounter:" , err ) ; }
2025-05-26 09:41:50 -04:00
setShowEndEncounterConfirm ( false ) ;
2025-05-25 21:19:22 -04:00
} ;
if ( ! encounter || ! encounter . participants ) return null ;
return (
2025-05-26 09:41:50 -04:00
< >
2025-05-25 21:19:22 -04:00
< 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 >
) : (
< >
2025-05-26 21:34:37 -04:00
< button onClick = { handleTogglePause } className = { ` px-4 py-2 text-sm font-medium text-white rounded-md transition-colors flex items-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' }
< / 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 || encounter . isPaused } > < SkipForwardIcon size = { 18 } className = "mr-2" / > Next Turn < / b u t t o n >
2025-05-26 09:41:50 -04:00
< button onClick = { requestEndEncounter } 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 >
2025-05-25 21:19:22 -04:00
< p className = "text-slate-300 self-center" > Round : { encounter . round } < / p >
2025-05-26 21:34:37 -04:00
{ encounter . isPaused && < p className = "text-yellow-400 font-semibold self-center" > ( Paused ) < / p > }
2025-05-25 21:19:22 -04:00
< / >
) }
< / d i v >
< / d i v >
2025-05-26 21:34:37 -04:00
< ConfirmationModal isOpen = { showEndEncounterConfirm } onClose = { ( ) => setShowEndEncounterConfirm ( 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. ` } / >
2025-05-26 09:41:50 -04:00
< / >
2025-05-25 21:19:22 -04:00
) ;
}
2025-05-26 07:59:05 -04:00
function DisplayView ( ) {
2025-05-26 08:33:39 -04:00
const { data : activeDisplayData , isLoading : isLoadingActiveDisplay , error : activeDisplayError } = useFirestoreDocument ( getActiveDisplayDocPath ( ) ) ;
2025-05-25 21:19:22 -04:00
const [ activeEncounterData , setActiveEncounterData ] = useState ( null ) ;
2025-05-26 08:53:26 -04: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-05-26 07:59:05 -04:00
const [ isPlayerDisplayActive , setIsPlayerDisplayActive ] = useState ( false ) ;
2025-05-25 21:19:22 -04:00
useEffect ( ( ) => {
2025-05-26 21:34:37 -04: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-05-26 08:33:39 -04:00
if ( activeDisplayData ) {
const { activeCampaignId , activeEncounterId } = activeDisplayData ;
2025-05-26 07:50:24 -04:00
if ( activeCampaignId && activeEncounterId ) {
2025-05-26 21:34:37 -04:00
setIsPlayerDisplayActive ( true ) ; setIsLoadingEncounter ( true ) ; setEncounterError ( null ) ;
2025-05-26 08:33:39 -04:00
const campaignDocRef = doc ( db , getCampaignDocPath ( activeCampaignId ) ) ;
2025-05-26 21:34:37 -04:00
unsubscribeCampaign = onSnapshot ( campaignDocRef , ( campSnap ) => { if ( campSnap . exists ( ) ) { setCampaignBackgroundUrl ( campSnap . data ( ) . playerDisplayBackgroundUrl || '' ) ; } else { setCampaignBackgroundUrl ( '' ) ; } } , ( err ) => console . error ( "Error fetching campaign background:" , err ) ) ;
2025-05-26 08:33:39 -04:00
const encounterPath = getEncounterDocPath ( activeCampaignId , activeEncounterId ) ;
unsubscribeEncounter = onSnapshot ( doc ( db , encounterPath ) , ( encDocSnap ) => {
2025-05-26 21:34:37 -04:00
if ( encDocSnap . exists ( ) ) { setActiveEncounterData ( { id : encDocSnap . id , ... encDocSnap . data ( ) } ) ; }
else { setActiveEncounterData ( null ) ; setEncounterError ( "Active encounter data not found." ) ; }
2025-05-26 08:33:39 -04:00
setIsLoadingEncounter ( false ) ;
2025-05-26 21:34:37 -04:00
} , ( 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 ( ) ; } ;
2025-05-26 08:33:39 -04:00
} , [ activeDisplayData , isLoadingActiveDisplay ] ) ;
2025-05-26 21:34:37 -04:00
if ( isLoadingActiveDisplay || ( isPlayerDisplayActive && isLoadingEncounter ) ) { return < div className = "text-center py-10 text-2xl text-slate-300" > Loading Player Display ... < / d i v > ; }
if ( activeDisplayError || ( isPlayerDisplayActive && encounterError ) ) { return < div className = "text-center py-10 text-2xl text-red-400" > { activeDisplayError || encounterError } < / d i v > ; }
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" / >
< h2 className = "text-3xl font-semibold" > Game Session Paused < / h 2 >
< p className = "text-xl mt-2" > The Dungeon Master has not activated an encounter for display . < / p >
2025-05-26 21:34:37 -04:00
< / d i v > ) ;
2025-05-26 07:50:24 -04:00
}
2025-05-26 21:34:37 -04:00
const { name , participants , round , currentTurnParticipantId , isStarted , isPaused } = activeEncounterData ;
2025-05-26 09:52:53 -04:00
let participantsToRender = [ ] ;
2025-05-26 09:41:50 -04:00
if ( participants ) {
2025-05-25 22:21:45 -04:00
if ( isStarted && activeEncounterData . turnOrderIds ? . length > 0 ) {
2025-05-26 21:34:37 -04:00
const inTurnOrderAndActive = activeEncounterData . turnOrderIds . map ( id => participants . find ( p => p . id === id ) ) . filter ( p => p && p . isActive ) ;
const notInTurnOrderButActive = participants . filter ( p => p . isActive && ! activeEncounterData . turnOrderIds . includes ( p . id ) ) . sort ( ( a , b ) => { if ( a . initiative === b . initiative ) { const indexA = participants . findIndex ( op => op . id === a . id ) ; const indexB = participants . findIndex ( op => op . id === b . id ) ; return indexA - indexB ; } return b . initiative - a . initiative ; } ) ;
2025-05-26 21:17:42 -04:00
participantsToRender = [ ... inTurnOrderAndActive , ... notInTurnOrderButActive ] ;
} else {
2025-05-26 21:34:37 -04:00
participantsToRender = [ ... 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 22:21:45 -04:00
}
2025-05-25 21:19:22 -04:00
}
2025-05-26 21:34:37 -04:00
const displayStyles = campaignBackgroundUrl ? { backgroundImage : ` url( ${ campaignBackgroundUrl } ) ` , backgroundSize : 'cover' , backgroundPosition : 'center center' , backgroundRepeat : 'no-repeat' , minHeight : '100vh' } : { minHeight : '100vh' } ;
2025-05-25 21:19:22 -04:00
return (
2025-05-26 21:34:37 -04:00
< div className = { ` p-4 md:p-8 rounded-xl shadow-2xl ${ ! campaignBackgroundUrl ? 'bg-slate-900' : '' } ` } style = { displayStyles } >
2025-05-25 23:28:36 -04:00
< div className = { campaignBackgroundUrl ? 'bg-slate-900 bg-opacity-75 p-4 md:p-6 rounded-lg' : '' } >
< h2 className = "text-4xl md:text-5xl font-bold text-center text-amber-400 mb-2" > { name } < / h 2 >
2025-05-26 21:34:37 -04:00
{ isStarted && < p className = "text-2xl text-center text-sky-300 mb-1" > Round : { round } < / p > }
{ isStarted && isPaused && < p className = "text-xl text-center text-yellow-400 mb-4 font-semibold" > ( Combat Paused ) < / p > }
2025-05-25 23:28:36 -04:00
{ ! 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 > }
2025-05-26 08:53:26 -04:00
{ participantsToRender . length === 0 && isStarted && < p className = "text-xl text-slate-400" > No active participants . < / p > }
2025-05-26 09:17:53 -04:00
< div className = "space-y-4 max-w-3xl mx-auto" >
2025-05-26 08:53:26 -04:00
{ participantsToRender . map ( p => (
2025-05-26 21:34:37 -04:00
< div key = { p . id } className = { ` p-4 md:p-6 rounded-lg shadow-lg transition-all ${ p . id === currentTurnParticipantId && isStarted && ! isPaused ? 'bg-green-700 ring-4 ring-green-400 scale-105' : ( p . type === 'character' ? 'bg-sky-700' : 'bg-red-700' ) } ${ ! p . isActive ? 'opacity-40 grayscale' : '' } ${ isPaused && p . id === currentTurnParticipantId ? 'ring-2 ring-yellow-400' : '' } ` } >
2025-05-25 23:28:36 -04:00
< div className = "flex justify-between items-center mb-2" >
2025-05-26 21:34:37 -04:00
< h3 className = { ` text-2xl md:text-3xl font-bold ${ p . id === currentTurnParticipantId && isStarted && ! isPaused ? 'text-white' : ( p . type === 'character' ? 'text-sky-100' : 'text-red-100' ) } ` } > { p . name } { p . id === currentTurnParticipantId && isStarted && ! isPaused && < 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 && ! isPaused ? 'text-green-200' : 'text-slate-200' } ` } > Init : { p . initiative } < / s p a n >
2025-05-25 21:19:22 -04:00
< / d i v >
2025-05-25 23:28:36 -04: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 ${ 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-26 21:34:37 -04:00
{ 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 } < / s p a n > ) }
2025-05-25 23:28:36 -04:00
< / 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 > }
2025-05-26 21:34:37 -04:00
< / d i v > ) ) }
2025-05-25 23:28:36 -04:00
< / d i v >
2025-05-25 21:19:22 -04:00
< / d i v >
2025-05-26 21:34:37 -04:00
< / d i v > ) ;
2025-05-25 21:19:22 -04:00
}
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 >
) ;
}
2025-05-26 09:41:50 -04:00
// --- Icons ---
2025-05-26 21:34:37 -04:00
// PlayIcon, SkipForwardIcon, StopCircleIcon are now imported from lucide-react at the top
2025-05-25 21:19:22 -04:00
2025-05-26 08:53:26 -04:00
export default App ;