diff --git a/.gitignore b/.gitignore index e6905a2..02e711e 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,9 @@ -.env* \ No newline at end of file +# .gitignore +node_modules +build +dist +.env +.env.local +.env.development.local +.env.test.local +.env.production.local \ No newline at end of file diff --git a/App.js b/App.js new file mode 100644 index 0000000..173176b --- /dev/null +++ b/App.js @@ -0,0 +1,946 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { initializeApp } from 'firebase/app'; +import { getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken } from 'firebase/auth'; +import { getFirestore, doc, setDoc, addDoc, getDoc, getDocs, collection, onSnapshot, updateDoc, deleteDoc, query, where, writeBatch } from 'firebase/firestore'; +import { ArrowLeft, PlusCircle, Users, Swords, Shield, Trash2, Eye, Edit3, Save, XCircle, ChevronsUpDown, ChevronDown, ChevronUp, UserCheck, UserX, HeartCrack, HeartPulse, Zap, Share2, Copy as CopyIcon } from 'lucide-react'; + +// --- Firebase Configuration --- +// Read from environment variables +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 +}; + +// --- Initialize Firebase --- +// Check if all necessary Firebase config values are present +const requiredFirebaseConfigKeys = [ + 'apiKey', 'authDomain', 'projectId', 'appId' + // storageBucket and messagingSenderId might be optional depending on usage +]; +const missingKeys = requiredFirebaseConfigKeys.filter(key => !firebaseConfig[key]); + +let app; +if (missingKeys.length > 0) { + console.error(`Missing Firebase config keys from environment variables: ${missingKeys.join(', ')}`); + console.warn("Firebase is not initialized. Please set up your .env.local file with the necessary REACT_APP_FIREBASE_... variables."); + // You might want to render an error message or a fallback UI here +} else { + app = initializeApp(firebaseConfig); +} + +const db = app ? getFirestore(app) : null; // Conditionally get Firestore +const auth = app ? getAuth(app) : null; // Conditionally get Auth + +// --- Firestore Paths --- +const APP_ID = process.env.REACT_APP_TRACKER_APP_ID || 'ttrpg-initiative-tracker-default'; +// ... rest of your code + +// --- Helper Functions --- +const generateId = () => crypto.randomUUID(); + +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); + const [directDisplayParams, setDirectDisplayParams] = useState(null); + + useEffect(() => { + const handleHashChange = () => { + const hash = window.location.hash; + if (hash.startsWith('#/display/')) { + const parts = hash.substring('#/display/'.length).split('/'); + if (parts.length === 2 && parts[0] && parts[1]) { + setDirectDisplayParams({ campaignId: parts[0], encounterId: parts[1] }); + setViewMode('display'); + } else { + setDirectDisplayParams(null); + } + } else { + setDirectDisplayParams(null); + } + }; + window.addEventListener('hashchange', handleHashChange); + handleHashChange(); + return () => window.removeEventListener('hashchange', handleHashChange); + }, []); + + useEffect(() => { + const initAuth = async () => { + try { + if (typeof __initial_auth_token !== 'undefined' && __initial_auth_token) { + await signInWithCustomToken(auth, __initial_auth_token); + } else { + await signInAnonymously(auth); + } + } catch (err) { + console.error("Authentication error:", err); + setError("Failed to authenticate. Please try again later."); + } + }; + const unsubscribe = onAuthStateChanged(auth, (user) => { + setUserId(user ? user.uid : null); + setIsAuthReady(true); + setIsLoading(false); + }); + initAuth(); + return () => unsubscribe(); + }, []); + + if (isLoading || !isAuthReady) { + return ( +
+
+

Loading Initiative Tracker...

