import React, { useState, useEffect, useCallback } 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, where, writeBatch } from 'firebase/firestore'; import { ArrowLeft, PlusCircle, Users, Swords, Shield, Trash2, Eye, Edit3, Save, XCircle, ChevronsUpDown, ChevronDown, ChevronUp, UserCheck, UserX, HeartCrack, HeartPulse } from 'lucide-react'; // --- Firebase Configuration --- // NOTE: Replace with your actual Firebase config const firebaseConfig = typeof __firebase_config !== 'undefined' ? JSON.parse(__firebase_config) : { apiKey: "YOUR_API_KEY", authDomain: "YOUR_AUTH_DOMAIN", projectId: "YOUR_PROJECT_ID", storageBucket: "YOUR_STORAGE_BUCKET", messagingSenderId: "YOUR_MESSAGING_SENDER_ID", appId: "YOUR_APP_ID" }; // --- Initialize Firebase --- const app = initializeApp(firebaseConfig); const db = getFirestore(app); const auth = getAuth(app); // --- Firestore Paths --- const APP_ID = typeof __app_id !== 'undefined' ? __app_id : 'ttrpg-initiative-tracker-default'; const PUBLIC_DATA_PATH = `artifacts/${APP_ID}/public/data`; const CAMPAIGNS_COLLECTION = `${PUBLIC_DATA_PATH}/campaigns`; const ACTIVE_DISPLAY_DOC = `${PUBLIC_DATA_PATH}/activeDisplay/status`; // --- Helper Functions --- const generateId = () => crypto.randomUUID(); // --- Main App Component --- function App() { const [userId, setUserId] = useState(null); const [isAuthReady, setIsAuthReady] = useState(false); const [viewMode, setViewMode] = useState('admin'); // 'admin' or 'display' const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); // --- Authentication --- useEffect(() => { const initAuth = async () => { try { if (typeof __initial_auth_token !== 'undefined' && __initial_auth_token) { await signInWithCustomToken(auth, __initial_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) => { if (user) { setUserId(user.uid); } else { setUserId(null); } setIsAuthReady(true); setIsLoading(false); }); initAuth(); return () => unsubscribe(); }, []); if (isLoading || !isAuthReady) { return (

Loading Initiative Tracker...

{error &&

{error}

}
); } return (

TTRPG Initiative Tracker

{userId && UID: {userId}}
{viewMode === 'admin' && isAuthReady && userId && } {viewMode === 'display' && isAuthReady && } {!isAuthReady &&

Authenticating...

}
); } // --- Admin View Component --- function AdminView({ userId }) { const [campaigns, setCampaigns] = useState([]); const [selectedCampaignId, setSelectedCampaignId] = useState(null); const [showCreateCampaignModal, setShowCreateCampaignModal] = useState(false); // --- Fetch Campaigns --- useEffect(() => { const campaignsCollectionRef = collection(db, CAMPAIGNS_COLLECTION); const q = query(campaignsCollectionRef); const unsubscribe = onSnapshot(q, (snapshot) => { const camps = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() })); setCampaigns(camps); }, (err) => { console.error("Error fetching campaigns:", err); }); return () => unsubscribe(); }, []); const handleCreateCampaign = async (name) => { if (!name.trim()) return; const newCampaignId = generateId(); try { await setDoc(doc(db, CAMPAIGNS_COLLECTION, newCampaignId), { name: name.trim(), ownerId: userId, createdAt: new Date().toISOString(), players: [], }); setShowCreateCampaignModal(false); setSelectedCampaignId(newCampaignId); } catch (err) { console.error("Error creating campaign:", err); } }; const handleDeleteCampaign = async (campaignId) => { // TODO: Replace window.confirm with a custom modal if (!window.confirm("Are you sure you want to delete this campaign and all its encounters? This action cannot be undone.")) return; try { const encountersPath = `${CAMPAIGNS_COLLECTION}/${campaignId}/encounters`; const encountersSnapshot = await getDocs(collection(db, encountersPath)); const batch = writeBatch(db); encountersSnapshot.docs.forEach(encounterDoc => { batch.delete(encounterDoc.ref); }); await batch.commit(); await deleteDoc(doc(db, CAMPAIGNS_COLLECTION, campaignId)); if (selectedCampaignId === campaignId) { setSelectedCampaignId(null); } const activeDisplayRef = doc(db, ACTIVE_DISPLAY_DOC); const activeDisplaySnap = await getDoc(activeDisplayRef); if (activeDisplaySnap.exists() && activeDisplaySnap.data().activeCampaignId === campaignId) { await updateDoc(activeDisplayRef, { activeCampaignId: null, activeEncounterId: null }); } } catch (err) { console.error("Error deleting campaign:", err); } }; const selectedCampaign = campaigns.find(c => c.id === selectedCampaignId); return (

