import React, { useState, useEffect, useRef } from 'react'; import { initializeApp } from 'firebase/app'; import { getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken } from 'firebase/auth'; import { getFirestore, doc, setDoc, addDoc, getDoc, getDocs, collection, onSnapshot, updateDoc, deleteDoc, query, writeBatch } from 'firebase/firestore'; import { PlusCircle, Users, Swords, Shield, Trash2, Eye, Edit3, Save, XCircle, ChevronsUpDown, UserCheck, UserX, HeartCrack, HeartPulse, Zap, Image as ImageIcon, EyeOff, ExternalLink } from 'lucide-react'; // --- Firebase Configuration --- const firebaseConfig = { apiKey: process.env.REACT_APP_FIREBASE_API_KEY, authDomain: process.env.REACT_APP_FIREBASE_AUTH_DOMAIN, projectId: process.env.REACT_APP_FIREBASE_PROJECT_ID, storageBucket: process.env.REACT_APP_FIREBASE_STORAGE_BUCKET, messagingSenderId: process.env.REACT_APP_FIREBASE_MESSAGING_SENDER_ID, appId: process.env.REACT_APP_FIREBASE_APP_ID }; let app; let db; let auth; const requiredFirebaseConfigKeys = ['apiKey', 'authDomain', 'projectId', 'appId']; const missingKeys = requiredFirebaseConfigKeys.filter(key => !firebaseConfig[key]); if (missingKeys.length > 0) { 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."); } else { try { app = initializeApp(firebaseConfig); db = getFirestore(app); auth = getAuth(app); } catch (error) { console.error("Error initializing Firebase:", error); } } // --- Firestore Paths --- const APP_ID = process.env.REACT_APP_TRACKER_APP_ID || 'ttrpg-initiative-tracker-default'; const PUBLIC_DATA_PATH = `artifacts/${APP_ID}/public/data`; // --- 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`; // --- Helper Functions --- const generateId = () => crypto.randomUUID(); // --- 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 }; } // --- Main App Component --- function App() { const [userId, setUserId] = useState(null); const [isAuthReady, setIsAuthReady] = useState(false); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [isPlayerViewOnlyMode, setIsPlayerViewOnlyMode] = useState(false); useEffect(() => { const queryParams = new URLSearchParams(window.location.search); if (queryParams.get('playerView') === 'true') { setIsPlayerViewOnlyMode(true); } if (!auth) { setError("Firebase Auth not initialized. Check your Firebase configuration."); setIsLoading(false); setIsAuthReady(false); return; } const initAuth = async () => { try { const token = window.__initial_auth_token; if (token) { await signInWithCustomToken(auth, token); } else { await signInAnonymously(auth); } } catch (err) { console.error("Authentication error:", err); setError("Failed to authenticate. Please try again later."); } }; const unsubscribe = onAuthStateChanged(auth, (user) => { setUserId(user ? user.uid : null); setIsAuthReady(true); setIsLoading(false); }); initAuth(); return () => { unsubscribe(); }; }, []); if (!db || !auth) { return (

Configuration Error

Firebase is not properly configured or initialized.

Please check your `.env.local` file and ensure all `REACT_APP_FIREBASE_...` variables are correctly set.

Also, check the browser console for more specific error messages.