+ {error &&

{error}

} +
+ ); + } + + return ( +
+ {!directDisplayParams && ( +
+
+

TTRPG Initiative Tracker

+
+ {userId && UID: {userId}} + {viewMode !== 'display' && ( // Only show Admin View button if not in display mode (when header is visible) + + )} + {viewMode !== 'admin' && ( // Only show Player Display button if not in admin mode + + )} + {/* If in admin mode, show player display button. If in player mode, show admin button (unless hidden above) */} + {/* This logic seems a bit complex, let's simplify: always show both if header is visible, style active one */} + {viewMode === 'admin' && ( // Show Player Display button if in Admin mode + + )} +
+
+
+ )} + +
+ {directDisplayParams && isAuthReady && ( + + )} + {!directDisplayParams && viewMode === 'admin' && isAuthReady && userId && } + {!directDisplayParams && viewMode === 'display' && isAuthReady && } + {!isAuthReady &&

Authenticating...

} +
+ {!directDisplayParams && ( + + )} +
+ ); +} + +// --- 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(() => { + 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) => { + if (!name.trim()) return; + const newCampaignId = generateId(); + try { + await setDoc(doc(db, CAMPAIGNS_COLLECTION, newCampaignId), { + name: name.trim(), ownerId: userId, createdAt: new Date().toISOString(), players: [], + }); + setShowCreateCampaignModal(false); + setSelectedCampaignId(newCampaignId); + } catch (err) { console.error("Error creating campaign:", err); } + }; + + const handleDeleteCampaign = async (campaignId) => { + // TODO: 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}

+ +
+ ))} +
+
+ {showCreateCampaignModal && setShowCreateCampaignModal(false)} title="Create New Campaign"> setShowCreateCampaignModal(false)} />} + {selectedCampaign && ( +
+

Managing: {selectedCampaign.name}

+ +
+ +
+ )} +
+ ); +} + +function CreateCampaignForm({ onCreate, onCancel }) { + const [name, setName] = useState(''); + return ( +
{ e.preventDefault(); onCreate(name); }} className="space-y-4"> +
+ + setName(e.target.value)} className="mt-1 block w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-md shadow-sm focus:outline-none focus:ring-sky-500 focus:border-sky-500 sm:text-sm text-white" required /> +
+
+ + +
+
+ ); +} + +function CharacterManager({ campaignId, campaignCharacters }) { + const [characterName, setCharacterName] = useState(''); + const [editingCharacter, setEditingCharacter] = useState(null); + + const handleAddCharacter = async () => { + if (!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 (!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) => { + // 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

+
{ e.preventDefault(); handleAddCharacter(); }} className="flex gap-2 mb-4"> + setCharacterName(e.target.value)} placeholder="New character name" className="flex-grow px-3 py-2 bg-slate-700 border border-slate-600 rounded-md shadow-sm focus:outline-none focus:ring-sky-500 focus:border-sky-500 sm:text-sm text-white" /> + +
+ {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); + const [copiedLinkEncounterId, setCopiedLinkEncounterId] = useState(null); + const encountersPath = `${CAMPAIGNS_COLLECTION}/${campaignId}/encounters`; + const selectedEncounterIdRef = React.useRef(selectedEncounterId); // Ref to track current selection for initial set + + useEffect(() => { + selectedEncounterIdRef.current = selectedEncounterId; + }, [selectedEncounterId]); + + useEffect(() => { + if (!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); + + if (selectedEncounterIdRef.current === null || !fetchedEncounters.some(e => e.id === selectedEncounterIdRef.current)) { + 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(() => { + 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 (!name.trim() || !campaignId) return; + const newEncounterId = generateId(); + try { + await setDoc(doc(db, encountersPath, newEncounterId), { + name: name.trim(), createdAt: new Date().toISOString(), participants: [], round: 0, currentTurnParticipantId: null, isStarted: false, + }); + setShowCreateEncounterModal(false); + setSelectedEncounterId(newEncounterId); + } catch (err) { console.error("Error creating encounter:", err); } + }; + + const handleDeleteEncounter = async (encounterId) => { + // TODO: 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), { activeEncounterId: null }); + } + } catch (err) { console.error("Error deleting encounter:", err); } + }; + + const handleSetEncounterAsActiveDisplay = async (encounterId) => { + try { + await setDoc(doc(db, ACTIVE_DISPLAY_DOC), { activeCampaignId: campaignId, activeEncounterId: encounterId }, { merge: true }); + console.log("Encounter set as active for DM's main display!"); + } catch (err) { console.error("Error setting active display:", err); } + }; + + const handleCopyToClipboard = (encounterId) => { + const link = `${getShareableLinkBase()}#/display/${campaignId}/${encounterId}`; + navigator.clipboard.writeText(link).then(() => { + setCopiedLinkEncounterId(encounterId); + setTimeout(() => setCopiedLinkEncounterId(null), 2000); + }).catch(err => console.error('Failed to copy link: ', err)); + }; + + const selectedEncounter = encounters.find(e => e.id === selectedEncounterId); + + return ( +
+
+

Encounters

+ +
+ {encounters.length === 0 &&

No encounters yet.

} +
+ {encounters.map(encounter => { + const isDmActiveDisplay = activeDisplayInfo && activeDisplayInfo.activeCampaignId === campaignId && activeDisplayInfo.activeEncounterId === encounter.id; + return ( +
+
+
setSelectedEncounterId(encounter.id)} className="cursor-pointer flex-grow"> +

{encounter.name}

+

Participants: {encounter.participants?.length || 0}

+ {isDmActiveDisplay && LIVE ON DM DISPLAY} +
+
+ + + {copiedLinkEncounterId === encounter.id && Copied!} + +
+
+ {selectedEncounterId === encounter.id && ( +
+

Shareable Link for Players:

+
+ + +
+
+ )} +
+ ); + })} +
+ {showCreateEncounterModal && setShowCreateEncounterModal(false)} title="Create New Encounter"> setShowCreateEncounterModal(false)} />} + {selectedEncounter && ( +
+

Managing Encounter: {selectedEncounter.name}

+ + +
+ )} +
+ ); +} + +function CreateEncounterForm({ onCreate, onCancel }) { + const [name, setName] = useState(''); + return ( +
{ e.preventDefault(); onCreate(name); }} className="space-y-4"> +
+ + setName(e.target.value)} className="mt-1 block w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-md shadow-sm focus:outline-none focus:ring-sky-500 focus:border-sky-500 sm:text-sm text-white" required /> +
+
+ + +
+
+ ); +} + +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 ((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 (!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) => { + // 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) => { + 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) => { + 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 (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) { + console.log("Cannot drag between different initiative groups for tie-breaking."); + 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

+
{ e.preventDefault(); handleAddParticipant(); }} className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4 p-3 bg-slate-700 rounded"> +
+ + +
+ + {participantType === 'monster' && ( +
+ + setParticipantName(e.target.value)} placeholder="e.g., Dire Wolf" className="mt-1 w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-md shadow-sm focus:outline-none focus:ring-sky-500 focus:border-sky-500 sm:text-sm text-white" /> +
+ )} + {participantType === 'character' && (
)} + +
setInitiative(e.target.value)} className="mt-1 w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-md shadow-sm focus:outline-none focus:ring-sky-500 focus:border-sky-500 sm:text-sm text-white" />
+
setMaxHp(e.target.value)} className="mt-1 w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-md shadow-sm focus:outline-none focus:ring-sky-500 focus:border-sky-500 sm:text-sm text-white" />
+ +
+ +
+
+ {participants.length === 0 &&

No participants.

} + + {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 ( + +
+
setName(e.target.value)} className="mt-1 block w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-md shadow-sm focus:outline-none focus:ring-sky-500 focus:border-sky-500 sm:text-sm text-white" />
+
setInitiative(e.target.value)} className="mt-1 block w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-md shadow-sm focus:outline-none focus:ring-sky-500 focus:border-sky-500 sm:text-sm text-white" />
+
+
setCurrentHp(e.target.value)} className="mt-1 block w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-md shadow-sm focus:outline-none focus:ring-sky-500 focus:border-sky-500 sm:text-sm text-white" />
+
setMaxHp(e.target.value)} className="mt-1 block w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-md shadow-sm focus:outline-none focus:ring-sky-500 focus:border-sky-500 sm:text-sm text-white" />
+
+
+ + +
+
+
+ ); +} + +function InitiativeControls({ campaignId, encounter, encounterPath }) { + const handleStartEncounter = async () => { + if (!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) + // Participants array in DB already reflects D&D order, so no need to update it here again unless sorting changes it. + // The `participants` field in the database should be the source of truth for the D&D order. + // The `turnOrderIds` is derived from this for active combat. + }); + 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 (!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 () => { + // 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: [] }); + } catch (err) { console.error("Error ending encounter:", err); } + }; + + if (!encounter || !encounter.participants) return null; + return ( +
+

Combat Controls

+
+ {!encounter.isStarted ? ( + + ) : ( + <> + + +

Round: {encounter.round}

+ + )} +
+
+ ); +} + +function DisplayView({ campaignIdFromUrl, encounterIdFromUrl }) { + const [activeEncounterData, setActiveEncounterData] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + setIsLoading(true); setError(null); setActiveEncounterData(null); + let unsubscribeEncounter; + + if (campaignIdFromUrl && encounterIdFromUrl) { + const encounterPath = `${CAMPAIGNS_COLLECTION}/${campaignIdFromUrl}/encounters/${encounterIdFromUrl}`; + unsubscribeEncounter = onSnapshot(doc(db, encounterPath), (encDocSnap) => { + if (encDocSnap.exists()) setActiveEncounterData({ id: encDocSnap.id, ...encDocSnap.data() }); + else setError("The requested encounter was not found or is not accessible."); + setIsLoading(false); + }, (err) => { console.error("Error fetching specific encounter for display:", err); setError("Error loading encounter data from link."); setIsLoading(false); }); + } else { + const activeDisplayRef = doc(db, ACTIVE_DISPLAY_DOC); + const unsubDisplayConfig = onSnapshot(activeDisplayRef, async (docSnap) => { + if (docSnap.exists()) { + const { activeCampaignId, activeEncounterId } = docSnap.data(); + if (activeCampaignId && activeEncounterId) { + const encounterPath = `${CAMPAIGNS_COLLECTION}/${activeCampaignId}/encounters/${activeEncounterId}`; + if(unsubscribeEncounter) unsubscribeEncounter(); + unsubscribeEncounter = onSnapshot(doc(db, encounterPath), (encDocSnap) => { + if (encDocSnap.exists()) setActiveEncounterData({ id: encDocSnap.id, ...encDocSnap.data() }); + else { setActiveEncounterData(null); setError("Active encounter not found. The DM might have deleted it.");} + setIsLoading(false); + }, (err) => { console.error("Error fetching active encounter details:", err); setError("Error loading active encounter data."); setIsLoading(false);}); + } else { setActiveEncounterData(null); setIsLoading(false); } + } else { setActiveEncounterData(null); setIsLoading(false); } + }, (err) => { console.error("Error fetching active display config:", err); setError("Could not load display configuration."); setIsLoading(false); }); + return () => { unsubDisplayConfig(); if (unsubscribeEncounter) unsubscribeEncounter(); }; + } + return () => { if (unsubscribeEncounter) unsubscribeEncounter(); }; + }, [campaignIdFromUrl, encounterIdFromUrl]); + + if (isLoading) return
Loading Player Display...
; + if (error) return
{error}
; + if (!activeEncounterData) return
No active encounter to display.
; + + const { name, participants, round, currentTurnParticipantId, isStarted } = activeEncounterData; + + let displayParticipants = []; + if (isStarted && activeEncounterData.turnOrderIds?.length > 0 && participants) { + displayParticipants = activeEncounterData.turnOrderIds + .map(id => participants.find(p => p.id === id)).filter(p => p && p.isActive); + } else if (participants) { + displayParticipants = [...participants].filter(p => p.isActive) + .sort((a, b) => { + if (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; + }); + } + + 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' && ( // Only show HP text if not a 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 ( +
+
+
+

{title}

+ +
+ {children} +
+
+ ); +} + +const PlayIcon = ({ size = 24, className = '' }) => ; +const SkipForwardIcon = ({ size = 24, className = '' }) => ; +const StopCircleIcon = ({size=24, className=''}) => ; + +export default App; diff --git a/app.js b/app.js deleted file mode 100644 index f1426a0..0000000 --- a/app.js +++ /dev/null @@ -1,1090 +0,0 @@ -import React, { useState, useEffect, useCallback } from 'react'; -import { initializeApp } from 'firebase/app'; -import { getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken } from 'firebase/auth'; -import { getFirestore, doc, setDoc, addDoc, getDoc, getDocs, collection, onSnapshot, updateDoc, deleteDoc, query, where, writeBatch } from 'firebase/firestore'; -import { ArrowLeft, PlusCircle, Users, Swords, Shield, Trash2, Eye, Edit3, Save, XCircle, ChevronsUpDown, ChevronDown, ChevronUp, UserCheck, UserX, HeartCrack, HeartPulse } from 'lucide-react'; - -// --- Firebase Configuration --- -// NOTE: Replace with your actual Firebase config -const firebaseConfig = typeof __firebase_config !== 'undefined' ? JSON.parse(__firebase_config) : { - apiKey: "YOUR_API_KEY", - authDomain: "YOUR_AUTH_DOMAIN", - projectId: "YOUR_PROJECT_ID", - storageBucket: "YOUR_STORAGE_BUCKET", - messagingSenderId: "YOUR_MESSAGING_SENDER_ID", - appId: "YOUR_APP_ID" -}; - -// --- Initialize Firebase --- -const app = initializeApp(firebaseConfig); -const db = getFirestore(app); -const auth = getAuth(app); - -// --- Firestore Paths --- -const APP_ID = typeof __app_id !== 'undefined' ? __app_id : 'ttrpg-initiative-tracker-default'; -const PUBLIC_DATA_PATH = `artifacts/${APP_ID}/public/data`; -const CAMPAIGNS_COLLECTION = `${PUBLIC_DATA_PATH}/campaigns`; -const ACTIVE_DISPLAY_DOC = `${PUBLIC_DATA_PATH}/activeDisplay/status`; - -// --- Helper Functions --- -const generateId = () => crypto.randomUUID(); - -// --- Main App Component --- -function App() { - const [userId, setUserId] = useState(null); - const [isAuthReady, setIsAuthReady] = useState(false); - const [viewMode, setViewMode] = useState('admin'); // 'admin' or 'display' - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - - // --- Authentication --- - useEffect(() => { - const initAuth = async () => { - try { - if (typeof __initial_auth_token !== 'undefined' && __initial_auth_token) { - await signInWithCustomToken(auth, __initial_auth_token); - } else { - await signInAnonymously(auth); - } - } catch (err) { - console.error("Authentication error:", err); - setError("Failed to authenticate. Please try again later."); - } - }; - - const unsubscribe = onAuthStateChanged(auth, (user) => { - if (user) { - setUserId(user.uid); - } else { - setUserId(null); - } - setIsAuthReady(true); - setIsLoading(false); - }); - - initAuth(); - return () => unsubscribe(); - }, []); - - if (isLoading || !isAuthReady) { - return ( -
-
-

