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 (
{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 (
);
}
// --- 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
{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 (
);
}
// --- 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 */}
{/* Participant List */}
{participants.length === 0 &&
No participants added yet.
}
{sortedAdminParticipants.map(p => (
-
{p.name} ({p.type})
Init: {p.initiative} | HP: {p.currentHp}/{p.maxHp}
{/* HP Change Controls - only if encounter started and participant active */}
{encounter.isStarted && p.isActive && (
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}`}
/>
)}
))}
{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 (
);
}
// --- 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 (
);
}
// --- Icons ---
const PlayIcon = ({ size = 24, className = '' }) => ;
const SkipForwardIcon = ({ size = 24, className = '' }) => ;
const StopCircleIcon = ({size=24, className=''}) => ;
export default App;