Campaigns

{campaigns.length === 0 &&

No campaigns yet. Create one to get started!

}
{campaigns.map(campaign => (
setSelectedCampaignId(campaign.id)}>

{campaign.name}

ID: {campaign.id}

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

Managing: {selectedCampaign.name}


)}
); } // --- Create Campaign Form --- function CreateCampaignForm({ 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 />
); } // --- Player Manager --- function PlayerManager({ campaignId, campaignPlayers }) { const [playerName, setPlayerName] = useState(''); const [editingPlayer, setEditingPlayer] = useState(null); const handleAddPlayer = async () => { if (!playerName.trim() || !campaignId) return; const newPlayer = { id: generateId(), name: playerName.trim() }; const campaignRef = doc(db, CAMPAIGNS_COLLECTION, campaignId); try { await updateDoc(campaignRef, { players: [...campaignPlayers, newPlayer] }); setPlayerName(''); } catch (err) { console.error("Error adding player:", err); } }; const handleUpdatePlayer = async (playerId, newName) => { if (!newName.trim() || !campaignId) return; const updatedPlayers = campaignPlayers.map(p => p.id === playerId ? { ...p, name: newName.trim() } : p); const campaignRef = doc(db, CAMPAIGNS_COLLECTION, campaignId); try { await updateDoc(campaignRef, { players: updatedPlayers }); setEditingPlayer(null); } catch (err) { console.error("Error updating player:", err); } }; const handleDeletePlayer = async (playerId) => { // TODO: Replace window.confirm if (!window.confirm("Are you sure you want to remove this player from the campaign?")) return; const updatedPlayers = campaignPlayers.filter(p => p.id !== playerId); const campaignRef = doc(db, CAMPAIGNS_COLLECTION, campaignId); try { await updateDoc(campaignRef, { players: updatedPlayers }); } catch (err) { console.error("Error deleting player:", err); } }; return (

Campaign Players

