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 { ArrowLeft, PlusCircle, Users, Swords, Shield, Trash2, Eye, Edit3, Save, XCircle, ChevronsUpDown, ChevronDown, ChevronUp, UserCheck, UserX, HeartCrack, HeartPulse, Zap, Share2, Copy as CopyIcon, Image as ImageIcon, EyeOff } 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`;
const CAMPAIGNS_COLLECTION = `${PUBLIC_DATA_PATH}/campaigns`;
const ACTIVE_DISPLAY_DOC = `${PUBLIC_DATA_PATH}/activeDisplay/status`; // Single source for what player display shows
// --- Helper Functions ---
const generateId = () => crypto.randomUUID();
// getShareableLinkBase is no longer needed for individual encounter links if we have one player display URL
// function getShareableLinkBase() {
// return window.location.origin + window.location.pathname;
// }
// --- Main App Component ---
function App() {
const [userId, setUserId] = useState(null);
const [isAuthReady, setIsAuthReady] = useState(false);
const [viewMode, setViewMode] = useState('admin');
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
// directDisplayParams and hash routing removed, Player Display solely relies on ACTIVE_DISPLAY_DOC
useEffect(() => {
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 goToAdminView = () => {
setViewMode('admin');
};
return (
{viewMode === 'admin' && isAuthReady && userId && }
{viewMode === 'display' && isAuthReady && } {/* DisplayView no longer needs URL params */}
{!isAuthReady && !error && Authenticating...
}
);
}
// --- Admin View Component ---
function AdminView({ userId }) {
const [campaigns, setCampaigns] = useState([]);
const [selectedCampaignId, setSelectedCampaignId] = useState(null);
const [showCreateCampaignModal, setShowCreateCampaignModal] = useState(false);
const [initialActiveInfo, setInitialActiveInfo] = useState(null);
useEffect(() => {
if (!db) return;
const campaignsCollectionRef = collection(db, CAMPAIGNS_COLLECTION);
const q = query(campaignsCollectionRef);
const unsubscribeCampaigns = onSnapshot(q, (snapshot) => {
setCampaigns(snapshot.docs.map(doc => ({ id: doc.id, ...doc.data(), characters: doc.data().players || [] })));
}, (err) => console.error("Error fetching campaigns:", err));
const activeDisplayRef = doc(db, ACTIVE_DISPLAY_DOC);
const unsubscribeActiveDisplay = onSnapshot(activeDisplayRef, (docSnap) => {
if (docSnap.exists()) {
setInitialActiveInfo(docSnap.data());
} else {
setInitialActiveInfo(null);
}
}, (err) => {
console.error("Error fetching initial active display info for AdminView:", err);
});
return () => {
unsubscribeCampaigns();
unsubscribeActiveDisplay();
};
}, []);
useEffect(() => {
if (initialActiveInfo && initialActiveInfo.activeCampaignId && campaigns.length > 0 && !selectedCampaignId) {
const campaignExists = campaigns.some(c => c.id === initialActiveInfo.activeCampaignId);
if (campaignExists) {
setSelectedCampaignId(initialActiveInfo.activeCampaignId);
}
}
}, [initialActiveInfo, campaigns, selectedCampaignId]);
const handleCreateCampaign = async (name, backgroundUrl) => {
if (!db || !name.trim()) return;
const newCampaignId = generateId();
try {
await setDoc(doc(db, CAMPAIGNS_COLLECTION, 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 = `${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.
}
{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 (
);
}
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, CAMPAIGNS_COLLECTION, 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, CAMPAIGNS_COLLECTION, 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, CAMPAIGNS_COLLECTION, campaignId), { players: updatedCharacters });
} catch (err) { console.error("Error deleting character:", err); }
};
return (
Campaign Characters
{campaignCharacters.length === 0 &&
No characters added.
}
);
}
function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharacters }) {
const [encounters, setEncounters] = useState([]);
const [selectedEncounterId, setSelectedEncounterId] = useState(null);
const [showCreateEncounterModal, setShowCreateEncounterModal] = useState(false);
const [activeDisplayInfo, setActiveDisplayInfo] = useState(null);
// copiedLinkEncounterId removed as shareable links per encounter are removed
const encountersPath = `${CAMPAIGNS_COLLECTION}/${campaignId}/encounters`;
const selectedEncounterIdRef = useRef(selectedEncounterId);
useEffect(() => {
selectedEncounterIdRef.current = selectedEncounterId;
}, [selectedEncounterId]);
useEffect(() => {
if (!db || !campaignId) {
setEncounters([]);
setSelectedEncounterId(null);
return;
}
const unsubEncounters = onSnapshot(query(collection(db, encountersPath)), (snapshot) => {
const fetchedEncounters = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
setEncounters(fetchedEncounters);
const currentSelection = selectedEncounterIdRef.current;
if (currentSelection === null || !fetchedEncounters.some(e => e.id === currentSelection)) {
if (initialActiveEncounterId && fetchedEncounters.some(e => e.id === initialActiveEncounterId)) {
setSelectedEncounterId(initialActiveEncounterId);
} else if (activeDisplayInfo && activeDisplayInfo.activeCampaignId === campaignId &&
fetchedEncounters.some(e => e.id === activeDisplayInfo.activeEncounterId)) {
setSelectedEncounterId(activeDisplayInfo.activeEncounterId);
}
}
}, (err) => console.error(`Error fetching encounters for campaign ${campaignId}:`, err));
return () => unsubEncounters();
}, [campaignId, initialActiveEncounterId, activeDisplayInfo]);
useEffect(() => {
if (!db) return;
const unsub = onSnapshot(doc(db, ACTIVE_DISPLAY_DOC), (docSnap) => {
setActiveDisplayInfo(docSnap.exists() ? docSnap.data() : null);
}, (err) => { console.error("Error fetching active display info:", err); setActiveDisplayInfo(null); });
return () => unsub();
}, []);
const handleCreateEncounter = async (name) => {
if (!db ||!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) => {
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, encountersPath, encounterId));
if (selectedEncounterId === encounterId) setSelectedEncounterId(null);
if (activeDisplayInfo && activeDisplayInfo.activeEncounterId === encounterId) {
await updateDoc(doc(db, ACTIVE_DISPLAY_DOC), { 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) {
// Currently active, so toggle off (clear the active display)
await setDoc(doc(db, ACTIVE_DISPLAY_DOC), {
activeCampaignId: null,
activeEncounterId: null,
}, { merge: true }); // Use set with merge to ensure document exists or is overwritten
console.log("Player Display for this encounter turned OFF.");
} else {
// Not active or different encounter, so set this one as active for players
await setDoc(doc(db, ACTIVE_DISPLAY_DOC), {
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);
}
};
// Shareable link per encounter removed
// const handleCopyToClipboard = (encounterId) => { ... };
const selectedEncounter = encounters.find(e => e.id === selectedEncounterId);
return (
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}
{/* Share button for individual encounter link removed */}
{/* Shareable link display removed */}
);
})}
{showCreateEncounterModal &&
setShowCreateEncounterModal(false)} title="Create New Encounter"> setShowCreateEncounterModal(false)} />}
{selectedEncounter && (
Managing Encounter: {selectedEncounter.name}
)}
);
}
function CreateEncounterForm({ onCreate, onCancel }) {
const [name, setName] = useState('');
return (
);
}
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); }
};
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) {
setDraggedItemId(null); return;
}
const draggedItem = currentParticipants[draggedItemIndex];
const targetItem = currentParticipants[targetItemIndex];
if (draggedItem.initiative !== targetItem.initiative) {
setDraggedItemId(null); return;
}
const reorderedParticipants = [...currentParticipants];
const [removedItem] = reorderedParticipants.splice(draggedItemIndex, 1);
reorderedParticipants.splice(targetItemIndex, 0, removedItem);
try {
await updateDoc(doc(db, encounterPath), { participants: reorderedParticipants });
} 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
{participants.length === 0 &&
No participants.
}
{sortedAdminParticipants.map((p, index) => {
const isCurrentTurn = encounter.isStarted && p.id === encounter.currentTurnParticipantId;
const isDraggable = !encounter.isStarted && tiedInitiatives.includes(Number(p.initiative));
return (
- handleDragStart(e, p.id) : undefined}
onDragOver={isDraggable ? handleDragOver : undefined}
onDrop={isDraggable ? (e) => handleDrop(e, p.id) : undefined}
onDragEnd={isDraggable ? handleDragEnd : undefined}
className={`p-3 rounded-md flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 transition-all
${isCurrentTurn ? 'bg-green-600 ring-2 ring-green-300 shadow-lg' : (p.type === 'character' ? 'bg-sky-800' : 'bg-red-800')}
${!p.isActive ? 'opacity-50' : ''}
${isDraggable ? 'cursor-grab' : ''}
${draggedItemId === p.id ? 'opacity-50 ring-2 ring-yellow-400' : ''}
`}
>
{isDraggable &&
}
{p.name} ({p.type}){isCurrentTurn && CURRENT}
Init: {p.initiative} | HP: {p.currentHp}/{p.maxHp}
{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} />}
);
}
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 (
);
}
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, ACTIVE_DISPLAY_DOC), {
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: [] });
// Optionally, also clear the active display when an encounter ends
// await updateDoc(doc(db, ACTIVE_DISPLAY_DOC), { activeCampaignId: null, activeEncounterId: null });
} catch (err) { console.error("Error ending encounter:", err); }
};
if (!encounter || !encounter.participants) return null;
return (
Combat Controls
{!encounter.isStarted ? (
) : (
<>
Round: {encounter.round}
>
)}
);
}
function DisplayView() { // Removed campaignIdFromUrl, encounterIdFromUrl props
const [activeEncounterData, setActiveEncounterData] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const [campaignBackgroundUrl, setCampaignBackgroundUrl] = useState('');
const [isPlayerDisplayActive, setIsPlayerDisplayActive] = useState(false); // New state
useEffect(() => {
if (!db) {
setError("Firestore not available."); setIsLoading(false); return;
}
setIsLoading(true); setError(null); setActiveEncounterData(null); setCampaignBackgroundUrl(''); setIsPlayerDisplayActive(false);
let unsubscribeEncounter;
let unsubscribeCampaign;
const activeDisplayRef = doc(db, ACTIVE_DISPLAY_DOC);
const unsubDisplayConfig = onSnapshot(activeDisplayRef, async (docSnap) => {
if (docSnap.exists()) {
const { activeCampaignId, activeEncounterId } = docSnap.data();
if (activeCampaignId && activeEncounterId) {
setIsPlayerDisplayActive(true); // An encounter is active for display
// Fetch campaign background
const campaignDocRef = doc(db, CAMPAIGNS_COLLECTION, activeCampaignId);
if (unsubscribeCampaign) unsubscribeCampaign(); // Clean up previous listener
unsubscribeCampaign = onSnapshot(campaignDocRef, (campSnap) => {
if (campSnap.exists()) {
setCampaignBackgroundUrl(campSnap.data().playerDisplayBackgroundUrl || '');
} else {
setCampaignBackgroundUrl(''); // Campaign not found
}
}, (err) => console.error("Error fetching campaign background for display:", err));
// Fetch encounter data
const encounterPath = `${CAMPAIGNS_COLLECTION}/${activeCampaignId}/encounters/${activeEncounterId}`;
if (unsubscribeEncounter) unsubscribeEncounter(); // Clean up previous listener
unsubscribeEncounter = onSnapshot(doc(db, encounterPath), (encDocSnap) => {
if (encDocSnap.exists()) {
setActiveEncounterData({ id: encDocSnap.id, ...encDocSnap.data() });
setError(null);
} else {
setActiveEncounterData(null);
setError("Active encounter data not found. The DM might have deleted it or it's misconfigured.");
}
setIsLoading(false);
}, (err) => {
console.error("Error fetching active encounter details for display:", err);
setError("Error loading active encounter data.");
setIsLoading(false);
});
} else {
// No active encounter set by DM
setActiveEncounterData(null);
setCampaignBackgroundUrl('');
setIsPlayerDisplayActive(false); // No encounter is active for display
setIsLoading(false);
}
} else {
// ACTIVE_DISPLAY_DOC doesn't exist
setActiveEncounterData(null);
setCampaignBackgroundUrl('');
setIsPlayerDisplayActive(false);
setIsLoading(false);
}
}, (err) => {
console.error("Error fetching active display config:", err);
setError("Could not load display configuration.");
setIsLoading(false);
});
return () => {
unsubDisplayConfig();
if (unsubscribeEncounter) unsubscribeEncounter();
if (unsubscribeCampaign) unsubscribeCampaign();
};
}, []); // Removed URL params from dependencies
if (isLoading) return Loading Player Display...
;
if (error) return {error}
;
if (!isPlayerDisplayActive || !activeEncounterData) {
return (
Game Session Paused
The Dungeon Master has not activated an encounter for display.
);
}
const { name, participants, round, currentTurnParticipantId, isStarted } = activeEncounterData;
let displayParticipants = [];
if (participants) {
if (isStarted && activeEncounterData.turnOrderIds?.length > 0 ) {
displayParticipants = activeEncounterData.turnOrderIds
.map(id => participants.find(p => p.id === id)).filter(p => p && p.isActive);
} else {
displayParticipants = [...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}
}
{!isStarted && participants?.length > 0 &&
Awaiting Start
}
{!isStarted && (!participants || participants.length === 0) &&
No participants.
}
{displayParticipants.length === 0 && isStarted &&
No active participants.
}
{displayParticipants.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 (
);
}
const PlayIcon = ({ size = 24, className = '' }) => ;
const SkipForwardIcon = ({ size = 24, className = '' }) => ;
const StopCircleIcon = ({size=24, className=''}) => ;
export default App;