Loading Initiative Tracker...

- {error &&

{error}

} -
- ); - } - - return ( -
-
-
-

TTRPG Initiative Tracker

-
- {userId && UID: {userId}} - - -
-
-
- -
- {viewMode === 'admin' && isAuthReady && userId && } - {viewMode === 'display' && isAuthReady && } - {!isAuthReady &&

Authenticating...

} -
- -
- ); -} - -// --- Admin View Component --- -function AdminView({ userId }) { - const [campaigns, setCampaigns] = useState([]); - const [selectedCampaignId, setSelectedCampaignId] = useState(null); - const [showCreateCampaignModal, setShowCreateCampaignModal] = useState(false); - - // --- Fetch Campaigns --- - useEffect(() => { - const campaignsCollectionRef = collection(db, CAMPAIGNS_COLLECTION); - const q = query(campaignsCollectionRef); - - const unsubscribe = onSnapshot(q, (snapshot) => { - const camps = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() })); - setCampaigns(camps); - }, (err) => { - console.error("Error fetching campaigns:", err); - }); - return () => unsubscribe(); - }, []); - - const handleCreateCampaign = async (name) => { - if (!name.trim()) return; - const newCampaignId = generateId(); - try { - await setDoc(doc(db, CAMPAIGNS_COLLECTION, newCampaignId), { - name: name.trim(), - ownerId: userId, - createdAt: new Date().toISOString(), - players: [], - }); - setShowCreateCampaignModal(false); - setSelectedCampaignId(newCampaignId); - } catch (err) { - console.error("Error creating campaign:", err); - } - }; - - const handleDeleteCampaign = async (campaignId) => { - // TODO: Replace window.confirm with a custom modal - if (!window.confirm("Are you sure you want to delete this campaign and all its encounters? This action cannot be undone.")) return; - try { - const encountersPath = `${CAMPAIGNS_COLLECTION}/${campaignId}/encounters`; - const encountersSnapshot = await getDocs(collection(db, encountersPath)); - const batch = writeBatch(db); - encountersSnapshot.docs.forEach(encounterDoc => { - batch.delete(encounterDoc.ref); - }); - await batch.commit(); - - await deleteDoc(doc(db, CAMPAIGNS_COLLECTION, campaignId)); - - if (selectedCampaignId === campaignId) { - setSelectedCampaignId(null); - } - const activeDisplayRef = doc(db, ACTIVE_DISPLAY_DOC); - const activeDisplaySnap = await getDoc(activeDisplayRef); - if (activeDisplaySnap.exists() && activeDisplaySnap.data().activeCampaignId === campaignId) { - await updateDoc(activeDisplayRef, { activeCampaignId: null, activeEncounterId: null }); - } - } catch (err) { - console.error("Error deleting campaign:", err); - } - }; - - const selectedCampaign = campaigns.find(c => c.id === selectedCampaignId); - - return ( -
-
-
-