{error &&

{error}

}
); } if (isLoading || !isAuthReady) { return (

Loading Initiative Tracker...

{error &&

{error}

}
); } const openPlayerWindow = () => { const playerViewUrl = window.location.origin + window.location.pathname + '?playerView=true'; window.open(playerViewUrl, '_blank', 'noopener,noreferrer,width=1024,height=768'); }; if (isPlayerViewOnlyMode) { return (
{isAuthReady && } {!isAuthReady && !error &&

Authenticating for Player Display...

}
); } return (

TTRPG Initiative Tracker

{userId && UID: {userId}}
{isAuthReady && userId && } {!isAuthReady && !error &&

Authenticating...

}
); } // --- Admin View Component --- function AdminView({ userId }) { const { data: campaignsData, isLoading: isLoadingCampaigns } = useFirestoreCollection(getCampaignsCollectionPath()); const { data: initialActiveInfoData } = useFirestoreDocument(getActiveDisplayDocPath()); const [campaigns, setCampaigns] = useState([]); const [selectedCampaignId, setSelectedCampaignId] = useState(null); const [showCreateCampaignModal, setShowCreateCampaignModal] = useState(false); useEffect(() => { if (campaignsData) { setCampaigns(campaignsData.map(c => ({ ...c, characters: c.players || [] }))); } }, [campaignsData]); useEffect(() => { if (initialActiveInfoData && initialActiveInfoData.activeCampaignId && campaigns.length > 0 && !selectedCampaignId) { const campaignExists = campaigns.some(c => c.id === initialActiveInfoData.activeCampaignId); if (campaignExists) { setSelectedCampaignId(initialActiveInfoData.activeCampaignId); } } }, [initialActiveInfoData, campaigns, selectedCampaignId]); const handleCreateCampaign = async (name, backgroundUrl) => { if (!db || !name.trim()) return; const newCampaignId = generateId(); try { await setDoc(doc(db, getCampaignDocPath(newCampaignId)), { name: name.trim(), playerDisplayBackgroundUrl: backgroundUrl.trim() || '', ownerId: userId, createdAt: new Date().toISOString(), players: [], }); setShowCreateCampaignModal(false); setSelectedCampaignId(newCampaignId); } catch (err) { console.error("Error creating campaign:", err); } }; const handleDeleteCampaign = async (campaignId) => { if (!db) return; // TODO: Implement custom confirmation modal for deleting campaigns console.warn("Attempting to delete campaign without confirmation:", campaignId); try { const encountersPath = getEncountersCollectionPath(campaignId); const encountersSnapshot = await getDocs(collection(db, encountersPath)); const batch = writeBatch(db); encountersSnapshot.docs.forEach(encounterDoc => batch.delete(encounterDoc.ref)); await batch.commit(); await deleteDoc(doc(db, getCampaignDocPath(campaignId))); if (selectedCampaignId === campaignId) setSelectedCampaignId(null); const activeDisplayRef = doc(db, getActiveDisplayDocPath()); 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); if (isLoadingCampaigns) { return

Loading campaigns...

