diff --git a/src/App.js b/src/App.js
index c285593..4d25c19 100644
--- a/src/App.js
+++ b/src/App.js
@@ -2,7 +2,7 @@ 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, ExternalLink } from 'lucide-react';
+import { PlusCircle, Users, Swords, Shield, Trash2, Eye, Edit3, Save, XCircle, ChevronsUpDown, UserCheck, UserX, HeartCrack, HeartPulse, Zap, Image as ImageIcon, EyeOff, ExternalLink } from 'lucide-react'; // Removed unused icons
// --- Firebase Configuration ---
const firebaseConfig = {
@@ -37,12 +37,96 @@ if (missingKeys.length > 0) {
// --- 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`;
+
+// --- 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);
+
+ // Ensure queryConstraints is an array before spreading
+ 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();
+ // Using JSON.stringify for queryConstraints is a common way to handle array/object dependencies.
+ // For simple cases, it's fine. For complex queries, a more robust memoization or comparison might be needed.
+ }, [collectionPath, JSON.stringify(queryConstraints)]);
+
+ return { data, isLoading, error };
+}
+
+
// --- Main App Component ---
function App() {
const [userId, setUserId] = useState(null);
@@ -153,7 +237,7 @@ function App() {
{!isAuthReady && !error &&
Authenticating...
}
- TTRPG Initiative Tracker v0.1.18
+ TTRPG Initiative Tracker v0.1.19
);
@@ -161,51 +245,34 @@ function App() {
// --- 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);
- 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();
- };
- }, []);
+ if (campaignsData) {
+ setCampaigns(campaignsData.map(c => ({ ...c, characters: c.players || [] })));
+ }
+ }, [campaignsData]);
useEffect(() => {
- if (initialActiveInfo && initialActiveInfo.activeCampaignId && campaigns.length > 0 && !selectedCampaignId) {
- const campaignExists = campaigns.some(c => c.id === initialActiveInfo.activeCampaignId);
+ if (initialActiveInfoData && initialActiveInfoData.activeCampaignId && campaigns.length > 0 && !selectedCampaignId) {
+ const campaignExists = campaigns.some(c => c.id === initialActiveInfoData.activeCampaignId);
if (campaignExists) {
- setSelectedCampaignId(initialActiveInfo.activeCampaignId);
+ setSelectedCampaignId(initialActiveInfoData.activeCampaignId);
}
}
- }, [initialActiveInfo, campaigns, selectedCampaignId]);
+ }, [initialActiveInfoData, campaigns, selectedCampaignId]);
const handleCreateCampaign = async (name, backgroundUrl) => {
if (!db || !name.trim()) return;
const newCampaignId = generateId();
try {
- await setDoc(doc(db, CAMPAIGNS_COLLECTION, newCampaignId), {
+ await setDoc(doc(db, getCampaignDocPath(newCampaignId)), {
name: name.trim(),
playerDisplayBackgroundUrl: backgroundUrl.trim() || '',
ownerId: userId,
@@ -222,14 +289,15 @@ function AdminView({ userId }) {
// 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 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, CAMPAIGNS_COLLECTION, campaignId));
+ await deleteDoc(doc(db, getCampaignDocPath(campaignId)));
if (selectedCampaignId === campaignId) setSelectedCampaignId(null);
- const activeDisplayRef = doc(db, ACTIVE_DISPLAY_DOC);
+
+ const activeDisplayRef = doc(db, getActiveDisplayDocPath());
const activeDisplaySnap = await getDoc(activeDisplayRef);
if (activeDisplaySnap.exists() && activeDisplaySnap.data().activeCampaignId === campaignId) {
await updateDoc(activeDisplayRef, { activeCampaignId: null, activeEncounterId: null });
@@ -239,6 +307,10 @@ function AdminView({ userId }) {
const selectedCampaign = campaigns.find(c => c.id === selectedCampaignId);
+ if (isLoadingCampaigns) {
+ return Loading campaigns...
;
+ }
+
return (
@@ -280,7 +352,7 @@ function AdminView({ userId }) {
@@ -324,7 +396,7 @@ function CharacterManager({ campaignId, campaignCharacters }) {
if (!db ||!characterName.trim() || !campaignId) return;
const newCharacter = { id: generateId(), name: characterName.trim() };
try {
- await updateDoc(doc(db, CAMPAIGNS_COLLECTION, campaignId), { players: [...campaignCharacters, newCharacter] });
+ await updateDoc(doc(db, getCampaignDocPath(campaignId)), { players: [...campaignCharacters, newCharacter] });
setCharacterName('');
} catch (err) { console.error("Error adding character:", err); }
};
@@ -333,7 +405,7 @@ function CharacterManager({ campaignId, campaignCharacters }) {
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 });
+ await updateDoc(doc(db, getCampaignDocPath(campaignId)), { players: updatedCharacters });
setEditingCharacter(null);
} catch (err) { console.error("Error updating character:", err); }
};
@@ -344,7 +416,7 @@ function CharacterManager({ campaignId, campaignCharacters }) {
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 });
+ await updateDoc(doc(db, getCampaignDocPath(campaignId)), { players: updatedCharacters });
} catch (err) { console.error("Error deleting character:", err); }
};
@@ -374,11 +446,12 @@ function CharacterManager({ campaignId, campaignCharacters }) {
}
function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharacters }) {
- const [encounters, setEncounters] = useState([]);
+ 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 [activeDisplayInfo, setActiveDisplayInfo] = useState(null);
- const encountersPath = `${CAMPAIGNS_COLLECTION}/${campaignId}/encounters`;
+
const selectedEncounterIdRef = useRef(selectedEncounterId);
useEffect(() => {
@@ -386,44 +459,31 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
}, [selectedEncounterId]);
useEffect(() => {
- if (!db || !campaignId) {
- setEncounters([]);
- setSelectedEncounterId(null);
- return;
+ if (!campaignId) { // If no campaign is selected, clear selection
+ 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);
+ 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);
+ }
}
- }
- }, (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();
- }, []);
+ } else if (encounters && encounters.length === 0) { // No encounters in this campaign
+ setSelectedEncounterId(null);
+ }
+ }, [campaignId, initialActiveEncounterId, activeDisplayInfo, encounters]);
const handleCreateEncounter = async (name) => {
if (!db ||!name.trim() || !campaignId) return;
const newEncounterId = generateId();
try {
- await setDoc(doc(db, encountersPath, newEncounterId), {
+ await setDoc(doc(db, getEncountersCollectionPath(campaignId), newEncounterId), {
name: name.trim(), createdAt: new Date().toISOString(), participants: [], round: 0, currentTurnParticipantId: null, isStarted: false,
});
setShowCreateEncounterModal(false);
@@ -436,10 +496,10 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
// TODO: Implement custom confirmation modal for deleting encounters
console.warn("Attempting to delete encounter without confirmation:", encounterId);
try {
- await deleteDoc(doc(db, encountersPath, encounterId));
+ await deleteDoc(doc(db, getEncounterDocPath(campaignId, encounterId)));
if (selectedEncounterId === encounterId) setSelectedEncounterId(null);
if (activeDisplayInfo && activeDisplayInfo.activeEncounterId === encounterId) {
- await updateDoc(doc(db, ACTIVE_DISPLAY_DOC), { activeCampaignId: null, activeEncounterId: null });
+ await updateDoc(doc(db, getActiveDisplayDocPath()), { activeCampaignId: null, activeEncounterId: null });
}
} catch (err) { console.error("Error deleting encounter:", err); }
};
@@ -451,13 +511,13 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
const currentActiveEncounter = activeDisplayInfo?.activeEncounterId;
if (currentActiveCampaign === campaignId && currentActiveEncounter === encounterId) {
- await setDoc(doc(db, ACTIVE_DISPLAY_DOC), {
+ 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, ACTIVE_DISPLAY_DOC), {
+ await setDoc(doc(db, getActiveDisplayDocPath()), {
activeCampaignId: campaignId,
activeEncounterId: encounterId,
}, { merge: true });
@@ -468,7 +528,11 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
}
};
- const selectedEncounter = encounters.find(e => e.id === selectedEncounterId);
+ const selectedEncounter = encounters?.find(e => e.id === selectedEncounterId);
+
+ if (isLoadingEncounters && campaignId) {
+ return
Loading encounters...
;
+ }
return (
@@ -476,9 +540,9 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
Encounters
setShowCreateEncounterModal(true)} className="px-4 py-2 text-sm font-medium text-white bg-orange-500 hover:bg-orange-600 rounded-md transition-colors flex items-center"> Create Encounter
- {encounters.length === 0 &&
No encounters yet.
}
+ {(!encounters || encounters.length === 0) &&
No encounters yet.
}
- {encounters.map(encounter => {
+ {encounters?.map(encounter => {
const isLiveOnPlayerDisplay = activeDisplayInfo && activeDisplayInfo.activeCampaignId === campaignId && activeDisplayInfo.activeEncounterId === encounter.id;
return (
@@ -507,8 +571,8 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
{selectedEncounter && (
Managing Encounter: {selectedEncounter.name}
-
-
+
+
)}
@@ -615,56 +679,83 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
} catch (err) { console.error("Error applying HP change:", err); }
};
+ // --- Drag and Drop Handlers ---
const handleDragStart = (e, id) => {
setDraggedItemId(id);
- e.dataTransfer.effectAllowed = 'move';
+ e.dataTransfer.effectAllowed = 'move'; // Indicates that the element can be moved
+ // e.dataTransfer.setData('text/plain', id); // Optional: useful for some browsers or inter-app drag
};
const handleDragOver = (e) => {
- e.preventDefault();
- e.dataTransfer.dropEffect = 'move';
+ e.preventDefault(); // This is necessary to allow a drop
+ e.dataTransfer.dropEffect = 'move'; // Visual feedback to the user
};
const handleDrop = async (e, targetId) => {
- e.preventDefault();
+ e.preventDefault(); // Prevent default browser behavior
if (!db || draggedItemId === null || draggedItemId === targetId) {
- setDraggedItemId(null); return;
+ setDraggedItemId(null); // Reset if no valid drag or dropping on itself
+ return;
}
- const currentParticipants = [...participants];
+
+ const currentParticipants = [...participants]; // Create a mutable copy
const draggedItemIndex = currentParticipants.findIndex(p => p.id === draggedItemId);
const targetItemIndex = currentParticipants.findIndex(p => p.id === targetId);
+ // Ensure both items are found
if (draggedItemIndex === -1 || targetItemIndex === -1) {
- setDraggedItemId(null); return;
+ console.error("Dragged or target item not found in participants list.");
+ setDraggedItemId(null);
+ return;
}
+
const draggedItem = currentParticipants[draggedItemIndex];
const targetItem = currentParticipants[targetItemIndex];
+ // Crucial: Only allow reordering within the same initiative score for tie-breaking
if (draggedItem.initiative !== targetItem.initiative) {
- setDraggedItemId(null); return;
+ console.log("Drag-and-drop for tie-breaking only allowed between participants with the same initiative score.");
+ setDraggedItemId(null);
+ return;
}
- const reorderedParticipants = [...currentParticipants];
- const [removedItem] = reorderedParticipants.splice(draggedItemIndex, 1);
- reorderedParticipants.splice(targetItemIndex, 0, removedItem);
+
+ // Perform the reorder
+ const [removedItem] = currentParticipants.splice(draggedItemIndex, 1); // Remove dragged item
+ currentParticipants.splice(targetItemIndex, 0, removedItem); // Insert it at the target's position
+
try {
- await updateDoc(doc(db, encounterPath), { participants: reorderedParticipants });
- } catch (err) { console.error("Error updating participants after drag-drop:", err); }
- setDraggedItemId(null);
+ // Update Firestore with the new participants order
+ 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);
+ // Optionally, you might want to revert the local state if Firestore update fails,
+ // or display an error to the user. For now, we log the error.
+ }
+ setDraggedItemId(null); // Clear the dragged item ID
};
const handleDragEnd = () => {
- setDraggedItemId(null);
+ // This event fires after a drag operation, regardless of whether it was successful or not.
+ setDraggedItemId(null); // Always clear the dragged item ID
};
+ // Sort participants for display. Primary sort by initiative (desc), secondary by existing order in array (for stable tie-breaking after D&D)
const sortedAdminParticipants = [...participants].sort((a, b) => {
if (a.initiative === b.initiative) {
+ // If initiatives are tied, maintain their current relative order from the `participants` array.
+ // This relies on `Array.prototype.sort` being stable, which it is in modern JS engines.
+ // To be absolutely sure or for older engines, one might compare original indices if stored.
+ // However, since drag-and-drop directly modifies the `participants` array order in Firestore,
+ // this simple stable sort approach should preserve the manually set tie-breaker order.
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;
+ return b.initiative - a.initiative; // Higher initiative first
});
+ // Identify which initiative scores have ties to enable dragging only for them
const initiativeGroups = participants.reduce((acc, p) => {
acc[p.initiative] = (acc[p.initiative] || 0) + 1;
return acc;
@@ -705,6 +796,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
{sortedAdminParticipants.map((p, index) => {
const isCurrentTurn = encounter.isStarted && p.id === encounter.currentTurnParticipantId;
+ // A participant is draggable if the encounter hasn't started AND their initiative score is part of a tie.
const isDraggable = !encounter.isStarted && tiedInitiatives.includes(Number(p.initiative));
return (
p.id)
});
- await setDoc(doc(db, ACTIVE_DISPLAY_DOC), {
+ await setDoc(doc(db, getActiveDisplayDocPath()), {
activeCampaignId: campaignId,
activeEncounterId: encounter.id
}, { merge: true });
@@ -824,7 +916,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
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, ACTIVE_DISPLAY_DOC), {
+ await setDoc(doc(db, getActiveDisplayDocPath()), {
activeCampaignId: null,
activeEncounterId: null
}, { merge: true });
@@ -852,81 +944,80 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
}
function DisplayView() {
+ const { data: activeDisplayData, isLoading: isLoadingActiveDisplay, error: activeDisplayError } = useFirestoreDocument(getActiveDisplayDocPath());
+
const [activeEncounterData, setActiveEncounterData] = useState(null);
- const [isLoading, setIsLoading] = useState(true);
- const [error, setError] = useState(null);
+ const [isLoadingEncounter, setIsLoadingEncounter] = useState(true); // Separate loading for encounter
+ const [encounterError, setEncounterError] = useState(null);
const [campaignBackgroundUrl, setCampaignBackgroundUrl] = useState('');
const [isPlayerDisplayActive, setIsPlayerDisplayActive] = useState(false);
useEffect(() => {
if (!db) {
- setError("Firestore not available."); setIsLoading(false); return;
+ setEncounterError("Firestore not available.");
+ setIsLoadingEncounter(false);
+ setIsPlayerDisplayActive(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 (activeDisplayData) {
+ const { activeCampaignId, activeEncounterId } = activeDisplayData;
if (activeCampaignId && activeEncounterId) {
- setIsPlayerDisplayActive(true);
+ setIsPlayerDisplayActive(true);
+ setIsLoadingEncounter(true);
+ setEncounterError(null);
- const campaignDocRef = doc(db, CAMPAIGNS_COLLECTION, activeCampaignId);
- if (unsubscribeCampaign) unsubscribeCampaign();
- unsubscribeCampaign = onSnapshot(campaignDocRef, (campSnap) => {
- if (campSnap.exists()) {
- setCampaignBackgroundUrl(campSnap.data().playerDisplayBackgroundUrl || '');
- } else {
- setCampaignBackgroundUrl('');
- }
- }, (err) => console.error("Error fetching campaign background for display:", err));
+ 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 = `${CAMPAIGNS_COLLECTION}/${activeCampaignId}/encounters/${activeEncounterId}`;
- if (unsubscribeEncounter) unsubscribeEncounter();
- 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 {
- setActiveEncounterData(null);
- setCampaignBackgroundUrl('');
- setIsPlayerDisplayActive(false);
- setIsLoading(false);
+ 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 {
+ } else if (!isLoadingActiveDisplay) { // activeDisplayData is null and not loading
setActiveEncounterData(null);
setCampaignBackgroundUrl('');
setIsPlayerDisplayActive(false);
- setIsLoading(false);
- }
- }, (err) => {
- console.error("Error fetching active display config:", err);
- setError("Could not load display configuration.");
- setIsLoading(false);
- });
-
+ setIsLoadingEncounter(false);
+ }
+
return () => {
- unsubDisplayConfig();
if (unsubscribeEncounter) unsubscribeEncounter();
if (unsubscribeCampaign) unsubscribeCampaign();
};
- }, []);
+ }, [activeDisplayData, isLoadingActiveDisplay]);
- if (isLoading) return Loading Player Display...
;
- if (error) return {error}
;
+ if (isLoadingActiveDisplay || (isPlayerDisplayActive && isLoadingEncounter)) {
+ return Loading Player Display...
;
+ }
+ if (activeDisplayError || (isPlayerDisplayActive && encounterError)) {
+ return {activeDisplayError || encounterError}
;
+ }
if (!isPlayerDisplayActive || !activeEncounterData) {
return (
@@ -990,7 +1081,7 @@ function DisplayView() {
{p.type !== 'monster' && (
-
{/* Removed mix-blend-difference */}
+
HP: {p.currentHp} / {p.maxHp}
)}
@@ -1029,4 +1120,4 @@ const PlayIcon = ({ size = 24, className = '' }) => ;
const StopCircleIcon = ({size=24, className=''}) => ;
-export default App;
+export default App;
\ No newline at end of file