Campaigns

- -
- {campaigns.length === 0 &&

No campaigns yet. Create one to get started!

} -
- {campaigns.map(campaign => ( -
-
setSelectedCampaignId(campaign.id)}> -

{campaign.name}

-

ID: {campaign.id}

-
- -
- ))} -
-
- - {showCreateCampaignModal && ( - setShowCreateCampaignModal(false)} title="Create New Campaign"> - setShowCreateCampaignModal(false)} /> - - )} - - {selectedCampaign && ( -
-

Managing: {selectedCampaign.name}

- -
- -
- )} -
- ); -} - -// --- Create Campaign Form --- -function CreateCampaignForm({ onCreate, onCancel }) { - const [name, setName] = useState(''); - return ( -
{ e.preventDefault(); onCreate(name); }} className="space-y-4"> -
- - setName(e.target.value)} - className="mt-1 block w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-md shadow-sm focus:outline-none focus:ring-sky-500 focus:border-sky-500 sm:text-sm text-white" - required - /> -
-
- - -
-
- ); -} - -// --- Player Manager --- -function PlayerManager({ campaignId, campaignPlayers }) { - const [playerName, setPlayerName] = useState(''); - const [editingPlayer, setEditingPlayer] = useState(null); - - const handleAddPlayer = async () => { - if (!playerName.trim() || !campaignId) return; - const newPlayer = { id: generateId(), name: playerName.trim() }; - const campaignRef = doc(db, CAMPAIGNS_COLLECTION, campaignId); - try { - await updateDoc(campaignRef, { - players: [...campaignPlayers, newPlayer] - }); - setPlayerName(''); - } catch (err) { - console.error("Error adding player:", err); - } - }; - - const handleUpdatePlayer = async (playerId, newName) => { - if (!newName.trim() || !campaignId) return; - const updatedPlayers = campaignPlayers.map(p => p.id === playerId ? { ...p, name: newName.trim() } : p); - const campaignRef = doc(db, CAMPAIGNS_COLLECTION, campaignId); - try { - await updateDoc(campaignRef, { players: updatedPlayers }); - setEditingPlayer(null); - } catch (err) - { - console.error("Error updating player:", err); - } - }; - - const handleDeletePlayer = async (playerId) => { - // TODO: Replace window.confirm - if (!window.confirm("Are you sure you want to remove this player from the campaign?")) return; - const updatedPlayers = campaignPlayers.filter(p => p.id !== playerId); - const campaignRef = doc(db, CAMPAIGNS_COLLECTION, campaignId); - try { - await updateDoc(campaignRef, { players: updatedPlayers }); - } catch (err) { - console.error("Error deleting player:", err); - } - }; - - return ( -
-