{ e.preventDefault(); handleAddPlayer(); }} className="flex gap-2 mb-4"> setPlayerName(e.target.value)} placeholder="New player 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" />
{campaignPlayers.length === 0 &&

No players added to this campaign yet.

}
); } // --- Encounter Manager --- function EncounterManager({ campaignId, campaignPlayers }) { const [encounters, setEncounters] = useState([]); const [selectedEncounterId, setSelectedEncounterId] = useState(null); const [showCreateEncounterModal, setShowCreateEncounterModal] = useState(false); const [activeDisplayInfo, setActiveDisplayInfo] = useState(null); // Stores { activeCampaignId, activeEncounterId } const encountersPath = `${CAMPAIGNS_COLLECTION}/${campaignId}/encounters`; // --- Fetch Encounters --- useEffect(() => { if (!campaignId) return; const encountersCollectionRef = collection(db, encountersPath); const q = query(encountersCollectionRef); const unsubscribe = onSnapshot(q, (snapshot) => { const encs = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() })); setEncounters(encs); }, (err) => { console.error(`Error fetching encounters for campaign ${campaignId}:`, err); }); return () => unsubscribe(); }, [campaignId, encountersPath]); // --- Fetch Active Display Info --- useEffect(() => { const unsub = onSnapshot(doc(db, ACTIVE_DISPLAY_DOC), (docSnap) => { if (docSnap.exists()) { setActiveDisplayInfo(docSnap.data()); } else { setActiveDisplayInfo(null); } }, (err) => { console.error("Error fetching active display info:", err); setActiveDisplayInfo(null); }); return () => unsub(); }, []); const handleCreateEncounter = async (name) => { if (!name.trim() || !campaignId) return; const newEncounterId = generateId(); try { await setDoc(doc(db, encountersPath, newEncounterId), { name: name.trim(), createdAt: new Date().toISOString(), participants: [], round: 0, currentTurnParticipantId: null, isStarted: false, }); setShowCreateEncounterModal(false); setSelectedEncounterId(newEncounterId); } catch (err) { console.error("Error creating encounter:", err); } }; const handleDeleteEncounter = async (encounterId) => { // TODO: Replace window.confirm if (!window.confirm("Are you sure you want to delete this encounter? This action cannot be undone.")) return; try { await deleteDoc(doc(db, encountersPath, encounterId)); if (selectedEncounterId === encounterId) { setSelectedEncounterId(null); } if (activeDisplayInfo && activeDisplayInfo.activeEncounterId === encounterId) { await updateDoc(doc(db, ACTIVE_DISPLAY_DOC), { activeEncounterId: null }); } } catch (err) { console.error("Error deleting encounter:", err); } }; const handleSetEncounterAsActiveDisplay = async (encounterId) => { try { await setDoc(doc(db, ACTIVE_DISPLAY_DOC), { activeCampaignId: campaignId, activeEncounterId: encounterId, }, { merge: true }); console.log("Encounter set as active for display!"); // Replaced alert with console.log } catch (err) { console.error("Error setting active display:", err); } }; const selectedEncounter = encounters.find(e => e.id === selectedEncounterId); return (

Encounters

{encounters.length === 0 &&

No encounters in this campaign yet.

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

{encounter.name}

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

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

Managing Encounter: {selectedEncounter.name}

)}
); } // --- Create Encounter Form --- 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 />
); } // --- Participant Manager --- function ParticipantManager({ encounter, encounterPath, campaignPlayers }) { const [participantName, setParticipantName] = useState(''); const [participantType, setParticipantType] = useState('monster'); const [selectedPlayerId, setSelectedPlayerId] = useState(''); const [initiative, setInitiative] = useState(10); const [maxHp, setMaxHp] = useState(10); const [editingParticipant, setEditingParticipant] = useState(null); const [hpChangeValues, setHpChangeValues] = useState({}); // { [participantId]: "value" } const participants = encounter.participants || []; const handleAddParticipant = async () => { if ((participantType === 'monster' && !participantName.trim()) || (participantType === 'player' && !selectedPlayerId)) return; let nameToAdd = participantName.trim(); if (participantType === 'player') { const player = campaignPlayers.find(p => p.id === selectedPlayerId); if (!player) { console.error("Selected player not found"); return; } if (participants.some(p => p.type === 'player' && p.originalPlayerId === selectedPlayerId)) { // TODO: Replace alert with better notification alert(`${player.name} is already in this encounter.`); return; } nameToAdd = player.name; } const newParticipant = { id: generateId(), name: nameToAdd, type: participantType, originalPlayerId: participantType === 'player' ? selectedPlayerId : 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); setSelectedPlayerId(''); } catch (err) { console.error("Error adding participant:", err); } }; const handleUpdateParticipant = async (updatedData) => { if (!editingParticipant) return; const updatedParticipants = participants.map(p => p.id === editingParticipant.id ? { ...p, ...updatedData } : p ); try { await updateDoc(doc(db, encounterPath), { participants: updatedParticipants }); setEditingParticipant(null); } catch (err) { console.error("Error updating participant:", err); } }; const handleDeleteParticipant = async (participantId) => { // TODO: Replace window.confirm if (!window.confirm("Remove this participant from the encounter?")) return; 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) => { const participant = participants.find(p => p.id === participantId); if (!participant) return; const updatedParticipants = participants.map(p => p.id === participantId ? { ...p, isActive: !p.isActive } : p ); try { await updateDoc(doc(db, encounterPath), { participants: updatedParticipants }); } catch (err) { console.error("Error toggling participant active state:", err); } }; const handleHpInputChange = (participantId, value) => { setHpChangeValues(prev => ({ ...prev, [participantId]: value })); }; const applyHpChange = async (participantId, changeType) => { const amountStr = hpChangeValues[participantId]; if (amountStr === undefined || amountStr.trim() === '') return; const amount = parseInt(amountStr, 10); if (isNaN(amount) || amount === 0) { setHpChangeValues(prev => ({ ...prev, [participantId]: '' })); // Clear if invalid return; } const participant = participants.find(p => p.id === participantId); if (!participant) return; let newHp = participant.currentHp; if (changeType === 'damage') { newHp = Math.max(0, participant.currentHp - amount); } else if (changeType === 'heal') { newHp = Math.min(participant.maxHp, participant.currentHp + amount); } const updatedParticipants = participants.map(p => p.id === participantId ? { ...p, currentHp: newHp } : p ); try { await updateDoc(doc(db, encounterPath), { participants: updatedParticipants }); setHpChangeValues(prev => ({ ...prev, [participantId]: '' })); // Clear input after applying } catch (err) { console.error("Error applying HP change:", err); } }; const sortedAdminParticipants = [...participants].sort((a, b) => { if (b.initiative === a.initiative) { return a.name.localeCompare(b.name); } return b.initiative - a.initiative; }); return (

