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-28 14:58:54 -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 , ChevronDown , ChevronUp } from 'lucide-react' ; // Added ChevronDown, ChevronUp
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 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 21:17:42 -04:00
const q = query ( collection ( db , collectionPath ), ... queryConstraints );
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 ([]);
});
return () => unsubscribe ();
2025-05-27 11:02:03 -04:00
// eslint-disable-next-line react-hooks/exhaustive-deps
2025-05-26 21:17:42 -04:00
}, [ collectionPath , queryString ]);
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 < /h1>
< 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>}
< /div>
);
}
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" >< /div>
< p className = "mt-4 text-xl" > Loading Initiative Tracker ... < /p>
{ error && < p className = "mt-2 text-red-400" > { error } < /p>}
< /div>
);
}
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>}
< /div>
);
}
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 < /h1>
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
< /button>
2025-05-25 21:19:22 -04:00
< /div>
< /div>
< /header>
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
< /main>
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-28 14:58:54 -04:00
TTRPG Initiative Tracker v0 . 2.1
2025-05-26 07:50:24 -04:00
< /footer>
2025-05-25 21:19:22 -04:00
< /div>
);
}
2025-05-26 09:41:50 -04:00
// --- Confirmation Modal Component ---
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" } < /h2>
< /div>
< p className = "text-slate-300 mb-6" > { message || "Are you sure you want to proceed?" } < /p>
< div className = "flex justify-end space-x-3" >
2025-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 < /button>
< button onClick = { onConfirm } className = "px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-md transition-colors" > Confirm < /button>
2025-05-26 09:41:50 -04:00
< /div>
< /div>
< /div>
);
}
2025-05-28 14:58:54 -04:00
// --- Admin View Component ---
2025-05-25 21:19:22 -04:00
function AdminView ({ userId }) {
2025-05-28 12:15:01 -04:00
const { data : campaignsData , isLoading : isLoadingCampaigns , error : campaignsError } = useFirestoreCollection ( getCampaignsCollectionPath ());
2025-05-26 08:33:39 -04:00
const { data : initialActiveInfoData } = useFirestoreDocument ( getActiveDisplayDocPath ());
2025-05-28 12:15:01 -04:00
const [ campaignsWithDetails , setCampaignsWithDetails ] = useState ([]);
2025-05-25 21:19:22 -04:00
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-28 12:15:01 -04:00
if ( campaignsData && db ) {
const fetchDetails = async () => {
const detailedCampaigns = await Promise . all (
campaignsData . map ( async ( campaign ) => {
const characters = campaign . players || [];
let encounterCount = 0 ;
try {
const encountersSnapshot = await getDocs ( collection ( db , getEncountersCollectionPath ( campaign . id )));
encounterCount = encountersSnapshot . size ;
} catch ( err ) {
console . error ( `Failed to fetch encounters for campaign ${ campaign . id } ( ${ campaign . name } ):` , err );
}
return { ... campaign , characters , encounterCount };
})
);
setCampaignsWithDetails ( detailedCampaigns );
};
fetchDetails ();
} else if ( campaignsData ) {
setCampaignsWithDetails ( campaignsData . map ( c => ({ ... c , characters : c . players || [], encounterCount : 0 })));
2025-05-26 08:33:39 -04:00
}
}, [ campaignsData ]);
2025-05-25 21:19:22 -04:00
useEffect (() => {
2025-05-28 12:15:01 -04:00
if ( initialActiveInfoData && initialActiveInfoData . activeCampaignId && campaignsWithDetails . length > 0 && ! selectedCampaignId ) {
const campaignExists = campaignsWithDetails . 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-28 12:15:01 -04:00
}, [ initialActiveInfoData , campaignsWithDetails , selectedCampaignId ]);
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
};
2025-05-28 12:15:01 -04:00
const selectedCampaign = campaignsWithDetails . find ( c => c . id === selectedCampaignId );
2025-05-25 21:19:22 -04:00
2025-05-26 08:33:39 -04:00
if ( isLoadingCampaigns ) {
return < p className = "text-center text-slate-300" > Loading campaigns ... < /p>;
}
2025-05-28 12:15:01 -04:00
if ( campaignsError ) {
return < p className = "text-center text-red-400" > Error loading campaigns : { campaignsError . message || String ( campaignsError )} < /p>;
}
2025-05-26 08:33:39 -04:00
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 < /h2>
< 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
< /button>
< /div>
2025-05-28 12:15:01 -04:00
{ campaignsWithDetails . length === 0 && ! isLoadingCampaigns && < p className = "text-slate-400" > No campaigns yet . < /p>}
2025-05-26 09:41:50 -04:00
< div className = "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4" >
2025-05-28 12:15:01 -04:00
{ campaignsWithDetails . map ( campaign => {
2025-05-26 21:34:37 -04:00
const cardStyle = campaign . playerDisplayBackgroundUrl ? { backgroundImage : `url( ${ campaign . playerDisplayBackgroundUrl } )` } : {};
2025-05-28 12:15:01 -04:00
const cardClasses = `h-40 flex flex-col justify-between rounded-lg shadow-md cursor-pointer transition-all relative overflow-hidden bg-cover bg-center ${ selectedCampaignId === campaign . id ? 'ring-4 ring-sky-400' : '' } ${ ! campaign . playerDisplayBackgroundUrl ? 'bg-slate-700 hover:bg-slate-600' : 'hover:shadow-xl' } ` ;
2025-05-26 09:52:53 -04:00
return (
2025-05-26 21:34:37 -04:00
< div key = { campaign . id } onClick = {() => setSelectedCampaignId ( campaign . id )} className = { cardClasses } style = { cardStyle } >
2025-05-27 11:02:03 -04:00
< div className = { `relative z-10 flex flex-col justify-between h-full ${ campaign . playerDisplayBackgroundUrl ? 'bg-black bg-opacity-60 p-3' : 'p-4' } ` } >
2025-05-28 12:15:01 -04:00
< div >
< h3 className = "text-xl font-semibold text-white" > { campaign . name } < /h3>
< div className = "text-xs text-slate-100 mt-1 space-x-3" >
< span className = "inline-flex items-center" >
< Users size = { 12 } className = "mr-1" /> { campaign . characters ? . length || 0 } Characters
< /span>
< span className = "inline-flex items-center" >
< Swords size = { 12 } className = "mr-1" /> { campaign . encounterCount === undefined ? '...' : campaign . encounterCount } Encounters
< /span>
< /div>
< /div>
2025-05-27 11:02:03 -04:00
< button onClick = {( e ) => { e . stopPropagation (); requestDeleteCampaign ( campaign . id , campaign . name ); }} className = "mt-auto text-red-300 hover:text-red-100 text-xs flex items-center self-start bg-black bg-opacity-50 hover:bg-opacity-70 px-2 py-1 rounded" >< Trash2 size = { 14 } className = "mr-1" /> Delete < /button>
2025-05-26 09:52:53 -04:00
< /div>
2025-05-26 21:34:37 -04:00
< /div>);
2025-05-26 09:52:53 -04:00
})}
2025-05-26 09:41:50 -04:00
< /div>
2025-05-25 21:19:22 -04:00
< /div>
2025-05-26 09:41:50 -04:00
{ showCreateCampaignModal && < Modal onClose = {() => setShowCreateCampaignModal ( false )} title = "Create New Campaign" >< CreateCampaignForm onCreate = { handleCreateCampaign } onCancel = {() => setShowCreateCampaignModal ( false )} />< /Modal>}
{ 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 } < /h2>
2025-05-28 12:15:01 -04:00
< CharacterManager campaignId = { selectedCampaignId } campaignCharacters = { selectedCampaign . characters || []} />
2025-05-26 09:41:50 -04:00
< hr className = "my-6 border-slate-600" />
2025-05-28 12:15:01 -04:00
< EncounterManager campaignId = { selectedCampaignId } initialActiveEncounterId = { initialActiveInfoData && initialActiveInfoData . activeCampaignId === selectedCampaignId ? initialActiveInfoData . activeEncounterId : null } campaignCharacters = { selectedCampaign . characters || []} />
2025-05-26 09:41:50 -04:00
< /div>
)}
2025-05-25 21:19:22 -04:00
< /div>
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
);
}
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 < /label>
< 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 />
< /div>
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 ) < /label>
< 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" />
< /div>
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 < /button>
< button type = "submit" className = "px-4 py-2 text-sm font-medium text-white bg-green-500 hover:bg-green-600 rounded-md transition-colors" > Create < /button>
< /div>
< /form>
);
}
2025-05-28 12:23:10 -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-28 14:58:54 -04:00
const [ isCharactersSectionOpen , setIsCharactersSectionOpen ] = useState ( true ); // ADDED THIS LINE
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" >
2025-05-28 14:58:54 -04:00
< div className = "flex justify-between items-center mb-3" > { /* ADDED THIS DIV FOR HEADER AND TOGGLE */ }
< h3 className = "text-xl font-semibold text-sky-300 flex items-center" >< Users size = { 24 } className = "mr-2" /> Campaign Characters < /h3>
< button
onClick = {() => setIsCharactersSectionOpen ( ! isCharactersSectionOpen )}
className = "p-1 text-slate-400 hover:text-slate-200"
aria - label = { isCharactersSectionOpen ? "Collapse Characters Section" : "Expand Characters Section" }
>
{ isCharactersSectionOpen ? < ChevronUp size = { 20 } /> : < ChevronDown size = { 20 } /> }
< /button>
< /div>
{ isCharactersSectionOpen && ( // ADDED CONDITIONAL RENDERING
<>
< form onSubmit = {( e ) => { e . preventDefault (); handleAddCharacter (); }} className = "grid grid-cols-1 sm:grid-cols-3 gap-2 mb-4 items-end" >
< div className = "sm:col-span-1" >
< label htmlFor = "characterName" className = "block text-xs font-medium text-slate-400" > Name < /label>
< input type = "text" id = "characterName" value = { characterName } onChange = {( e ) => setCharacterName ( e . target . value )} placeholder = "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" />
< /div>
< div className = "w-full sm:w-auto" >
< label htmlFor = "defaultMaxHp" className = "block text-xs font-medium text-slate-400" > Default HP < /label>
< input type = "number" id = "defaultMaxHp" value = { defaultMaxHp } onChange = {( e ) => setDefaultMaxHp ( e . target . value )} 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" />
< /div>
< div className = "w-full sm:w-auto" >
< label htmlFor = "defaultInitMod" className = "block text-xs font-medium text-slate-400" > Init Mod < /label>
< input type = "number" id = "defaultInitMod" value = { defaultInitMod } onChange = {( e ) => setDefaultInitMod ( e . target . value )} 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" />
< /div>
< 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 < /button>
< /form>
{ 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 ? (
< form onSubmit = {( e ) => { e . preventDefault (); handleUpdateCharacter ( character . id , editingCharacter . name , editingCharacter . defaultMaxHp , editingCharacter . defaultInitMod );}} className = "flex-grow flex flex-wrap gap-2 items-center" >
< input type = "text" value = { editingCharacter . name } onChange = {( e ) => setEditingCharacter ({... editingCharacter , name : e . target . value })} className = "flex-grow min-w-[100px] px-2 py-1 bg-slate-600 border border-slate-500 rounded-md text-white" />
< input type = "number" value = { editingCharacter . defaultMaxHp } onChange = {( e ) => setEditingCharacter ({... editingCharacter , defaultMaxHp : e . target . value })} className = "w-20 px-2 py-1 bg-slate-600 border border-slate-500 rounded-md text-white" title = "Default Max HP" />
< input type = "number" value = { editingCharacter . defaultInitMod } onChange = {( e ) => setEditingCharacter ({... editingCharacter , defaultInitMod : e . target . value })} className = "w-20 px-2 py-1 bg-slate-600 border border-slate-500 rounded-md text-white" title = "Default Init Mod" />
< button type = "submit" className = "p-1 text-green-400 hover:text-green-300" >< Save size = { 18 } />< /button>
< button type = "button" onClick = {() => setEditingCharacter ( null )} className = "p-1 text-slate-400 hover:text-slate-200" >< XCircle size = { 18 } />< /button>
< /form>
) : (
<>
< span className = "text-slate-100" > { character . name } < span className = "text-xs text-slate-400" > ( HP : { character . defaultMaxHp || 'N/A' }, Init Mod : { character . defaultInitMod !== undefined ? ( character . defaultInitMod >= 0 ? `+ ${ character . defaultInitMod } ` : character . defaultInitMod ) : 'N/A' }) < /span></span>
< div className = "flex space-x-2" >
< 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 } />< /button>
< button onClick = {() => requestDeleteCharacter ( character . id , character . name )} className = "p-1 rounded transition-colors text-red-400 hover:text-red-300 bg-slate-600 hover:bg-slate-500" aria - label = "Delete character" >< Trash2 size = { 18 } />< /button>
< /div>
< />
)}
< /li>
))}
< /ul>
< />
)}
2025-05-25 21:19:22 -04:00
< /div>
2025-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
);
}
2025-05-28 14:58:54 -04:00
// ... (EncounterManager, CreateEncounterForm, ParticipantManager, EditParticipantModal, InitiativeControls, DisplayView, Modal, Icons)
// The rest of the components are assumed to be the same as v0.1.30 for this update.
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 < /h3>
< 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 < /button>
< /div>
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 } < /h4>
< 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 < /span>}
2025-05-25 21:19:22 -04:00
< /div>
< 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 } /> } < /button>
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 } />< /button>
2025-05-25 21:19:22 -04:00
< /div>
< /div>
2025-05-26 21:34:37 -04:00
< /div>);
2025-05-25 21:19:22 -04:00
})}
< /div>
{ showCreateEncounterModal && < Modal onClose = {() => setShowCreateEncounterModal ( false )} title = "Create New Encounter" >< CreateEncounterForm onCreate = { handleCreateEncounter } onCancel = {() => setShowCreateEncounterModal ( false )} />< /Modal>}
{ 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 } < /h3>
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
< /div>
)}
< /div>
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 < /label>
< 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 />
< /div>
< div className = "flex justify-end space-x-3" >
< button type = "button" onClick = { onCancel } className = "px-4 py-2 text-sm font-medium text-slate-300 bg-slate-600 hover:bg-slate-500 rounded-md transition-colors" > Cancel < /button>
< button type = "submit" className = "px-4 py-2 text-sm font-medium text-white bg-orange-500 hover:bg-orange-600 rounded-md transition-colors" > Create < /button>
< /div>
< /form>
);
}
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 );
2025-05-27 11:02:03 -04:00
const [ isNpc , setIsNpc ] = useState ( false );
2025-05-25 21:19:22 -04:00
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 ); }
2025-05-27 11:02:03 -04:00
setIsNpc ( false );
2025-05-27 10:51:29 -04:00
} 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-27 10:51:29 -04:00
let participantIsNpc = false ;
2025-05-26 21:48:28 -04:00
2025-05-25 21:19:22 -04:00
if ( participantType === 'character' ) {
const character = campaignCharacters . find ( c => c . id === selectedCharacterId );
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-27 11:02:03 -04:00
} else {
2025-05-26 22:42:37 -04:00
modifier = parseInt ( monsterInitMod , 10 ) || 0 ;
2025-05-26 22:31:43 -04:00
finalInitiative = initiativeRoll + modifier ;
2025-05-27 11:02:03 -04:00
participantIsNpc = isNpc ;
2025-05-25 21:19:22 -04:00
}
2025-05-27 10:51:29 -04:00
const newParticipant = {
id : generateId (), name : nameToAdd , type : participantType ,
originalCharacterId : participantType === 'character' ? selectedCharacterId : null ,
initiative : finalInitiative , maxHp : currentMaxHp , currentHp : currentMaxHp ,
2025-05-27 11:02:03 -04:00
isNpc : participantType === 'monster' ? participantIsNpc : false ,
2025-05-27 10:51:29 -04:00
conditions : [], isActive : true ,
};
2025-05-26 22:31:43 -04:00
try {
await updateDoc ( doc ( db , encounterPath ), { participants : [... participants , newParticipant ] });
2025-05-27 10:51:29 -04:00
setLastRollDetails ({ name : nameToAdd , roll : initiativeRoll , mod : modifier , total : finalInitiative , type : participantIsNpc ? 'NPC' : participantType });
2025-05-26 22:42:37 -04:00
setTimeout (() => setLastRollDetails ( null ), 5000 );
2025-05-27 10:51:29 -04:00
setParticipantName ( '' ); setMaxHp ( 10 ); setSelectedCharacterId ( '' ); setMonsterInitMod ( MONSTER_DEFAULT_INIT_MOD ); setIsNpc ( false );
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-27 10:51:29 -04:00
let consoleRollLog = "Adding all campaign characters:\n" ;
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 ;
2025-05-27 10:51:29 -04:00
consoleRollLog += ` ${ char . name } : Rolled D20 ( ${ initiativeRoll } ) + ${ modifier } (mod) = ${ finalInitiative } init\n` ;
2025-05-27 11:02:03 -04:00
return { id : generateId (), name : char . name , type : 'character' , originalCharacterId : char . id , initiative : finalInitiative , maxHp : char . defaultMaxHp || 10 , currentHp : char . defaultMaxHp || 10 , conditions : [], isActive : true , isNpc : false };
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 ; }
2025-05-27 10:51:29 -04:00
console . log ( consoleRollLog );
2025-05-26 22:31:43 -04:00
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-27 11:02:03 -04:00
const updatedParticipants = participants . map ( p => p . id === editingParticipant . id ? { ... p , ... updatedData } : 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 < /h4>
< 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
< /button>
< /div>
2025-05-27 11:02:03 -04:00
< form onSubmit = {( e ) => { e . preventDefault (); handleAddParticipant (); }} className = "grid grid-cols-1 md:grid-cols-6 gap-x-4 gap-y-2 mb-4 p-3 bg-slate-700 rounded items-end" >
< div className = "md:col-span-2" >
2025-05-25 21:19:22 -04:00
< label className = "block text-sm font-medium text-slate-300" > Type < /label>
2025-05-27 10:51:29 -04:00
< select value = { participantType } onChange = {( e ) => { setParticipantType ( e . target . value ); setSelectedCharacterId ( '' ); setIsNpc ( false );}} className = "mt-1 w-full px-3 py-2 bg-slate-600 border-slate-500 rounded text-white" >
2025-05-25 21:19:22 -04:00
< option value = "monster" > Monster < /option>
< option value = "character" > Character < /option>
< /select>
< /div>
2025-05-27 11:02:03 -04:00
2025-05-26 22:31:43 -04:00
{ participantType === 'monster' && (
<>
2025-05-27 11:02:03 -04:00
< div className = "md:col-span-4" >
2025-05-27 10:51:29 -04:00
< label htmlFor = "monsterName" className = "block text-sm font-medium text-slate-300" > Monster Name < /label>
< input type = "text" id = "monsterName" value = { participantName } onChange = {( e ) => setParticipantName ( e . target . value )} placeholder = "e.g., Dire Wolf" 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" />
2025-05-26 22:31:43 -04:00
< /div>
2025-05-27 11:02:03 -04:00
< div className = "md:col-span-2" >
2025-05-27 10:51:29 -04:00
< label htmlFor = "monsterInitMod" className = "block text-sm font-medium text-slate-300" > Monster Init Mod < /label>
< input type = "number" id = "monsterInitMod" 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" />
< /div>
2025-05-27 11:02:03 -04:00
< div className = "md:col-span-2" >
2025-05-27 11:15:32 -04:00
< label htmlFor = "monsterMaxHp" className = "block text-sm font-medium text-slate-300" > Max HP < /label>
< input type = "number" id = "monsterMaxHp" value = { maxHp } onChange = {( e ) => setMaxHp ( e . target . value )} 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" />
2025-05-27 11:02:03 -04:00
< /div>
2025-05-27 14:52:04 -04:00
< div className = "md:col-span-2 flex items-center pt-5" >
2025-05-27 10:51:29 -04:00
< input type = "checkbox" id = "isNpc" checked = { isNpc } onChange = {( e ) => setIsNpc ( e . target . checked )} className = "h-4 w-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500" />
< label htmlFor = "isNpc" className = "ml-2 block text-sm text-slate-300" > Is NPC ?< /label>
2025-05-26 22:31:43 -04:00
< /div>
< />
)}
2025-05-27 11:02:03 -04:00
{ participantType === 'character' && (
<>
< div className = "md:col-span-4" >
< label className = "block text-sm font-medium text-slate-300" > Select Character < /label>
< select value = { selectedCharacterId } onChange = {( e ) => setSelectedCharacterId ( e . target . value )} className = "mt-1 w-full px-3 py-2 bg-slate-600 border-slate-500 rounded text-white" >
< option value = "" >-- Select from Campaign --< /option>
{ campaignCharacters . map ( c => < option key = { c . id } value = { c . id } > { c . name } ( HP : { c . defaultMaxHp || 'N/A' }, Mod : { c . defaultInitMod >= 0 ? `+ ${ c . defaultInitMod } ` : c . defaultInitMod }) < /option>)}
< /select>
< /div>
2025-05-27 14:52:04 -04:00
< div className = "md:col-span-2" >
2025-05-27 11:02:03 -04:00
< label className = "block text-sm font-medium text-slate-300" > Max HP ( Encounter ) < /label>
< input type = "number" value = { maxHp } onChange = {( e ) => setMaxHp ( e . target . value )} 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" />
< /div>
< />
)}
< div className = "md:col-span-6 flex justify-end mt-2" >
< 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 )
< /button>
< /div>
2025-05-25 21:19:22 -04:00
< /form>
2025-05-26 22:31:43 -04:00
{ lastRollDetails && (
< p className = "text-sm text-green-400 mt-2 mb-2 text-center" >
2025-05-27 11:02:03 -04:00
{ lastRollDetails . name } ({ lastRollDetails . type === 'character' ? 'Character' : lastRollDetails . type }) : Rolled D20 ({ lastRollDetails . roll }) { lastRollDetails . mod >= 0 ? '+' : '' } { lastRollDetails . mod } ( mod ) = { lastRollDetails . total } Initiative
2025-05-26 22:31:43 -04:00
< /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-27 10:51:29 -04:00
const participantDisplayType = p . type === 'monster' ? ( p . isNpc ? 'NPC' : 'Monster' ) : 'Character' ;
2025-05-27 14:52:04 -04:00
let bgColor = p . type === 'character' ? 'bg-sky-800' : ( p . isNpc ? 'bg-slate-600' : 'bg-[#8e351c]' );
2025-05-27 10:51:29 -04:00
if ( isCurrentTurn && ! encounter . isPaused ) bgColor = 'bg-green-600' ;
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 }
2025-05-27 10:51:29 -04:00
className = { `p-3 rounded-md flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 transition-all ${ bgColor } ${ isCurrentTurn && ! encounter . isPaused ? 'ring-2 ring-green-300 shadow-lg' : '' } ${ ! 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-27 10:51:29 -04:00
< p className = { `font-semibold text-lg text-white` } > { p . name } < span className = "text-xs" > ({ participantDisplayType }) < /span>{isCurrentTurn && !encounter.isPaused && <span className="ml-2 px-2 py-0.5 bg-yellow-400 text-black text-xs font-bold rounded-full inline-flex items-center"><Zap size={12} className="mr-1"/ > CURRENT < /span>}</p>
2025-05-26 21:34:37 -04:00
< 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
< /div>
< /div>
< 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}/ >< /button></div>)}
< 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 } /> } < /button>
< button onClick = {() => setEditingParticipant ( p )} className = "p-1 rounded transition-colors text-yellow-400 hover:text-yellow-300 bg-slate-600 hover:bg-slate-500" title = "Edit" >< Edit3 size = { 18 } />< /button>
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 } />< /button>
2025-05-25 21:19:22 -04:00
< /div>
2025-05-26 21:34:37 -04:00
< /li>);
2025-05-25 21:19:22 -04:00
})}
< /ul>
{ editingParticipant && < EditParticipantModal participant = { editingParticipant } onClose = {() => setEditingParticipant ( null )} onSave = { handleUpdateParticipant } /> }
< /div>
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
);
}
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-27 10:51:29 -04:00
const [ isNpc , setIsNpc ] = useState ( participant . type === 'monster' ? ( participant . isNpc || false ) : false );
const handleSubmit = ( e ) => {
e . preventDefault ();
onSave ({
name : name . trim (),
initiative : parseInt ( initiative , 10 ),
currentHp : parseInt ( currentHp , 10 ),
maxHp : parseInt ( maxHp , 10 ),
2025-05-27 11:02:03 -04:00
isNpc : participant . type === 'monster' ? isNpc : false ,
2025-05-27 10:51:29 -04:00
});
};
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" / >< /div>
< div >< label className = "block text-sm font-medium text-slate-300" > Initiative < /label><input type="number" value={initiative} onChange={(e) => setInitiative(e.target.value)} 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" / >< /div>
< div className = "flex gap-4" >
< div className = "flex-1" >< label className = "block text-sm font-medium text-slate-300" > Current HP < /label><input type="number" value={currentHp} onChange={(e) => setCurrentHp(e.target.value)} 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" / >< /div>
< div className = "flex-1" >< label className = "block text-sm font-medium text-slate-300" > Max HP < /label><input type="number" value={maxHp} onChange={(e) => setMaxHp(e.target.value)} 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" / >< /div>
< /div>
2025-05-27 10:51:29 -04:00
{ participant . type === 'monster' && (
< div className = "flex items-center" >
< input type = "checkbox" id = "editIsNpc" checked = { isNpc } onChange = {( e ) => setIsNpc ( e . target . checked )} className = "h-4 w-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500" />
< label htmlFor = "editIsNpc" className = "ml-2 block text-sm text-slate-300" > Is NPC ?< /label>
< /div>
)}
2025-05-25 21:19:22 -04:00
< div className = "flex justify-end space-x-3 pt-2" >
< button type = "button" onClick = { onClose } className = "px-4 py-2 text-sm font-medium text-slate-300 bg-slate-600 hover:bg-slate-500 rounded-md transition-colors" > Cancel < /button>
< button type = "submit" 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 < /button>
< /div>
< /form>
< /Modal>
);
}
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 < /h4>
< 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 < /button>
) : (
<>
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' }
< /button>
< 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 < /button>
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 < /button>
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
< />
)}
< /div>
< /div>
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 ... < /div>; }
if ( activeDisplayError || ( isPlayerDisplayActive && encounterError )) { return < div className = "text-center py-10 text-2xl text-red-400" > { activeDisplayError || encounterError } < /div>; }
2025-05-26 07:50:24 -04:00
if ( ! isPlayerDisplayActive || ! activeEncounterData ) {
return (
< div className = "min-h-screen bg-black text-slate-400 flex flex-col items-center justify-center p-4 text-center" >
< EyeOff size = { 64 } className = "mb-4 text-slate-500" />
< h2 className = "text-3xl font-semibold" > Game Session Paused < /h2>
< 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
< /div>);
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-27 14:52:04 -04:00
const activeParticipants = participants . filter ( p => p . isActive );
participantsToRender = [... activeParticipants ]. sort (( a , b ) => {
if ( a . initiative === b . initiative ) {
2025-05-28 12:15:01 -04:00
const indexA = participants . findIndex ( p => p . id === a . id );
2025-05-27 14:52:04 -04:00
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 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 } < /h2>
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-27 10:51:29 -04:00
{ participantsToRender . map ( p => {
2025-05-27 11:15:32 -04:00
let participantBgColor = p . type === 'monster' ? ( p . isNpc ? 'bg-slate-700' : 'bg-[#8e351c]' ) : 'bg-sky-700' ;
2025-05-27 10:51:29 -04:00
if ( p . id === currentTurnParticipantId && isStarted && ! isPaused ) {
participantBgColor = 'bg-green-700 ring-4 ring-green-400 scale-105' ;
} else if ( isPaused && p . id === currentTurnParticipantId ) {
participantBgColor += ' ring-2 ring-yellow-400' ;
}
return (
< div key = { p . id } className = { `p-4 md:p-6 rounded-lg shadow-lg transition-all ${ participantBgColor } ${ ! p . isActive ? 'opacity-40 grayscale' : '' } ` } >
2025-05-25 23:28:36 -04:00
< div className = "flex justify-between items-center mb-2" >
2025-05-27 14:52:04 -04:00
< h3 className = { `text-2xl md:text-3xl font-bold ${ p . id === currentTurnParticipantId && isStarted && ! isPaused ? 'text-white' : ( p . type === 'character' ? 'text-sky-100' : ( p . isNpc ? 'text-slate-100' : 'text-white' )) } ` } > { p . name }{ p . id === currentTurnParticipantId && isStarted && ! isPaused && < span className = "text-yellow-300 animate-pulse ml-2" > ( Current ) < /span>}</h3>
2025-05-26 21:34:37 -04:00
< span className = { `text-xl md:text-2xl font-semibold ${ p . id === currentTurnParticipantId && isStarted && ! isPaused ? 'text-green-200' : 'text-slate-200' } ` } > Init : { p . initiative } < /span>
2025-05-25 21:19:22 -04:00
< /div>
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 ) } %` }} >< /div>
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 } < /span>)}
2025-05-25 23:28:36 -04:00
< /div>
< /div>
{ 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-27 11:02:03 -04:00
< /div>);
2025-05-27 10:51:29 -04:00
})}
2025-05-25 23:28:36 -04:00
< /div>
2025-05-25 21:19:22 -04:00
< /div>
2025-05-26 21:34:37 -04:00
< /div>);
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 } < /h2>
< button onClick = { onClose } className = "text-slate-400 hover:text-slate-200" >< XCircle size = { 24 } />< /button>
< /div>
{ children }
< /div>
< /div>
);
}
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 ;