Campaign Players

-
{ e.preventDefault(); handleAddPlayer(); }} className="flex gap-2 mb-4"> - setPlayerName(e.target.value)} - placeholder="New player name" - className="flex-grow px-3 py-2 bg-slate-700 border border-slate-600 rounded-md shadow-sm focus:outline-none focus:ring-sky-500 focus:border-sky-500 sm:text-sm text-white" - /> - -
- {campaignPlayers.length === 0 &&

No players added to this campaign yet.

} - -
- ); -} - -// --- Encounter Manager --- -function EncounterManager({ campaignId, campaignPlayers }) { - const [encounters, setEncounters] = useState([]); - const [selectedEncounterId, setSelectedEncounterId] = useState(null); - const [showCreateEncounterModal, setShowCreateEncounterModal] = useState(false); - const [activeDisplayInfo, setActiveDisplayInfo] = useState(null); // Stores { activeCampaignId, activeEncounterId } - const encountersPath = `${CAMPAIGNS_COLLECTION}/${campaignId}/encounters`; - - // --- Fetch Encounters --- - useEffect(() => { - if (!campaignId) return; - const encountersCollectionRef = collection(db, encountersPath); - const q = query(encountersCollectionRef); - - const unsubscribe = onSnapshot(q, (snapshot) => { - const encs = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() })); - setEncounters(encs); - }, (err) => { - console.error(`Error fetching encounters for campaign ${campaignId}:`, err); - }); - return () => unsubscribe(); - }, [campaignId, encountersPath]); - - // --- Fetch Active Display Info --- - useEffect(() => { - const unsub = onSnapshot(doc(db, ACTIVE_DISPLAY_DOC), (docSnap) => { - if (docSnap.exists()) { - setActiveDisplayInfo(docSnap.data()); - } else { - setActiveDisplayInfo(null); - } - }, (err) => { - console.error("Error fetching active display info:", err); - setActiveDisplayInfo(null); - }); - return () => unsub(); - }, []); - - - const handleCreateEncounter = async (name) => { - if (!name.trim() || !campaignId) return; - const newEncounterId = generateId(); - try { - await setDoc(doc(db, encountersPath, newEncounterId), { - name: name.trim(), - createdAt: new Date().toISOString(), - participants: [], - round: 0, - currentTurnParticipantId: null, - isStarted: false, - }); - setShowCreateEncounterModal(false); - setSelectedEncounterId(newEncounterId); - } catch (err) { - console.error("Error creating encounter:", err); - } - }; - - const handleDeleteEncounter = async (encounterId) => { - // TODO: Replace window.confirm - if (!window.confirm("Are you sure you want to delete this encounter? This action cannot be undone.")) return; - try { - await deleteDoc(doc(db, encountersPath, encounterId)); - if (selectedEncounterId === encounterId) { - setSelectedEncounterId(null); - } - if (activeDisplayInfo && activeDisplayInfo.activeEncounterId === encounterId) { - await updateDoc(doc(db, ACTIVE_DISPLAY_DOC), { activeEncounterId: null }); - } - } catch (err) { - console.error("Error deleting encounter:", err); - } - }; - - const handleSetEncounterAsActiveDisplay = async (encounterId) => { - try { - await setDoc(doc(db, ACTIVE_DISPLAY_DOC), { - activeCampaignId: campaignId, - activeEncounterId: encounterId, - }, { merge: true }); - console.log("Encounter set as active for display!"); // Replaced alert with console.log - } catch (err) { - console.error("Error setting active display:", err); - } - }; - - const selectedEncounter = encounters.find(e => e.id === selectedEncounterId); - - return ( -
-
-