Participants

{/* Add Participant Form */}
{participantType === 'monster' && (
setParticipantName(e.target.value)} placeholder="e.g., Goblin Boss" className="mt-1 w-full p-2 bg-slate-600 border-slate-500 rounded text-white" />
)} {participantType === 'player' && (
)}
setInitiative(e.target.value)} className="mt-1 w-full p-2 bg-slate-600 border-slate-500 rounded text-white" />
setMaxHp(e.target.value)} className="mt-1 w-full p-2 bg-slate-600 border-slate-500 rounded text-white" />
{/* Participant List */} {participants.length === 0 &&

No participants added yet.

} {editingParticipant && ( setEditingParticipant(null)} onSave={handleUpdateParticipant} /> )}
); } // --- Edit Participant Modal --- 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 w-full p-2 bg-slate-700 border-slate-600 rounded text-white" />
setInitiative(e.target.value)} className="mt-1 w-full p-2 bg-slate-700 border-slate-600 rounded text-white" />
setCurrentHp(e.target.value)} className="mt-1 w-full p-2 bg-slate-700 border-slate-600 rounded text-white" />
setMaxHp(e.target.value)} className="mt-1 w-full p-2 bg-slate-700 border-slate-600 rounded text-white" />
); } // --- Initiative Controls --- function InitiativeControls({ encounter, encounterPath }) { const handleStartEncounter = async () => { if (!encounter.participants || encounter.participants.length === 0) { // TODO: Replace alert alert("Add participants before starting the encounter."); return; } const activeParticipants = encounter.participants.filter(p => p.isActive); if (activeParticipants.length === 0) { // TODO: Replace alert alert("No active participants to start the encounter."); return; } const sortedParticipants = [...activeParticipants].sort((a, b) => { if (b.initiative === a.initiative) { return Math.random() - 0.5; } return b.initiative - a.initiative; }); try { await updateDoc(doc(db, encounterPath), { participants: encounter.participants.map(p => { const sortedVersion = sortedParticipants.find(sp => sp.id === p.id); return sortedVersion ? sortedVersion : p; }), isStarted: true, round: 1, currentTurnParticipantId: sortedParticipants[0].id, turnOrderIds: sortedParticipants.map(p => p.id) }); } catch (err) { console.error("Error starting encounter:", err); } }; const handleNextTurn = async () => { if (!encounter.isStarted || !encounter.currentTurnParticipantId || !encounter.turnOrderIds || encounter.turnOrderIds.length === 0) return; const activeParticipantsInOrder = encounter.turnOrderIds .map(id => encounter.participants.find(p => p.id === id && p.isActive)) .filter(Boolean); if (activeParticipantsInOrder.length === 0) { // TODO: Replace alert alert("No active participants left in the turn order."); await updateDoc(doc(db, encounterPath), { isStarted: false, currentTurnParticipantId: null, round: encounter.round }); return; } const currentIndex = activeParticipantsInOrder.findIndex(p => p.id === encounter.currentTurnParticipantId); let nextIndex = (currentIndex + 1) % activeParticipantsInOrder.length; let nextRound = encounter.round; if (nextIndex === 0 && currentIndex !== -1) { nextRound += 1; } const nextParticipantId = activeParticipantsInOrder[nextIndex].id; try { await updateDoc(doc(db, encounterPath), { currentTurnParticipantId: nextParticipantId, round: nextRound }); } catch (err) { console.error("Error advancing turn:", err); } }; const handleEndEncounter = async () => { // TODO: Replace window.confirm if (!window.confirm("Are you sure you want to end this encounter? Initiative order will be reset.")) return; try { await updateDoc(doc(db, encounterPath), { isStarted: false, currentTurnParticipantId: null, round: 0, turnOrderIds: [] }); } catch (err) { console.error("Error ending encounter:", err); } }; if (!encounter || !encounter.participants) return null; return (

