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...

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

- {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 }) {