Encounters

- -
- {encounters.length === 0 &&

No encounters in this campaign yet.

} -
- {encounters.map(encounter => { - const isActiveOnDisplay = activeDisplayInfo && - activeDisplayInfo.activeCampaignId === campaignId && - activeDisplayInfo.activeEncounterId === encounter.id; - return ( -
-
-
setSelectedEncounterId(encounter.id)} className="cursor-pointer flex-grow"> -

{encounter.name}

-

Participants: {encounter.participants?.length || 0}

- {isActiveOnDisplay && LIVE ON DISPLAY} -
-
- - -
-
-
- ); - })} -
- - {showCreateEncounterModal && ( - setShowCreateEncounterModal(false)} title="Create New Encounter"> - setShowCreateEncounterModal(false)} /> - - )} - - {selectedEncounter && ( -
-

Managing Encounter: {selectedEncounter.name}

- - -
- )} -
- ); -} - -// --- Create Encounter Form --- -function CreateEncounterForm({ onCreate, onCancel }) { - const [name, setName] = useState(''); - return ( -
{ e.preventDefault(); onCreate(name); }} className="space-y-4"> -
- - setName(e.target.value)} - className="mt-1 block w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-md shadow-sm focus:outline-none focus:ring-sky-500 focus:border-sky-500 sm:text-sm text-white" - required - /> -
-
- - -
-
- ); -} - -// --- Participant Manager --- -function ParticipantManager({ encounter, encounterPath, campaignPlayers }) { - const [participantName, setParticipantName] = useState(''); - const [participantType, setParticipantType] = useState('monster'); - const [selectedPlayerId, setSelectedPlayerId] = useState(''); - const [initiative, setInitiative] = useState(10); - const [maxHp, setMaxHp] = useState(10); - const [editingParticipant, setEditingParticipant] = useState(null); - const [hpChangeValues, setHpChangeValues] = useState({}); // { [participantId]: "value" } - - const participants = encounter.participants || []; - - const handleAddParticipant = async () => { - if ((participantType === 'monster' && !participantName.trim()) || (participantType === 'player' && !selectedPlayerId)) return; - - let nameToAdd = participantName.trim(); - if (participantType === 'player') { - const player = campaignPlayers.find(p => p.id === selectedPlayerId); - if (!player) { - console.error("Selected player not found"); - return; - } - if (participants.some(p => p.type === 'player' && p.originalPlayerId === selectedPlayerId)) { - // TODO: Replace alert with better notification - alert(`${player.name} is already in this encounter.`); - return; - } - nameToAdd = player.name; - } - - const newParticipant = { - id: generateId(), - name: nameToAdd, - type: participantType, - originalPlayerId: participantType === 'player' ? selectedPlayerId : null, - initiative: parseInt(initiative, 10) || 0, - maxHp: parseInt(maxHp, 10) || 1, - currentHp: parseInt(maxHp, 10) || 1, - conditions: [], - isActive: true, - }; - - try { - await updateDoc(doc(db, encounterPath), { - participants: [...participants, newParticipant] - }); - setParticipantName(''); - setInitiative(10); - setMaxHp(10); - setSelectedPlayerId(''); - } catch (err) { - console.error("Error adding participant:", err); - } - }; - - const handleUpdateParticipant = async (updatedData) => { - if (!editingParticipant) return; - const updatedParticipants = participants.map(p => - p.id === editingParticipant.id ? { ...p, ...updatedData } : p - ); - try { - await updateDoc(doc(db, encounterPath), { participants: updatedParticipants }); - setEditingParticipant(null); - } catch (err) { - console.error("Error updating participant:", err); - } - }; - - const handleDeleteParticipant = async (participantId) => { - // TODO: Replace window.confirm - if (!window.confirm("Remove this participant from the encounter?")) return; - const updatedParticipants = participants.filter(p => p.id !== participantId); - try { - await updateDoc(doc(db, encounterPath), { participants: updatedParticipants }); - } catch (err) { - console.error("Error deleting participant:", err); - } - }; - - const toggleParticipantActive = async (participantId) => { - const participant = participants.find(p => p.id === participantId); - if (!participant) return; - const updatedParticipants = participants.map(p => - p.id === participantId ? { ...p, isActive: !p.isActive } : p - ); - try { - await updateDoc(doc(db, encounterPath), { participants: updatedParticipants }); - } catch (err) { - console.error("Error toggling participant active state:", err); - } - }; - - const handleHpInputChange = (participantId, value) => { - setHpChangeValues(prev => ({ ...prev, [participantId]: value })); - }; - - const applyHpChange = async (participantId, changeType) => { - const amountStr = hpChangeValues[participantId]; - if (amountStr === undefined || amountStr.trim() === '') return; - - const amount = parseInt(amountStr, 10); - if (isNaN(amount) || amount === 0) { - setHpChangeValues(prev => ({ ...prev, [participantId]: '' })); // Clear if invalid - return; - } - - const participant = participants.find(p => p.id === participantId); - if (!participant) return; - - let newHp = participant.currentHp; - if (changeType === 'damage') { - newHp = Math.max(0, participant.currentHp - amount); - } else if (changeType === 'heal') { - newHp = Math.min(participant.maxHp, participant.currentHp + amount); - } - - const updatedParticipants = participants.map(p => - p.id === participantId ? { ...p, currentHp: newHp } : p - ); - try { - await updateDoc(doc(db, encounterPath), { participants: updatedParticipants }); - setHpChangeValues(prev => ({ ...prev, [participantId]: '' })); // Clear input after applying - } catch (err) { - console.error("Error applying HP change:", err); - } - }; - - - const sortedAdminParticipants = [...participants].sort((a, b) => { - if (b.initiative === a.initiative) { - return a.name.localeCompare(b.name); - } - return b.initiative - a.initiative; - }); - - - return ( -
-

