2025-05-25 23:28:36 -04:00
import React , { useState , useEffect , useRef } 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-25 23:28:36 -04:00
import { getFirestore , doc , setDoc , addDoc , getDoc , getDocs , collection , onSnapshot , updateDoc , deleteDoc , query , writeBatch } from 'firebase/firestore' ;
2025-05-26 08:53:26 -04:00
import { PlusCircle , Users , Swords , Shield , Trash2 , Eye , Edit3 , Save , XCircle , ChevronsUpDown , UserCheck , UserX , HeartCrack , HeartPulse , Zap , Image as ImageIcon , EyeOff , ExternalLink } from 'lucide-react' ;
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 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 ) ;
useEffect ( ( ) => {
if ( ! db || ! collectionPath ) {
setData ( [ ] ) ;
setIsLoading ( false ) ;
setError ( collectionPath ? "Firestore not available." : "Collection path not provided." ) ;
return ;
}
setIsLoading ( true ) ;
setError ( null ) ;
const constraints = Array . isArray ( queryConstraints ) ? queryConstraints : [ ] ;
const q = query ( collection ( db , collectionPath ) , ... constraints ) ;
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 ( [ ] ) ;
} ) ;
return ( ) => unsubscribe ( ) ;
} , [ collectionPath , JSON . stringify ( queryConstraints ) ] ) ;
return { data , isLoading , error } ;
}
2025-05-25 21:19:22 -04:00
// --- Main App Component ---
function App ( ) {
const [ userId , setUserId ] = useState ( null ) ;
const [ isAuthReady , setIsAuthReady ] = useState ( false ) ;
const [ isLoading , setIsLoading ] = useState ( true ) ;
const [ error , setError ] = useState ( null ) ;
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." ) ;
}
} ;
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 ( ) => {
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-25 23:28:36 -04:00
< h1
2025-05-26 08:18:13 -04:00
className = "text-3xl font-bold text-sky-400"
2025-05-25 23:28:36 -04:00
>
TTRPG Initiative Tracker
< / h 1 >
2025-05-25 21:19:22 -04:00
< 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
< 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 09:02:12 -04:00
TTRPG Initiative Tracker v0 . 1.21
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 >
) ;
}
// --- Admin View Component ---
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 ) ;
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 ) ; }
} ;
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 {
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 ) ; }
} ;
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 (
< 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 >
2025-05-25 23:28:36 -04:00
{ campaign . playerDisplayBackgroundUrl && < ImageIcon size = { 14 } className = "inline-block mr-1 text-slate-400" title = "Has custom background" / > }
2025-05-25 21:19:22 -04:00
< 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 }
2025-05-26 08:33:39 -04:00
initialActiveEncounterId = { initialActiveInfoData && initialActiveInfoData . activeCampaignId === selectedCampaignId ? initialActiveInfoData . activeEncounterId : null }
2025-05-25 21:19:22 -04:00
campaignCharacters = { selectedCampaign . players || [ ] }
/ >
< / d i v >
) }
< / d i v >
) ;
}
function CreateCampaignForm ( { onCreate , onCancel } ) {
const [ name , setName ] = useState ( '' ) ;
2025-05-25 23:28:36 -04:00
const [ backgroundUrl , setBackgroundUrl ] = useState ( '' ) ;
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 >
) ;
}
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 {
2025-05-26 08:33:39 -04:00
await updateDoc ( doc ( db , getCampaignDocPath ( campaignId ) ) , { players : [ ... campaignCharacters , newCharacter ] } ) ;
2025-05-25 21:19:22 -04:00
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 {
2025-05-26 08:33:39 -04:00
await updateDoc ( doc ( db , getCampaignDocPath ( campaignId ) ) , { players : updatedCharacters } ) ;
2025-05-25 21:19:22 -04:00
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 {
2025-05-26 08:33:39 -04:00
await updateDoc ( doc ( db , getCampaignDocPath ( campaignId ) ) , { players : updatedCharacters } ) ;
2025-05-25 21:19:22 -04:00
} 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 } ) {
2025-05-26 08:33:39 -04:00
const { data : encounters , isLoading : isLoadingEncounters } = useFirestoreCollection ( campaignId ? getEncountersCollectionPath ( campaignId ) : null ) ;
const { data : activeDisplayInfo } = useFirestoreDocument ( getActiveDisplayDocPath ( ) ) ;
2025-05-25 21:19:22 -04:00
const [ selectedEncounterId , setSelectedEncounterId ] = useState ( null ) ;
const [ showCreateEncounterModal , setShowCreateEncounterModal ] = useState ( false ) ;
2025-05-26 08:33:39 -04:00
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-26 08:53:26 -04:00
if ( ! campaignId ) {
2025-05-26 08:33:39 -04:00
setSelectedEncounterId ( null ) ;
return ;
2025-05-25 21:19:22 -04:00
}
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 ) ) {
if ( initialActiveEncounterId && encounters . some ( e => e . id === initialActiveEncounterId ) ) {
setSelectedEncounterId ( initialActiveEncounterId ) ;
} else if ( activeDisplayInfo && activeDisplayInfo . activeCampaignId === campaignId &&
encounters . some ( e => e . id === activeDisplayInfo . activeEncounterId ) ) {
setSelectedEncounterId ( activeDisplayInfo . activeEncounterId ) ;
}
2025-05-25 21:19:22 -04:00
}
2025-05-26 08:53:26 -04:00
} else if ( encounters && encounters . length === 0 ) {
2025-05-26 08:33:39 -04:00
setSelectedEncounterId ( null ) ;
}
} , [ campaignId , initialActiveEncounterId , activeDisplayInfo , 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 ( ) ;
try {
2025-05-26 08:33:39 -04:00
await setDoc ( doc ( db , getEncountersCollectionPath ( campaignId ) , newEncounterId ) , {
2025-05-25 21:19:22 -04:00
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 {
2025-05-26 08:33:39 -04:00
await deleteDoc ( doc ( db , getEncounterDocPath ( campaignId , encounterId ) ) ) ;
2025-05-25 21:19:22 -04:00
if ( selectedEncounterId === encounterId ) setSelectedEncounterId ( null ) ;
if ( activeDisplayInfo && activeDisplayInfo . activeEncounterId === encounterId ) {
2025-05-26 08:33:39 -04:00
await updateDoc ( doc ( db , getActiveDisplayDocPath ( ) ) , { activeCampaignId : null , activeEncounterId : null } ) ;
2025-05-25 21:19:22 -04:00
}
} catch ( err ) { console . error ( "Error deleting encounter:" , err ) ; }
} ;
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-25 23:28:36 -04:00
const currentActiveCampaign = activeDisplayInfo ? . activeCampaignId ;
const currentActiveEncounter = activeDisplayInfo ? . activeEncounterId ;
if ( currentActiveCampaign === campaignId && currentActiveEncounter === encounterId ) {
2025-05-26 08:33:39 -04:00
await setDoc ( doc ( db , getActiveDisplayDocPath ( ) ) , {
2025-05-25 23:28:36 -04:00
activeCampaignId : null ,
activeEncounterId : null ,
2025-05-26 07:59:05 -04:00
} , { merge : true } ) ;
2025-05-26 07:50:24 -04:00
console . log ( "Player Display for this encounter turned OFF." ) ;
2025-05-25 23:28:36 -04:00
} else {
2025-05-26 08:33:39 -04:00
await setDoc ( doc ( db , getActiveDisplayDocPath ( ) ) , {
2025-05-25 23:28:36 -04:00
activeCampaignId : campaignId ,
activeEncounterId : encounterId ,
} , { merge : true } ) ;
2025-05-26 07:50:24 -04:00
console . log ( "Encounter set as active for Player Display!" ) ;
2025-05-25 23:28:36 -04:00
}
} catch ( err ) {
2025-05-26 07:50:24 -04:00
console . error ( "Error toggling Player Display for encounter:" , err ) ;
2025-05-25 23:28:36 -04:00
}
2025-05-25 21:19:22 -04:00
} ;
2025-05-26 08:33:39 -04:00
const selectedEncounter = encounters ? . find ( e => e . id === selectedEncounterId ) ;
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 (
< 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 07:50:24 -04:00
const isLiveOnPlayerDisplay = activeDisplayInfo && activeDisplayInfo . activeCampaignId === campaignId && activeDisplayInfo . 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-25 23:28:36 -04:00
< button
2025-05-26 07:50:24 -04:00
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" }
2025-05-25 23:28:36 -04:00
>
2025-05-26 07:50:24 -04:00
{ isLiveOnPlayerDisplay ? < EyeOff size = { 18 } / > : < Eye size = { 18 } / > }
2025-05-25 23:28:36 -04:00
< / b u t t o n >
2025-05-25 21:19:22 -04:00
< 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 >
< / 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 >
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 >
) ;
}
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 ) ; }
} ;
2025-05-26 08:33:39 -04:00
// --- Drag and Drop Handlers ---
2025-05-25 21:19:22 -04:00
const handleDragStart = ( e , id ) => {
setDraggedItemId ( id ) ;
2025-05-26 08:53:26 -04:00
e . dataTransfer . effectAllowed = 'move' ;
2025-05-25 21:19:22 -04:00
} ;
const handleDragOver = ( e ) => {
2025-05-26 08:53:26 -04:00
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-25 22:21:45 -04:00
if ( ! db || draggedItemId === null || draggedItemId === targetId ) {
2025-05-26 08:53:26 -04:00
setDraggedItemId ( null ) ;
2025-05-26 08:33:39 -04:00
return ;
2025-05-25 21:19:22 -04:00
}
2025-05-26 08:33:39 -04:00
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 ) ;
if ( draggedItemIndex === - 1 || targetItemIndex === - 1 ) {
2025-05-26 08:33:39 -04:00
console . error ( "Dragged or target item not found in participants list." ) ;
setDraggedItemId ( null ) ;
return ;
2025-05-25 21:19:22 -04:00
}
2025-05-26 08:33:39 -04:00
2025-05-25 21:19:22 -04:00
const draggedItem = currentParticipants [ draggedItemIndex ] ;
const targetItem = currentParticipants [ targetItemIndex ] ;
if ( draggedItem . initiative !== targetItem . initiative ) {
2025-05-26 08:33:39 -04:00
console . log ( "Drag-and-drop for tie-breaking only allowed between participants with the same initiative score." ) ;
setDraggedItemId ( null ) ;
return ;
2025-05-25 21:19:22 -04:00
}
2025-05-26 08:33:39 -04:00
2025-05-26 08:53:26 -04:00
const [ removedItem ] = currentParticipants . splice ( draggedItemIndex , 1 ) ;
currentParticipants . splice ( targetItemIndex , 0 , removedItem ) ;
2025-05-26 08:33:39 -04:00
2025-05-25 21:19:22 -04:00
try {
2025-05-26 08:33:39 -04:00
await updateDoc ( doc ( db , encounterPath ) , { participants : currentParticipants } ) ;
console . log ( "Participants reordered in Firestore for tie-breaking." ) ;
} catch ( err ) {
console . error ( "Error updating participants after drag-drop:" , err ) ;
}
2025-05-26 08:53:26 -04:00
setDraggedItemId ( null ) ;
2025-05-25 21:19:22 -04:00
} ;
const handleDragEnd = ( ) => {
2025-05-26 08:53:26 -04:00
setDraggedItemId ( null ) ;
2025-05-25 21:19:22 -04:00
} ;
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 ;
}
2025-05-26 08:53:26 -04:00
return b . initiative - a . initiative ;
2025-05-25 21:19:22 -04:00
} ) ;
const initiativeGroups = participants . reduce ( ( acc , p ) => {
acc [ p . initiative ] = ( acc [ p . initiative ] || 0 ) + 1 ;
return acc ;
} , { } ) ;
const tiedInitiatives = Object . keys ( initiativeGroups ) . filter ( init => initiativeGroups [ init ] > 1 ) . map ( Number ) ;
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 23:28:36 -04:00
const isDraggable = ! encounter . isStarted && tiedInitiatives . includes ( Number ( p . initiative ) ) ;
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 )
} ) ;
2025-05-26 08:33:39 -04:00
await setDoc ( doc ( db , getActiveDisplayDocPath ( ) ) , {
2025-05-25 21:19:22 -04:00
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 : [ ] } ) ;
2025-05-26 08:33:39 -04:00
await setDoc ( doc ( db , getActiveDisplayDocPath ( ) ) , {
2025-05-26 07:59:05 -04:00
activeCampaignId : null ,
activeEncounterId : null
} , { merge : true } ) ;
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 ) ; }
} ;
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 >
) ;
}
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-26 09:02:12 -04:00
const currentTurnRef = useRef ( null ) ;
2025-05-25 21:19:22 -04:00
useEffect ( ( ) => {
2025-05-25 22:21:45 -04:00
if ( ! db ) {
2025-05-26 08:33:39 -04:00
setEncounterError ( "Firestore not available." ) ;
setIsLoadingEncounter ( false ) ;
setIsPlayerDisplayActive ( false ) ;
return ;
2025-05-25 22:21:45 -04:00
}
2025-05-26 08:33:39 -04:00
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 08:33:39 -04:00
setIsPlayerDisplayActive ( true ) ;
setIsLoadingEncounter ( true ) ;
setEncounterError ( null ) ;
const campaignDocRef = doc ( db , getCampaignDocPath ( activeCampaignId ) ) ;
unsubscribeCampaign = onSnapshot ( campaignDocRef , ( campSnap ) => {
if ( campSnap . exists ( ) ) {
setCampaignBackgroundUrl ( campSnap . data ( ) . playerDisplayBackgroundUrl || '' ) ;
} else {
setCampaignBackgroundUrl ( '' ) ;
}
} , ( err ) => console . error ( "Error fetching campaign background for display:" , err ) ) ;
const encounterPath = getEncounterDocPath ( activeCampaignId , activeEncounterId ) ;
unsubscribeEncounter = onSnapshot ( doc ( db , encounterPath ) , ( encDocSnap ) => {
if ( encDocSnap . exists ( ) ) {
setActiveEncounterData ( { id : encDocSnap . id , ... encDocSnap . data ( ) } ) ;
} else {
setActiveEncounterData ( null ) ;
setEncounterError ( "Active encounter data not found." ) ;
}
setIsLoadingEncounter ( false ) ;
} , ( err ) => {
console . error ( "Error fetching active encounter details for display:" , err ) ;
setEncounterError ( "Error loading active encounter data." ) ;
setIsLoadingEncounter ( false ) ;
} ) ;
} else {
setActiveEncounterData ( null ) ;
setCampaignBackgroundUrl ( '' ) ;
setIsPlayerDisplayActive ( false ) ;
setIsLoadingEncounter ( false ) ;
2025-05-26 07:50:24 -04:00
}
2025-05-26 08:53:26 -04:00
} else if ( ! isLoadingActiveDisplay ) {
2025-05-26 07:50:24 -04:00
setActiveEncounterData ( null ) ;
setCampaignBackgroundUrl ( '' ) ;
setIsPlayerDisplayActive ( false ) ;
2025-05-26 08:33:39 -04:00
setIsLoadingEncounter ( false ) ;
}
2025-05-25 23:28:36 -04:00
return ( ) => {
2025-05-26 07:50:24 -04:00
if ( unsubscribeEncounter ) unsubscribeEncounter ( ) ;
if ( unsubscribeCampaign ) unsubscribeCampaign ( ) ;
2025-05-25 23:28:36 -04:00
} ;
2025-05-26 08:33:39 -04:00
} , [ activeDisplayData , isLoadingActiveDisplay ] ) ;
2025-05-25 21:19:22 -04:00
2025-05-26 08:53:26 -04:00
useEffect ( ( ) => {
if ( currentTurnRef . current ) {
currentTurnRef . current . scrollIntoView ( {
behavior : 'smooth' ,
block : 'center' ,
} ) ;
}
2025-05-26 09:02:12 -04:00
} , [ activeEncounterData ? . currentTurnParticipantId ] ) ;
2025-05-26 08:53:26 -04:00
2025-05-26 08:33:39 -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 >
< / d i v >
) ;
}
2025-05-25 21:19:22 -04:00
const { name , participants , round , currentTurnParticipantId , isStarted } = activeEncounterData ;
2025-05-26 08:53:26 -04:00
let allOrderedActiveParticipants = [ ] ;
if ( participants ) {
2025-05-25 22:21:45 -04:00
if ( isStarted && activeEncounterData . turnOrderIds ? . length > 0 ) {
2025-05-26 08:53:26 -04:00
allOrderedActiveParticipants = activeEncounterData . turnOrderIds
2025-05-25 22:21:45 -04:00
. map ( id => participants . find ( p => p . id === id ) ) . filter ( p => p && p . isActive ) ;
} else {
2025-05-26 08:53:26 -04:00
allOrderedActiveParticipants = [ ... participants ] . filter ( p => p . isActive )
2025-05-25 22:21:45 -04:00
. 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-26 08:53:26 -04:00
let participantsToRender = allOrderedActiveParticipants ;
2025-05-26 09:02:12 -04:00
const FOCUSED _VIEW _THRESHOLD = 7 ; // When to switch to focused view
const ITEMS _AROUND _CURRENT = 3 ; // Show 3 before and 3 after current = 7 total in focused view
2025-05-26 08:53:26 -04:00
let inFocusedView = false ;
if ( isStarted && allOrderedActiveParticipants . length >= FOCUSED _VIEW _THRESHOLD ) {
inFocusedView = true ;
const currentIndex = allOrderedActiveParticipants . findIndex ( p => p . id === currentTurnParticipantId ) ;
if ( currentIndex !== - 1 ) {
const focusedList = [ ] ;
for ( let i = - ITEMS _AROUND _CURRENT ; i <= ITEMS _AROUND _CURRENT ; i ++ ) {
const listLength = allOrderedActiveParticipants . length ;
2025-05-26 09:02:12 -04:00
// Ensure the index wraps around correctly for the circular list effect
2025-05-26 08:53:26 -04:00
const actualIndex = ( currentIndex + i + listLength ) % listLength ;
focusedList . push ( allOrderedActiveParticipants [ actualIndex ] ) ;
}
participantsToRender = focusedList ;
} else {
participantsToRender = allOrderedActiveParticipants . slice ( 0 , ITEMS _AROUND _CURRENT * 2 + 1 ) ;
}
}
2025-05-25 23:28:36 -04:00
const displayStyles = campaignBackgroundUrl ? {
backgroundImage : ` url( ${ campaignBackgroundUrl } ) ` ,
backgroundSize : 'cover' ,
backgroundPosition : 'center center' ,
backgroundRepeat : 'no-repeat' ,
minHeight : '100vh'
} : { minHeight : '100vh' } ;
2025-05-25 22:21:45 -04:00
2025-05-25 21:19:22 -04:00
return (
2025-05-25 23:28:36 -04:00
< div
className = { ` p-4 md:p-8 rounded-xl shadow-2xl ${ ! campaignBackgroundUrl ? 'bg-slate-900' : '' } ` }
style = { displayStyles }
>
< div className = { campaignBackgroundUrl ? 'bg-slate-900 bg-opacity-75 p-4 md:p-6 rounded-lg' : '' } >
< h2 className = "text-4xl md:text-5xl font-bold text-center text-amber-400 mb-2" > { name } < / h 2 >
2025-05-26 08:53:26 -04:00
{ isStarted && < p className = "text-2xl text-center text-sky-300 mb-2" > Round : { round } < / p > }
{ inFocusedView && < p className = "text-sm text-center text-slate-400 mb-4" > ( Focused View : { participantsToRender . length } of { allOrderedActiveParticipants . length } active ) < / 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:02:12 -04:00
< div className = "space-y-4 max-w-4xl mx-auto overflow-y-auto" style = { { maxHeight : 'calc(100vh - 220px)' } } > { /* Widened and adjusted maxHeight */ }
2025-05-26 08:53:26 -04:00
{ participantsToRender . map ( p => (
< div
key = { p . id }
2025-05-26 09:02:12 -04:00
ref = { p . id === currentTurnParticipantId ? currentTurnRef : null }
2025-05-26 08:53:26 -04:00
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' : '' } ` }
>
2025-05-25 23:28:36 -04:00
< 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 >
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 >
{ p . type !== 'monster' && (
2025-05-26 08:33:39 -04:00
< span className = "absolute inset-0 flex items-center justify-center text-xs md:text-sm font-medium text-white px-2" >
2025-05-25 23:28:36 -04:00
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 > }
2025-05-25 21:19:22 -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 >
< / 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-26 08:53:26 -04:00
export default App ;