Combat Controls

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

Round: {encounter.round}

)}
); } // --- Display View Component --- function DisplayView() { const [activeEncounterData, setActiveEncounterData] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { setIsLoading(true); const activeDisplayRef = doc(db, ACTIVE_DISPLAY_DOC); const unsubDisplayConfig = onSnapshot(activeDisplayRef, async (docSnap) => { if (docSnap.exists()) { const { activeCampaignId, activeEncounterId } = docSnap.data(); if (activeCampaignId && activeEncounterId) { const encounterPath = `${CAMPAIGNS_COLLECTION}/${activeCampaignId}/encounters/${activeEncounterId}`; const unsubEncounter = onSnapshot(doc(db, encounterPath), (encDocSnap) => { if (encDocSnap.exists()) { setActiveEncounterData({ id: encDocSnap.id, ...encDocSnap.data() }); setError(null); } else { setActiveEncounterData(null); setError("Active encounter not found. The DM might have deleted it or it's no longer set for display."); } setIsLoading(false); }, (err) => { console.error("Error fetching active encounter details:", err); setError("Error loading encounter data."); setIsLoading(false); }); return () => unsubEncounter(); } else { setActiveEncounterData(null); setIsLoading(false); setError(null); } } else { setActiveEncounterData(null); setIsLoading(false); setError(null); } }, (err) => { console.error("Error fetching active display config:", err); setError("Could not load display configuration."); setIsLoading(false); }); return () => unsubDisplayConfig(); }, []); if (isLoading) { return
Loading Player Display...
; } if (error) { return
{error}
; } if (!activeEncounterData) { return
No active encounter to display.
The DM needs to select one from the Admin View.
; } const { name, participants, round, currentTurnParticipantId, isStarted } = activeEncounterData; let displayParticipants = []; if (isStarted && activeEncounterData.turnOrderIds && activeEncounterData.turnOrderIds.length > 0) { displayParticipants = activeEncounterData.turnOrderIds .map(id => participants.find(p => p.id === id)) .filter(p => p && p.isActive); } else if (participants) { displayParticipants = [...participants] .filter(p => p.isActive) .sort((a, b) => { if (b.initiative === a.initiative) return a.name.localeCompare(b.name); return b.initiative - a.initiative; }); } return (

{name}

{isStarted &&

Round: {round}

} {!isStarted && participants && participants.length > 0 &&

Encounter Awaiting Start

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

No participants in this encounter yet.

} {displayParticipants.length === 0 && isStarted && (

No active participants in the encounter.

)}
{displayParticipants.map((p, index) => (

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

Init: {p.initiative}
HP: {p.currentHp} / {p.maxHp}
{p.conditions && p.conditions.length > 0 && (

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

)} {!p.isActive &&

(Inactive)

}
))}
); } // --- Modal Component --- 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}
); } // --- Icons --- const PlayIcon = ({ size = 24, className = '' }) => ; const SkipForwardIcon = ({ size = 24, className = '' }) => ; const StopCircleIcon = ({size=24, className=''}) => ; export default App;