Participants

- {/* Add Participant Form */} -
-
- - -
- {participantType === 'monster' && ( -
- - setParticipantName(e.target.value)} placeholder="e.g., Goblin Boss" className="mt-1 w-full p-2 bg-slate-600 border-slate-500 rounded text-white" /> -
- )} - {participantType === 'player' && ( -
- - -
- )} -
- - setInitiative(e.target.value)} className="mt-1 w-full p-2 bg-slate-600 border-slate-500 rounded text-white" /> -
-
- - setMaxHp(e.target.value)} className="mt-1 w-full p-2 bg-slate-600 border-slate-500 rounded text-white" /> -
-
- -
-
- - {/* Participant List */} - {participants.length === 0 &&

No participants added yet.

} - - - {editingParticipant && ( - setEditingParticipant(null)} - onSave={handleUpdateParticipant} - /> - )} -
- ); -} - -// --- Edit Participant Modal --- -function EditParticipantModal({ participant, onClose, onSave }) { - const [name, setName] = useState(participant.name); - const [initiative, setInitiative] = useState(participant.initiative); - const [currentHp, setCurrentHp] = useState(participant.currentHp); - const [maxHp, setMaxHp] = useState(participant.maxHp); - - const handleSubmit = (e) => { - e.preventDefault(); - onSave({ - name: name.trim(), - initiative: parseInt(initiative, 10), - currentHp: parseInt(currentHp, 10), - maxHp: parseInt(maxHp, 10), - }); - }; - - return ( - -
-
- - setName(e.target.value)} className="mt-1 w-full p-2 bg-slate-700 border-slate-600 rounded text-white" /> -
-
- - setInitiative(e.target.value)} className="mt-1 w-full p-2 bg-slate-700 border-slate-600 rounded text-white" /> -
-
-
- - setCurrentHp(e.target.value)} className="mt-1 w-full p-2 bg-slate-700 border-slate-600 rounded text-white" /> -
-
- - setMaxHp(e.target.value)} className="mt-1 w-full p-2 bg-slate-700 border-slate-600 rounded text-white" /> -
-
-
- - -
-
-
- ); -} - -// --- Initiative Controls --- -function InitiativeControls({ encounter, encounterPath }) { - const handleStartEncounter = async () => { - if (!encounter.participants || encounter.participants.length === 0) { - // TODO: Replace alert - alert("Add participants before starting the encounter."); - return; - } - const activeParticipants = encounter.participants.filter(p => p.isActive); - if (activeParticipants.length === 0) { - // TODO: Replace alert - alert("No active participants to start the encounter."); - return; - } - - const sortedParticipants = [...activeParticipants].sort((a, b) => { - if (b.initiative === a.initiative) { - return Math.random() - 0.5; - } - return b.initiative - a.initiative; - }); - - try { - await updateDoc(doc(db, encounterPath), { - participants: encounter.participants.map(p => { - const sortedVersion = sortedParticipants.find(sp => sp.id === p.id); - return sortedVersion ? sortedVersion : p; - }), - isStarted: true, - round: 1, - currentTurnParticipantId: sortedParticipants[0].id, - turnOrderIds: sortedParticipants.map(p => p.id) - }); - } catch (err) { - console.error("Error starting encounter:", err); - } - }; - - const handleNextTurn = async () => { - if (!encounter.isStarted || !encounter.currentTurnParticipantId || !encounter.turnOrderIds || encounter.turnOrderIds.length === 0) return; - - const activeParticipantsInOrder = encounter.turnOrderIds - .map(id => encounter.participants.find(p => p.id === id && p.isActive)) - .filter(Boolean); - - if (activeParticipantsInOrder.length === 0) { - // TODO: Replace alert - alert("No active participants left in the turn order."); - await updateDoc(doc(db, encounterPath), { isStarted: false, currentTurnParticipantId: null, round: encounter.round }); - return; - } - - const currentIndex = activeParticipantsInOrder.findIndex(p => p.id === encounter.currentTurnParticipantId); - let nextIndex = (currentIndex + 1) % activeParticipantsInOrder.length; - let nextRound = encounter.round; - - if (nextIndex === 0 && currentIndex !== -1) { - nextRound += 1; - } - - const nextParticipantId = activeParticipantsInOrder[nextIndex].id; - - try { - await updateDoc(doc(db, encounterPath), { - currentTurnParticipantId: nextParticipantId, - round: nextRound - }); - } catch (err) { - console.error("Error advancing turn:", err); - } - }; - - const handleEndEncounter = async () => { - // TODO: Replace window.confirm - if (!window.confirm("Are you sure you want to end this encounter? Initiative order will be reset.")) return; - try { - await updateDoc(doc(db, encounterPath), { - isStarted: false, - currentTurnParticipantId: null, - round: 0, - turnOrderIds: [] - }); - } catch (err) { - console.error("Error ending encounter:", err); - } - }; - - - if (!encounter || !encounter.participants) return null; - - return ( -
-

Combat Controls

