This commit is contained in:
Robert Johnson 2025-05-26 08:33:39 -04:00
parent d023da05a5
commit d5b93ac66a

View File

@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef } from 'react';
import { initializeApp } from 'firebase/app'; import { initializeApp } from 'firebase/app';
import { getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken } from 'firebase/auth'; 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 { 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 --- // --- Firebase Configuration ---
const firebaseConfig = { const firebaseConfig = {
@ -37,12 +37,96 @@ if (missingKeys.length > 0) {
// --- Firestore Paths --- // --- Firestore Paths ---
const APP_ID = process.env.REACT_APP_TRACKER_APP_ID || 'ttrpg-initiative-tracker-default'; const APP_ID = process.env.REACT_APP_TRACKER_APP_ID || 'ttrpg-initiative-tracker-default';
const PUBLIC_DATA_PATH = `artifacts/${APP_ID}/public/data`; 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 --- // --- Helper Functions ---
const generateId = () => crypto.randomUUID(); 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 --- // --- Main App Component ---
function App() { function App() {
const [userId, setUserId] = useState(null); const [userId, setUserId] = useState(null);
@ -153,7 +237,7 @@ function App() {
{!isAuthReady && !error && <p>Authenticating...</p>} {!isAuthReady && !error && <p>Authenticating...</p>}
</main> </main>
<footer className="bg-slate-900 p-4 text-center text-sm text-slate-400 mt-8"> <footer className="bg-slate-900 p-4 text-center text-sm text-slate-400 mt-8">
TTRPG Initiative Tracker v0.1.18 TTRPG Initiative Tracker v0.1.19
</footer> </footer>
</div> </div>
); );
@ -161,51 +245,34 @@ function App() {
// --- Admin View Component --- // --- Admin View Component ---
function AdminView({ userId }) { function AdminView({ userId }) {
const { data: campaignsData, isLoading: isLoadingCampaigns } = useFirestoreCollection(getCampaignsCollectionPath());
const { data: initialActiveInfoData } = useFirestoreDocument(getActiveDisplayDocPath());
const [campaigns, setCampaigns] = useState([]); const [campaigns, setCampaigns] = useState([]);
const [selectedCampaignId, setSelectedCampaignId] = useState(null); const [selectedCampaignId, setSelectedCampaignId] = useState(null);
const [showCreateCampaignModal, setShowCreateCampaignModal] = useState(false); const [showCreateCampaignModal, setShowCreateCampaignModal] = useState(false);
const [initialActiveInfo, setInitialActiveInfo] = useState(null);
useEffect(() => { useEffect(() => {
if (!db) return; if (campaignsData) {
const campaignsCollectionRef = collection(db, CAMPAIGNS_COLLECTION); setCampaigns(campaignsData.map(c => ({ ...c, characters: c.players || [] })));
const q = query(campaignsCollectionRef); }
const unsubscribeCampaigns = onSnapshot(q, (snapshot) => { }, [campaignsData]);
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(() => { useEffect(() => {
if (initialActiveInfo && initialActiveInfo.activeCampaignId && campaigns.length > 0 && !selectedCampaignId) { if (initialActiveInfoData && initialActiveInfoData.activeCampaignId && campaigns.length > 0 && !selectedCampaignId) {
const campaignExists = campaigns.some(c => c.id === initialActiveInfo.activeCampaignId); const campaignExists = campaigns.some(c => c.id === initialActiveInfoData.activeCampaignId);
if (campaignExists) { if (campaignExists) {
setSelectedCampaignId(initialActiveInfo.activeCampaignId); setSelectedCampaignId(initialActiveInfoData.activeCampaignId);
} }
} }
}, [initialActiveInfo, campaigns, selectedCampaignId]); }, [initialActiveInfoData, campaigns, selectedCampaignId]);
const handleCreateCampaign = async (name, backgroundUrl) => { const handleCreateCampaign = async (name, backgroundUrl) => {
if (!db || !name.trim()) return; if (!db || !name.trim()) return;
const newCampaignId = generateId(); const newCampaignId = generateId();
try { try {
await setDoc(doc(db, CAMPAIGNS_COLLECTION, newCampaignId), { await setDoc(doc(db, getCampaignDocPath(newCampaignId)), {
name: name.trim(), name: name.trim(),
playerDisplayBackgroundUrl: backgroundUrl.trim() || '', playerDisplayBackgroundUrl: backgroundUrl.trim() || '',
ownerId: userId, ownerId: userId,
@ -222,14 +289,15 @@ function AdminView({ userId }) {
// TODO: Implement custom confirmation modal for deleting campaigns // TODO: Implement custom confirmation modal for deleting campaigns
console.warn("Attempting to delete campaign without confirmation:", campaignId); console.warn("Attempting to delete campaign without confirmation:", campaignId);
try { try {
const encountersPath = `${CAMPAIGNS_COLLECTION}/${campaignId}/encounters`; const encountersPath = getEncountersCollectionPath(campaignId);
const encountersSnapshot = await getDocs(collection(db, encountersPath)); const encountersSnapshot = await getDocs(collection(db, encountersPath));
const batch = writeBatch(db); const batch = writeBatch(db);
encountersSnapshot.docs.forEach(encounterDoc => batch.delete(encounterDoc.ref)); encountersSnapshot.docs.forEach(encounterDoc => batch.delete(encounterDoc.ref));
await batch.commit(); await batch.commit();
await deleteDoc(doc(db, CAMPAIGNS_COLLECTION, campaignId)); await deleteDoc(doc(db, getCampaignDocPath(campaignId)));
if (selectedCampaignId === campaignId) setSelectedCampaignId(null); if (selectedCampaignId === campaignId) setSelectedCampaignId(null);
const activeDisplayRef = doc(db, ACTIVE_DISPLAY_DOC);
const activeDisplayRef = doc(db, getActiveDisplayDocPath());
const activeDisplaySnap = await getDoc(activeDisplayRef); const activeDisplaySnap = await getDoc(activeDisplayRef);
if (activeDisplaySnap.exists() && activeDisplaySnap.data().activeCampaignId === campaignId) { if (activeDisplaySnap.exists() && activeDisplaySnap.data().activeCampaignId === campaignId) {
await updateDoc(activeDisplayRef, { activeCampaignId: null, activeEncounterId: null }); await updateDoc(activeDisplayRef, { activeCampaignId: null, activeEncounterId: null });
@ -239,6 +307,10 @@ function AdminView({ userId }) {
const selectedCampaign = campaigns.find(c => c.id === selectedCampaignId); const selectedCampaign = campaigns.find(c => c.id === selectedCampaignId);
if (isLoadingCampaigns) {
return <p className="text-center text-slate-300">Loading campaigns...</p>;
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
@ -280,7 +352,7 @@ function AdminView({ userId }) {
<hr className="my-6 border-slate-600" /> <hr className="my-6 border-slate-600" />
<EncounterManager <EncounterManager
campaignId={selectedCampaignId} campaignId={selectedCampaignId}
initialActiveEncounterId={initialActiveInfo && initialActiveInfo.activeCampaignId === selectedCampaignId ? initialActiveInfo.activeEncounterId : null} initialActiveEncounterId={initialActiveInfoData && initialActiveInfoData.activeCampaignId === selectedCampaignId ? initialActiveInfoData.activeEncounterId : null}
campaignCharacters={selectedCampaign.players || []} campaignCharacters={selectedCampaign.players || []}
/> />
</div> </div>
@ -324,7 +396,7 @@ function CharacterManager({ campaignId, campaignCharacters }) {
if (!db ||!characterName.trim() || !campaignId) return; if (!db ||!characterName.trim() || !campaignId) return;
const newCharacter = { id: generateId(), name: characterName.trim() }; const newCharacter = { id: generateId(), name: characterName.trim() };
try { try {
await updateDoc(doc(db, CAMPAIGNS_COLLECTION, campaignId), { players: [...campaignCharacters, newCharacter] }); await updateDoc(doc(db, getCampaignDocPath(campaignId)), { players: [...campaignCharacters, newCharacter] });
setCharacterName(''); setCharacterName('');
} catch (err) { console.error("Error adding character:", err); } } catch (err) { console.error("Error adding character:", err); }
}; };
@ -333,7 +405,7 @@ function CharacterManager({ campaignId, campaignCharacters }) {
if (!db ||!newName.trim() || !campaignId) return; if (!db ||!newName.trim() || !campaignId) return;
const updatedCharacters = campaignCharacters.map(c => c.id === characterId ? { ...c, name: newName.trim() } : c); const updatedCharacters = campaignCharacters.map(c => c.id === characterId ? { ...c, name: newName.trim() } : c);
try { try {
await updateDoc(doc(db, CAMPAIGNS_COLLECTION, campaignId), { players: updatedCharacters }); await updateDoc(doc(db, getCampaignDocPath(campaignId)), { players: updatedCharacters });
setEditingCharacter(null); setEditingCharacter(null);
} catch (err) { console.error("Error updating character:", err); } } 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); console.warn("Attempting to delete character without confirmation:", characterId);
const updatedCharacters = campaignCharacters.filter(c => c.id !== characterId); const updatedCharacters = campaignCharacters.filter(c => c.id !== characterId);
try { 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); } } catch (err) { console.error("Error deleting character:", err); }
}; };
@ -374,11 +446,12 @@ function CharacterManager({ campaignId, campaignCharacters }) {
} }
function EncounterManager({ campaignId, initialActiveEncounterId, 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 [selectedEncounterId, setSelectedEncounterId] = useState(null);
const [showCreateEncounterModal, setShowCreateEncounterModal] = useState(false); const [showCreateEncounterModal, setShowCreateEncounterModal] = useState(false);
const [activeDisplayInfo, setActiveDisplayInfo] = useState(null);
const encountersPath = `${CAMPAIGNS_COLLECTION}/${campaignId}/encounters`;
const selectedEncounterIdRef = useRef(selectedEncounterId); const selectedEncounterIdRef = useRef(selectedEncounterId);
useEffect(() => { useEffect(() => {
@ -386,44 +459,31 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
}, [selectedEncounterId]); }, [selectedEncounterId]);
useEffect(() => { useEffect(() => {
if (!db || !campaignId) { if (!campaignId) { // If no campaign is selected, clear selection
setEncounters([]); setSelectedEncounterId(null);
setSelectedEncounterId(null); return;
return;
} }
const unsubEncounters = onSnapshot(query(collection(db, encountersPath)), (snapshot) => { if (encounters && encounters.length > 0) {
const fetchedEncounters = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() })); const currentSelection = selectedEncounterIdRef.current;
setEncounters(fetchedEncounters); if (currentSelection === null || !encounters.some(e => e.id === currentSelection)) {
if (initialActiveEncounterId && encounters.some(e => e.id === initialActiveEncounterId)) {
const currentSelection = selectedEncounterIdRef.current; setSelectedEncounterId(initialActiveEncounterId);
if (currentSelection === null || !fetchedEncounters.some(e => e.id === currentSelection)) { } else if (activeDisplayInfo && activeDisplayInfo.activeCampaignId === campaignId &&
if (initialActiveEncounterId && fetchedEncounters.some(e => e.id === initialActiveEncounterId)) { encounters.some(e => e.id === activeDisplayInfo.activeEncounterId)) {
setSelectedEncounterId(initialActiveEncounterId); setSelectedEncounterId(activeDisplayInfo.activeEncounterId);
} else if (activeDisplayInfo && activeDisplayInfo.activeCampaignId === campaignId && }
fetchedEncounters.some(e => e.id === activeDisplayInfo.activeEncounterId)) {
setSelectedEncounterId(activeDisplayInfo.activeEncounterId);
} }
} } else if (encounters && encounters.length === 0) { // No encounters in this campaign
}, (err) => console.error(`Error fetching encounters for campaign ${campaignId}:`, err)); setSelectedEncounterId(null);
}
return () => unsubEncounters(); }, [campaignId, initialActiveEncounterId, activeDisplayInfo, encounters]);
}, [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) => { const handleCreateEncounter = async (name) => {
if (!db ||!name.trim() || !campaignId) return; if (!db ||!name.trim() || !campaignId) return;
const newEncounterId = generateId(); const newEncounterId = generateId();
try { 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, name: name.trim(), createdAt: new Date().toISOString(), participants: [], round: 0, currentTurnParticipantId: null, isStarted: false,
}); });
setShowCreateEncounterModal(false); setShowCreateEncounterModal(false);
@ -436,10 +496,10 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
// TODO: Implement custom confirmation modal for deleting encounters // TODO: Implement custom confirmation modal for deleting encounters
console.warn("Attempting to delete encounter without confirmation:", encounterId); console.warn("Attempting to delete encounter without confirmation:", encounterId);
try { try {
await deleteDoc(doc(db, encountersPath, encounterId)); await deleteDoc(doc(db, getEncounterDocPath(campaignId, encounterId)));
if (selectedEncounterId === encounterId) setSelectedEncounterId(null); if (selectedEncounterId === encounterId) setSelectedEncounterId(null);
if (activeDisplayInfo && activeDisplayInfo.activeEncounterId === encounterId) { 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); } } catch (err) { console.error("Error deleting encounter:", err); }
}; };
@ -451,13 +511,13 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
const currentActiveEncounter = activeDisplayInfo?.activeEncounterId; const currentActiveEncounter = activeDisplayInfo?.activeEncounterId;
if (currentActiveCampaign === campaignId && currentActiveEncounter === encounterId) { if (currentActiveCampaign === campaignId && currentActiveEncounter === encounterId) {
await setDoc(doc(db, ACTIVE_DISPLAY_DOC), { await setDoc(doc(db, getActiveDisplayDocPath()), {
activeCampaignId: null, activeCampaignId: null,
activeEncounterId: null, activeEncounterId: null,
}, { merge: true }); }, { merge: true });
console.log("Player Display for this encounter turned OFF."); console.log("Player Display for this encounter turned OFF.");
} else { } else {
await setDoc(doc(db, ACTIVE_DISPLAY_DOC), { await setDoc(doc(db, getActiveDisplayDocPath()), {
activeCampaignId: campaignId, activeCampaignId: campaignId,
activeEncounterId: encounterId, activeEncounterId: encounterId,
}, { merge: true }); }, { 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 <p className="text-center text-slate-300 mt-4">Loading encounters...</p>;
}
return ( return (
<div className="mt-6 p-4 bg-slate-800 rounded-lg shadow"> <div className="mt-6 p-4 bg-slate-800 rounded-lg shadow">
@ -476,9 +540,9 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
<h3 className="text-xl font-semibold text-sky-300 flex items-center"><Swords size={24} className="mr-2" /> Encounters</h3> <h3 className="text-xl font-semibold text-sky-300 flex items-center"><Swords size={24} className="mr-2" /> Encounters</h3>
<button onClick={() => 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"><PlusCircle size={18} className="mr-1" /> Create Encounter</button> <button onClick={() => 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"><PlusCircle size={18} className="mr-1" /> Create Encounter</button>
</div> </div>
{encounters.length === 0 && <p className="text-sm text-slate-400">No encounters yet.</p>} {(!encounters || encounters.length === 0) && <p className="text-sm text-slate-400">No encounters yet.</p>}
<div className="space-y-3"> <div className="space-y-3">
{encounters.map(encounter => { {encounters?.map(encounter => {
const isLiveOnPlayerDisplay = activeDisplayInfo && activeDisplayInfo.activeCampaignId === campaignId && activeDisplayInfo.activeEncounterId === encounter.id; const isLiveOnPlayerDisplay = activeDisplayInfo && activeDisplayInfo.activeCampaignId === campaignId && activeDisplayInfo.activeEncounterId === encounter.id;
return ( return (
<div key={encounter.id} className={`p-3 rounded-md shadow transition-all ${selectedEncounterId === encounter.id ? 'bg-sky-600 ring-2 ring-sky-400' : 'bg-slate-700 hover:bg-slate-650'} ${isLiveOnPlayerDisplay ? 'ring-2 ring-green-500 shadow-md shadow-green-500/30' : ''}`}> <div key={encounter.id} className={`p-3 rounded-md shadow transition-all ${selectedEncounterId === encounter.id ? 'bg-sky-600 ring-2 ring-sky-400' : 'bg-slate-700 hover:bg-slate-650'} ${isLiveOnPlayerDisplay ? 'ring-2 ring-green-500 shadow-md shadow-green-500/30' : ''}`}>
@ -507,8 +571,8 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
{selectedEncounter && ( {selectedEncounter && (
<div className="mt-6 p-4 bg-slate-750 rounded-lg shadow-inner"> <div className="mt-6 p-4 bg-slate-750 rounded-lg shadow-inner">
<h3 className="text-xl font-semibold text-amber-300 mb-3">Managing Encounter: {selectedEncounter.name}</h3> <h3 className="text-xl font-semibold text-amber-300 mb-3">Managing Encounter: {selectedEncounter.name}</h3>
<ParticipantManager encounter={selectedEncounter} encounterPath={`${encountersPath}/${selectedEncounterId}`} campaignCharacters={campaignCharacters} /> <ParticipantManager encounter={selectedEncounter} encounterPath={getEncounterDocPath(campaignId, selectedEncounter.id)} campaignCharacters={campaignCharacters} />
<InitiativeControls campaignId={campaignId} encounter={selectedEncounter} encounterPath={`${encountersPath}/${selectedEncounterId}`} /> <InitiativeControls campaignId={campaignId} encounter={selectedEncounter} encounterPath={getEncounterDocPath(campaignId, selectedEncounter.id)} />
</div> </div>
)} )}
</div> </div>
@ -615,56 +679,83 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
} catch (err) { console.error("Error applying HP change:", err); } } catch (err) { console.error("Error applying HP change:", err); }
}; };
// --- Drag and Drop Handlers ---
const handleDragStart = (e, id) => { const handleDragStart = (e, id) => {
setDraggedItemId(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) => { const handleDragOver = (e) => {
e.preventDefault(); e.preventDefault(); // This is necessary to allow a drop
e.dataTransfer.dropEffect = 'move'; e.dataTransfer.dropEffect = 'move'; // Visual feedback to the user
}; };
const handleDrop = async (e, targetId) => { const handleDrop = async (e, targetId) => {
e.preventDefault(); e.preventDefault(); // Prevent default browser behavior
if (!db || draggedItemId === null || draggedItemId === targetId) { 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 draggedItemIndex = currentParticipants.findIndex(p => p.id === draggedItemId);
const targetItemIndex = currentParticipants.findIndex(p => p.id === targetId); const targetItemIndex = currentParticipants.findIndex(p => p.id === targetId);
// Ensure both items are found
if (draggedItemIndex === -1 || targetItemIndex === -1) { 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 draggedItem = currentParticipants[draggedItemIndex];
const targetItem = currentParticipants[targetItemIndex]; const targetItem = currentParticipants[targetItemIndex];
// Crucial: Only allow reordering within the same initiative score for tie-breaking
if (draggedItem.initiative !== targetItem.initiative) { 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); // Perform the reorder
reorderedParticipants.splice(targetItemIndex, 0, removedItem); const [removedItem] = currentParticipants.splice(draggedItemIndex, 1); // Remove dragged item
currentParticipants.splice(targetItemIndex, 0, removedItem); // Insert it at the target's position
try { try {
await updateDoc(doc(db, encounterPath), { participants: reorderedParticipants }); // Update Firestore with the new participants order
} catch (err) { console.error("Error updating participants after drag-drop:", err); } await updateDoc(doc(db, encounterPath), { participants: currentParticipants });
setDraggedItemId(null); 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 = () => { 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) => { const sortedAdminParticipants = [...participants].sort((a, b) => {
if (a.initiative === b.initiative) { 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 indexA = participants.findIndex(p => p.id === a.id);
const indexB = participants.findIndex(p => p.id === b.id); const indexB = participants.findIndex(p => p.id === b.id);
return indexA - indexB; 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) => { const initiativeGroups = participants.reduce((acc, p) => {
acc[p.initiative] = (acc[p.initiative] || 0) + 1; acc[p.initiative] = (acc[p.initiative] || 0) + 1;
return acc; return acc;
@ -705,6 +796,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
<ul className="space-y-2"> <ul className="space-y-2">
{sortedAdminParticipants.map((p, index) => { {sortedAdminParticipants.map((p, index) => {
const isCurrentTurn = encounter.isStarted && p.id === encounter.currentTurnParticipantId; 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)); const isDraggable = !encounter.isStarted && tiedInitiatives.includes(Number(p.initiative));
return ( return (
<li <li
@ -793,7 +885,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
await updateDoc(doc(db, encounterPath), { await updateDoc(doc(db, encounterPath), {
isStarted: true, round: 1, currentTurnParticipantId: sortedPs[0].id, turnOrderIds: sortedPs.map(p => p.id) isStarted: true, round: 1, currentTurnParticipantId: sortedPs[0].id, turnOrderIds: sortedPs.map(p => p.id)
}); });
await setDoc(doc(db, ACTIVE_DISPLAY_DOC), { await setDoc(doc(db, getActiveDisplayDocPath()), {
activeCampaignId: campaignId, activeCampaignId: campaignId,
activeEncounterId: encounter.id activeEncounterId: encounter.id
}, { merge: true }); }, { merge: true });
@ -824,7 +916,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
console.warn("Attempting to end encounter without confirmation"); console.warn("Attempting to end encounter without confirmation");
try { try {
await updateDoc(doc(db, encounterPath), { isStarted: false, currentTurnParticipantId: null, round: 0, turnOrderIds: [] }); 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, activeCampaignId: null,
activeEncounterId: null activeEncounterId: null
}, { merge: true }); }, { merge: true });
@ -852,81 +944,80 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
} }
function DisplayView() { function DisplayView() {
const { data: activeDisplayData, isLoading: isLoadingActiveDisplay, error: activeDisplayError } = useFirestoreDocument(getActiveDisplayDocPath());
const [activeEncounterData, setActiveEncounterData] = useState(null); const [activeEncounterData, setActiveEncounterData] = useState(null);
const [isLoading, setIsLoading] = useState(true); const [isLoadingEncounter, setIsLoadingEncounter] = useState(true); // Separate loading for encounter
const [error, setError] = useState(null); const [encounterError, setEncounterError] = useState(null);
const [campaignBackgroundUrl, setCampaignBackgroundUrl] = useState(''); const [campaignBackgroundUrl, setCampaignBackgroundUrl] = useState('');
const [isPlayerDisplayActive, setIsPlayerDisplayActive] = useState(false); const [isPlayerDisplayActive, setIsPlayerDisplayActive] = useState(false);
useEffect(() => { useEffect(() => {
if (!db) { 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 unsubscribeEncounter;
let unsubscribeCampaign; let unsubscribeCampaign;
const activeDisplayRef = doc(db, ACTIVE_DISPLAY_DOC); if (activeDisplayData) {
const unsubDisplayConfig = onSnapshot(activeDisplayRef, async (docSnap) => { const { activeCampaignId, activeEncounterId } = activeDisplayData;
if (docSnap.exists()) {
const { activeCampaignId, activeEncounterId } = docSnap.data();
if (activeCampaignId && activeEncounterId) { if (activeCampaignId && activeEncounterId) {
setIsPlayerDisplayActive(true); setIsPlayerDisplayActive(true);
setIsLoadingEncounter(true);
setEncounterError(null);
const campaignDocRef = doc(db, CAMPAIGNS_COLLECTION, activeCampaignId); const campaignDocRef = doc(db, getCampaignDocPath(activeCampaignId));
if (unsubscribeCampaign) unsubscribeCampaign(); unsubscribeCampaign = onSnapshot(campaignDocRef, (campSnap) => {
unsubscribeCampaign = onSnapshot(campaignDocRef, (campSnap) => { if (campSnap.exists()) {
if (campSnap.exists()) { setCampaignBackgroundUrl(campSnap.data().playerDisplayBackgroundUrl || '');
setCampaignBackgroundUrl(campSnap.data().playerDisplayBackgroundUrl || ''); } else {
} else { setCampaignBackgroundUrl('');
setCampaignBackgroundUrl(''); }
} }, (err) => console.error("Error fetching campaign background for display:", err));
}, (err) => console.error("Error fetching campaign background for display:", err));
const encounterPath = `${CAMPAIGNS_COLLECTION}/${activeCampaignId}/encounters/${activeEncounterId}`; const encounterPath = getEncounterDocPath(activeCampaignId, activeEncounterId);
if (unsubscribeEncounter) unsubscribeEncounter(); unsubscribeEncounter = onSnapshot(doc(db, encounterPath), (encDocSnap) => {
unsubscribeEncounter = onSnapshot(doc(db, encounterPath), (encDocSnap) => { if (encDocSnap.exists()) {
if (encDocSnap.exists()) { setActiveEncounterData({ id: encDocSnap.id, ...encDocSnap.data() });
setActiveEncounterData({ id: encDocSnap.id, ...encDocSnap.data() }); } else {
setError(null); setActiveEncounterData(null);
} else { setEncounterError("Active encounter data not found.");
setActiveEncounterData(null); }
setError("Active encounter data not found. The DM might have deleted it or it's misconfigured."); setIsLoadingEncounter(false);
} }, (err) => {
setIsLoading(false); console.error("Error fetching active encounter details for display:", err);
}, (err) => { setEncounterError("Error loading active encounter data.");
console.error("Error fetching active encounter details for display:", err); setIsLoadingEncounter(false);
setError("Error loading active encounter data."); });
setIsLoading(false); } else {
}); setActiveEncounterData(null);
} else { setCampaignBackgroundUrl('');
setActiveEncounterData(null); setIsPlayerDisplayActive(false);
setCampaignBackgroundUrl(''); setIsLoadingEncounter(false);
setIsPlayerDisplayActive(false);
setIsLoading(false);
} }
} else { } else if (!isLoadingActiveDisplay) { // activeDisplayData is null and not loading
setActiveEncounterData(null); setActiveEncounterData(null);
setCampaignBackgroundUrl(''); setCampaignBackgroundUrl('');
setIsPlayerDisplayActive(false); setIsPlayerDisplayActive(false);
setIsLoading(false); setIsLoadingEncounter(false);
} }
}, (err) => {
console.error("Error fetching active display config:", err);
setError("Could not load display configuration.");
setIsLoading(false);
});
return () => { return () => {
unsubDisplayConfig();
if (unsubscribeEncounter) unsubscribeEncounter(); if (unsubscribeEncounter) unsubscribeEncounter();
if (unsubscribeCampaign) unsubscribeCampaign(); if (unsubscribeCampaign) unsubscribeCampaign();
}; };
}, []); }, [activeDisplayData, isLoadingActiveDisplay]);
if (isLoading) return <div className="text-center py-10 text-2xl text-slate-300">Loading Player Display...</div>; if (isLoadingActiveDisplay || (isPlayerDisplayActive && isLoadingEncounter)) {
if (error) return <div className="text-center py-10 text-2xl text-red-400">{error}</div>; return <div className="text-center py-10 text-2xl text-slate-300">Loading Player Display...</div>;
}
if (activeDisplayError || (isPlayerDisplayActive && encounterError)) {
return <div className="text-center py-10 text-2xl text-red-400">{activeDisplayError || encounterError}</div>;
}
if (!isPlayerDisplayActive || !activeEncounterData) { if (!isPlayerDisplayActive || !activeEncounterData) {
return ( return (
@ -990,7 +1081,7 @@ function DisplayView() {
<div className="w-full bg-slate-600 rounded-full h-6 md:h-8 relative overflow-hidden border-2 border-slate-500"> <div className="w-full bg-slate-600 rounded-full h-6 md:h-8 relative overflow-hidden border-2 border-slate-500">
<div className={`h-full rounded-full transition-all ${p.currentHp <= p.maxHp / 4 ? 'bg-red-500' : (p.currentHp <= p.maxHp / 2 ? 'bg-yellow-500' : 'bg-green-500')}`} style={{ width: `${Math.max(0, (p.currentHp / p.maxHp) * 100)}%` }}></div> <div className={`h-full rounded-full transition-all ${p.currentHp <= p.maxHp / 4 ? 'bg-red-500' : (p.currentHp <= p.maxHp / 2 ? 'bg-yellow-500' : 'bg-green-500')}`} style={{ width: `${Math.max(0, (p.currentHp / p.maxHp) * 100)}%` }}></div>
{p.type !== 'monster' && ( {p.type !== 'monster' && (
<span className="absolute inset-0 flex items-center justify-center text-xs md:text-sm font-medium text-white px-2"> {/* Removed mix-blend-difference */} <span className="absolute inset-0 flex items-center justify-center text-xs md:text-sm font-medium text-white px-2">
HP: {p.currentHp} / {p.maxHp} HP: {p.currentHp} / {p.maxHp}
</span> </span>
)} )}
@ -1029,4 +1120,4 @@ const PlayIcon = ({ size = 24, className = '' }) => <svg xmlns="http://www.w3.or
const SkipForwardIcon = ({ size = 24, className = '' }) => <svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><polygon points="5 4 15 12 5 20 5 4"></polygon><line x1="19" y1="5" x2="19" y2="19"></line></svg>; const SkipForwardIcon = ({ size = 24, className = '' }) => <svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><polygon points="5 4 15 12 5 20 5 4"></polygon><line x1="19" y1="5" x2="19" y2="19"></line></svg>;
const StopCircleIcon = ({size=24, className=''}) => <svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><circle cx="12" cy="12" r="10"></circle><rect x="9" y="9" width="6" height="6"></rect></svg>; const StopCircleIcon = ({size=24, className=''}) => <svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><circle cx="12" cy="12" r="10"></circle><rect x="9" y="9" width="6" height="6"></rect></svg>;
export default App; export default App;