; } return (

Campaigns

{campaigns.length === 0 &&

No campaigns yet.

}
{campaigns.map(campaign => (
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'}`} >

{campaign.name}

ID: {campaign.id}

{campaign.playerDisplayBackgroundUrl && }
))}
{showCreateCampaignModal && setShowCreateCampaignModal(false)} title="Create New Campaign"> setShowCreateCampaignModal(false)} />} {selectedCampaign && (

Managing: {selectedCampaign.name}


)}
); } function CreateCampaignForm({ onCreate, onCancel }) { const [name, setName] = useState(''); const [backgroundUrl, setBackgroundUrl] = useState(''); const handleSubmit = (e) => { e.preventDefault(); onCreate(name, backgroundUrl); }; return (
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 />
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" />
); } function CharacterManager({ campaignId, campaignCharacters }) { const [characterName, setCharacterName] = useState(''); const [editingCharacter, setEditingCharacter] = useState(null); const handleAddCharacter = async () => { if (!db ||!characterName.trim() || !campaignId) return; const newCharacter = { id: generateId(), name: characterName.trim() }; try { await updateDoc(doc(db, getCampaignDocPath(campaignId)), { players: [...campaignCharacters, newCharacter] }); setCharacterName(''); } catch (err) { console.error("Error adding character:", err); } }; const handleUpdateCharacter = async (characterId, newName) => { if (!db ||!newName.trim() || !campaignId) return; const updatedCharacters = campaignCharacters.map(c => c.id === characterId ? { ...c, name: newName.trim() } : c); try { await updateDoc(doc(db, getCampaignDocPath(campaignId)), { players: updatedCharacters }); setEditingCharacter(null); } catch (err) { console.error("Error updating character:", err); } }; const handleDeleteCharacter = async (characterId) => { if (!db) return; // TODO: Implement custom confirmation modal for deleting characters console.warn("Attempting to delete character without confirmation:", characterId); const updatedCharacters = campaignCharacters.filter(c => c.id !== characterId); try { await updateDoc(doc(db, getCampaignDocPath(campaignId)), { players: updatedCharacters }); } catch (err) { console.error("Error deleting character:", err); } }; return (

Campaign Characters

{ e.preventDefault(); handleAddCharacter(); }} className="flex gap-2 mb-4"> 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" />
{campaignCharacters.length === 0 &&

No characters added.

}
); } function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharacters }) { const {data: encounters, isLoading: isLoadingEncounters } = useFirestoreCollection(campaignId ? getEncountersCollectionPath(campaignId) : null); const {data: activeDisplayInfo } = useFirestoreDocument(getActiveDisplayDocPath()); const [selectedEncounterId, setSelectedEncounterId] = useState(null); const [showCreateEncounterModal, setShowCreateEncounterModal] = useState(false); const selectedEncounterIdRef = useRef(selectedEncounterId); useEffect(() => { selectedEncounterIdRef.current = selectedEncounterId; }, [selectedEncounterId]); useEffect(() => { if (!campaignId) { setSelectedEncounterId(null); return; } if (encounters && encounters.length > 0) { const currentSelection = selectedEncounterIdRef.current; if (currentSelection === null || !encounters.some(e => e.id === currentSelection)) { if (initialActiveEncounterId && encounters.some(e => e.id === initialActiveEncounterId)) { setSelectedEncounterId(initialActiveEncounterId); } else if (activeDisplayInfo && activeDisplayInfo.activeCampaignId === campaignId && encounters.some(e => e.id === activeDisplayInfo.activeEncounterId)) { setSelectedEncounterId(activeDisplayInfo.activeEncounterId); } } } else if (encounters && encounters.length === 0) { setSelectedEncounterId(null); } }, [campaignId, initialActiveEncounterId, activeDisplayInfo, encounters]); const handleCreateEncounter = async (name) => { if (!db ||!name.trim() || !campaignId) return; const newEncounterId = generateId(); try { await setDoc(doc(db, getEncountersCollectionPath(campaignId), newEncounterId), { name: name.trim(), createdAt: new Date().toISOString(), participants: [], round: 0, currentTurnParticipantId: null, isStarted: false, }); setShowCreateEncounterModal(false); setSelectedEncounterId(newEncounterId); } catch (err) { console.error("Error creating encounter:", err); } }; const handleDeleteEncounter = async (encounterId) => { if (!db) return; // TODO: Implement custom confirmation modal for deleting encounters console.warn("Attempting to delete encounter without confirmation:", encounterId); try { await deleteDoc(doc(db, getEncounterDocPath(campaignId, encounterId))); if (selectedEncounterId === encounterId) setSelectedEncounterId(null); if (activeDisplayInfo && activeDisplayInfo.activeEncounterId === encounterId) { await updateDoc(doc(db, getActiveDisplayDocPath()), { activeCampaignId: null, activeEncounterId: null }); } } catch (err) { console.error("Error deleting encounter:", err); } }; const handleTogglePlayerDisplayForEncounter = async (encounterId) => { if (!db) return; try { const currentActiveCampaign = activeDisplayInfo?.activeCampaignId; const currentActiveEncounter = activeDisplayInfo?.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); } }; const selectedEncounter = encounters?.find(e => e.id === selectedEncounterId); if (isLoadingEncounters && campaignId) { return

Loading encounters...

; } return (

Encounters

{(!encounters || encounters.length === 0) &&

No encounters yet.

}
{encounters?.map(encounter => { const isLiveOnPlayerDisplay = activeDisplayInfo && activeDisplayInfo.activeCampaignId === campaignId && activeDisplayInfo.activeEncounterId === encounter.id; return (
setSelectedEncounterId(encounter.id)} className="cursor-pointer flex-grow">

{encounter.name}

Participants: {encounter.participants?.length || 0}

{isLiveOnPlayerDisplay && LIVE ON PLAYER DISPLAY}
); })}
{showCreateEncounterModal && setShowCreateEncounterModal(false)} title="Create New Encounter"> setShowCreateEncounterModal(false)} />} {selectedEncounter && (

Managing Encounter: {selectedEncounter.name}

)}
); } function CreateEncounterForm({ onCreate, onCancel }) { const [name, setName] = useState(''); return (
{ e.preventDefault(); onCreate(name); }} className="space-y-4">
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 />
); } 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 () => { if (!db || (participantType === 'monster' && !participantName.trim()) || (participantType === 'character' && !selectedCharacterId)) return; 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) => { if (!db || !editingParticipant) return; 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) => { if (!db) return; // 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) => { if (!db) return; 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) => { if (!db) return; 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); } }; // --- Drag and Drop Handlers --- const handleDragStart = (e, id) => { setDraggedItemId(id); e.dataTransfer.effectAllowed = 'move'; }; const handleDragOver = (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }; const handleDrop = async (e, targetId) => { e.preventDefault(); if (!db || draggedItemId === null || draggedItemId === targetId) { setDraggedItemId(null); return; } const currentParticipants = [...participants]; const draggedItemIndex = currentParticipants.findIndex(p => p.id === draggedItemId); const targetItemIndex = currentParticipants.findIndex(p => p.id === targetId); if (draggedItemIndex === -1 || targetItemIndex === -1) { console.error("Dragged or target item not found in participants list."); setDraggedItemId(null); return; } const draggedItem = currentParticipants[draggedItemIndex]; const targetItem = currentParticipants[targetItemIndex]; if (draggedItem.initiative !== targetItem.initiative) { console.log("Drag-and-drop for tie-breaking only allowed between participants with the same initiative score."); setDraggedItemId(null); return; } const [removedItem] = currentParticipants.splice(draggedItemIndex, 1); currentParticipants.splice(targetItemIndex, 0, removedItem); try { 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); } setDraggedItemId(null); }; const handleDragEnd = () => { setDraggedItemId(null); }; const sortedAdminParticipants = [...participants].sort((a, b) => { if (a.initiative === b.initiative) { const indexA = participants.findIndex(p => p.id === a.id); const indexB = participants.findIndex(p => p.id === b.id); return indexA - indexB; } return b.initiative - a.initiative; }); const initiativeGroups = participants.reduce((acc, p) => { acc[p.initiative] = (acc[p.initiative] || 0) + 1; return acc; }, {}); const tiedInitiatives = Object.keys(initiativeGroups).filter(init => initiativeGroups[init] > 1).map(Number); return (

Participants

{ e.preventDefault(); handleAddParticipant(); }} className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4 p-3 bg-slate-700 rounded">
{participantType === 'monster' && (
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" />
)} {participantType === 'character' && (
)}
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" />
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" />
{participants.length === 0 &&

No participants.

} {editingParticipant && setEditingParticipant(null)} onSave={handleUpdateParticipant} />}
); } 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 (
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" />
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" />
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" />
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" />
); } function InitiativeControls({ campaignId, encounter, encounterPath }) { const handleStartEncounter = async () => { if (!db ||!encounter.participants || encounter.participants.length === 0) { alert("Add participants first."); return; } const activePs = encounter.participants.filter(p => p.isActive); if (activePs.length === 0) { alert("No active participants."); return; } const sortedPs = [...activePs].sort((a, b) => { if (a.initiative === b.initiative) { const indexA = encounter.participants.findIndex(p => p.id === a.id); const indexB = encounter.participants.findIndex(p => p.id === b.id); return indexA - indexB; } return b.initiative - a.initiative; }); try { await updateDoc(doc(db, encounterPath), { isStarted: true, round: 1, currentTurnParticipantId: sortedPs[0].id, turnOrderIds: sortedPs.map(p => p.id) }); await setDoc(doc(db, getActiveDisplayDocPath()), { 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 () => { if (!db ||!encounter.isStarted || !encounter.currentTurnParticipantId || !encounter.turnOrderIds || encounter.turnOrderIds.length === 0) return; const activePsInOrder = encounter.turnOrderIds.map(id => encounter.participants.find(p => p.id === id && p.isActive)).filter(Boolean); if (activePsInOrder.length === 0) { alert("No active participants left."); await updateDoc(doc(db, encounterPath), { isStarted: false, 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 () => { if (!db) return; // 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: [] }); await setDoc(doc(db, getActiveDisplayDocPath()), { activeCampaignId: null, activeEncounterId: null }, { merge: true }); console.log("Encounter ended and deactivated from Player Display."); } catch (err) { console.error("Error ending encounter:", err); } }; if (!encounter || !encounter.participants) return null; return (

Combat Controls

{!encounter.isStarted ? ( ) : ( <>

Round: {encounter.round}

)}
); } function DisplayView() { const { data: activeDisplayData, isLoading: isLoadingActiveDisplay, error: activeDisplayError } = useFirestoreDocument(getActiveDisplayDocPath()); const [activeEncounterData, setActiveEncounterData] = useState(null); const [isLoadingEncounter, setIsLoadingEncounter] = useState(true); const [encounterError, setEncounterError] = useState(null); const [campaignBackgroundUrl, setCampaignBackgroundUrl] = useState(''); const [isPlayerDisplayActive, setIsPlayerDisplayActive] = useState(false); // currentTurnRef removed as we are reverting the scroll-to-view logic for now useEffect(() => { if (!db) { setEncounterError("Firestore not available."); setIsLoadingEncounter(false); setIsPlayerDisplayActive(false); return; } let unsubscribeEncounter; let unsubscribeCampaign; if (activeDisplayData) { const { activeCampaignId, activeEncounterId } = activeDisplayData; if (activeCampaignId && activeEncounterId) { 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); } } else if (!isLoadingActiveDisplay) { setActiveEncounterData(null); setCampaignBackgroundUrl(''); setIsPlayerDisplayActive(false); setIsLoadingEncounter(false); } return () => { if (unsubscribeEncounter) unsubscribeEncounter(); if (unsubscribeCampaign) unsubscribeCampaign(); }; }, [activeDisplayData, isLoadingActiveDisplay]); // useEffect for scrollIntoView removed if (isLoadingActiveDisplay || (isPlayerDisplayActive && isLoadingEncounter)) { return
Loading Player Display...
; } if (activeDisplayError || (isPlayerDisplayActive && encounterError)) { return
{activeDisplayError || encounterError}
; } if (!isPlayerDisplayActive || !activeEncounterData) { return (

Game Session Paused

The Dungeon Master has not activated an encounter for display.

); } const { name, participants, round, currentTurnParticipantId, isStarted } = activeEncounterData; // Reverted to showing all active participants, no focused view logic let participantsToRender = []; if (participants) { if (isStarted && activeEncounterData.turnOrderIds?.length > 0 ) { participantsToRender = activeEncounterData.turnOrderIds .map(id => participants.find(p => p.id === id)).filter(p => p && p.isActive); } else { participantsToRender = [...participants].filter(p => p.isActive) .sort((a, b) => { if (a.initiative === b.initiative) { const indexA = participants.findIndex(p => p.id === a.id); const indexB = participants.findIndex(p => p.id === b.id); return indexA - indexB; } return b.initiative - a.initiative; }); } } const displayStyles = campaignBackgroundUrl ? { backgroundImage: `url(${campaignBackgroundUrl})`, backgroundSize: 'cover', backgroundPosition: 'center center', backgroundRepeat: 'no-repeat', minHeight: '100vh' } : { minHeight: '100vh' }; return (

{name}

{isStarted &&

Round: {round}

} {/* Adjusted margin */} {/* Focused view indicator removed */} {!isStarted && participants?.length > 0 &&

Awaiting Start

} {!isStarted && (!participants || participants.length === 0) &&

No participants.

} {participantsToRender.length === 0 && isStarted &&

No active participants.

} {/* Reverted to simpler list container */}
{participantsToRender.map(p => (

{p.name}{p.id === currentTurnParticipantId && isStarted && (Current)}

Init: {p.initiative}
{p.type !== 'monster' && ( HP: {p.currentHp} / {p.maxHp} )}
{p.conditions?.length > 0 &&

Conditions: {p.conditions.join(', ')}

} {!p.isActive &&

(Inactive)

}
))}
); } 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 (

{title}

{children}
); } const PlayIcon = ({ size = 24, className = '' }) => ; const SkipForwardIcon = ({ size = 24, className = '' }) => ; const StopCircleIcon = ({size=24, className=''}) => ; export default App;