-
- {!encounter.isStarted ? ( - - ) : ( - <> - - -

Round: {encounter.round}

- - )} -
-
- ); -} - -// --- Display View Component --- -function DisplayView() { - const [activeEncounterData, setActiveEncounterData] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - setIsLoading(true); - const activeDisplayRef = doc(db, ACTIVE_DISPLAY_DOC); - - const unsubDisplayConfig = onSnapshot(activeDisplayRef, async (docSnap) => { - if (docSnap.exists()) { - const { activeCampaignId, activeEncounterId } = docSnap.data(); - if (activeCampaignId && activeEncounterId) { - const encounterPath = `${CAMPAIGNS_COLLECTION}/${activeCampaignId}/encounters/${activeEncounterId}`; - const unsubEncounter = onSnapshot(doc(db, encounterPath), (encDocSnap) => { - if (encDocSnap.exists()) { - setActiveEncounterData({ id: encDocSnap.id, ...encDocSnap.data() }); - setError(null); - } else { - setActiveEncounterData(null); - setError("Active encounter not found. The DM might have deleted it or it's no longer set for display."); - } - setIsLoading(false); - }, (err) => { - console.error("Error fetching active encounter details:", err); - setError("Error loading encounter data."); - setIsLoading(false); - }); - return () => unsubEncounter(); - } else { - setActiveEncounterData(null); - setIsLoading(false); - setError(null); - } - } else { - setActiveEncounterData(null); - setIsLoading(false); - setError(null); - } - }, (err) => { - console.error("Error fetching active display config:", err); - setError("Could not load display configuration."); - setIsLoading(false); - }); - - return () => unsubDisplayConfig(); - }, []); - - if (isLoading) { - return
Loading Player Display...
; - } - if (error) { - return
{error}
; - } - if (!activeEncounterData) { - return
No active encounter to display.
The DM needs to select one from the Admin View.
; - } - - const { name, participants, round, currentTurnParticipantId, isStarted } = activeEncounterData; - - let displayParticipants = []; - if (isStarted && activeEncounterData.turnOrderIds && activeEncounterData.turnOrderIds.length > 0) { - displayParticipants = activeEncounterData.turnOrderIds - .map(id => participants.find(p => p.id === id)) - .filter(p => p && p.isActive); - } else if (participants) { - displayParticipants = [...participants] - .filter(p => p.isActive) - .sort((a, b) => { - if (b.initiative === a.initiative) return a.name.localeCompare(b.name); - return b.initiative - a.initiative; - }); - } - - - return ( -
-

{name}

- {isStarted &&

Round: {round}

} - {!isStarted && participants && participants.length > 0 &&

Encounter Awaiting Start

} - {!isStarted && (!participants || participants.length === 0) &&

No participants in this encounter yet.

} - - {displayParticipants.length === 0 && isStarted && ( -

No active participants in the encounter.

- )} - -
- {displayParticipants.map((p, index) => ( -
-
-

- {p.name} - {p.id === currentTurnParticipantId && isStarted && (Current Turn)} -

- - Init: {p.initiative} - -
-
-
-
- - HP: {p.currentHp} / {p.maxHp} - -
-
- {p.conditions && p.conditions.length > 0 && ( -

Conditions: {p.conditions.join(', ')}

- )} - {!p.isActive &&

(Inactive)

} -
- ))} -
-
- ); -} - - -// --- Modal Component --- -function Modal({ onClose, title, children }) { - useEffect(() => { - const handleEsc = (event) => { - if (event.key === 'Escape') { - onClose(); - } - }; - window.addEventListener('keydown', handleEsc); - return () => window.removeEventListener('keydown', handleEsc); - }, [onClose]); - - return ( -
-
-
-

{title}

- -
- {children} -
-
- ); -} - -// --- Icons --- -const PlayIcon = ({ size = 24, className = '' }) => ; -const SkipForwardIcon = ({ size = 24, className = '' }) => ; -const StopCircleIcon = ({size=24, className=''}) => ; - - -export default App; - diff --git a/env.example b/env.example new file mode 100644 index 0000000..ef0bbff --- /dev/null +++ b/env.example @@ -0,0 +1,9 @@ +# .env.example (This file IS committed to Git) +REACT_APP_FIREBASE_API_KEY="YOUR_FIREBASE_API_KEY_HERE" +REACT_APP_FIREBASE_AUTH_DOMAIN="YOUR_FIREBASE_AUTH_DOMAIN_HERE" +REACT_APP_FIREBASE_PROJECT_ID="YOUR_FIREBASE_PROJECT_ID_HERE" +REACT_APP_FIREBASE_STORAGE_BUCKET="YOUR_FIREBASE_STORAGE_BUCKET_HERE" +REACT_APP_FIREBASE_MESSAGING_SENDER_ID="YOUR_FIREBASE_MESSAGING_SENDER_ID_HERE" +REACT_APP_FIREBASE_APP_ID="YOUR_FIREBASE_APP_ID_HERE" + +REACT_APP_TRACKER_APP_ID="ttrpg-initiative-tracker-default" \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..0cd18b9 --- /dev/null +++ b/package.json @@ -0,0 +1,40 @@ +{ + "name": "ttrpg-initiative-tracker", + "version": "0.1.0", + "private": true, + "dependencies": { + "@testing-library/jest-dom": "^5.17.0", // Optional: For testing + "@testing-library/react": "^13.4.0", // Optional: For testing + "@testing-library/user-event": "^13.5.0", // Optional: For testing + "firebase": "^10.12.2", // Firebase SDK + "lucide-react": "^0.395.0", // Icons + "react": "^18.3.1", // React library + "react-dom": "^18.3.1", // React DOM for web + "react-scripts": "5.0.1", // Scripts and configuration for Create React App + "web-vitals": "^2.1.4" // Optional: For measuring web vitals + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test", // Optional: For testing + "eject": "react-scripts eject" // Optional: For Create React App + }, + "eslintConfig": { // Optional: Basic ESLint setup + "extends": [ + "react-app", + "react-app/jest" + ] + }, + "browserslist": { // Optional: Defines browser support + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + } +} \ No newline at end of file