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 && (
+
+ )}
+
+
+ {directDisplayParams && isAuthReady && (
+
+ )}
+ {!directDisplayParams && viewMode === 'admin' && isAuthReady && userId && }
+ {!directDisplayParams && viewMode === 'display' && isAuthReady && }
+ {!isAuthReady && Authenticating...
}
+
+ {!directDisplayParams && (
+
+ TTRPG Initiative Tracker v0.1.12
+
+ )}
+
+ );
+}
+
+// --- 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
+
setShowCreateCampaignModal(true)} className="bg-green-500 hover:bg-green-600 text-white font-bold py-2 px-4 rounded-lg flex items-center transition-colors">
+ Create Campaign
+
+
+ {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}
+
{
+ e.stopPropagation();
+ handleDeleteCampaign(campaign.id);
+ }}
+ className="mt-2 text-red-400 hover:text-red-300 text-xs flex items-center"
+ >
+ Delete
+
+
+ ))}
+
+
+ {showCreateCampaignModal &&
setShowCreateCampaignModal(false)} title="Create New Campaign"> setShowCreateCampaignModal(false)} /> }
+ {selectedCampaign && (
+
+
Managing: {selectedCampaign.name}
+
+
+
+
+ )}
+
+ );
+}
+
+function CreateCampaignForm({ onCreate, onCancel }) {
+ const [name, setName] = useState('');
+ return (
+
+ );
+}
+
+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
+
+ {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
+
setShowCreateEncounterModal(true)} className="px-4 py-2 text-sm font-medium text-white bg-orange-500 hover:bg-orange-600 rounded-md transition-colors flex items-center"> Create Encounter
+
+ {encounters.length === 0 &&
No encounters yet.
}
+
+ {encounters.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 }
+
+
+ handleSetEncounterAsActiveDisplay(encounter.id)} className={`p-1 rounded transition-colors ${isDmActiveDisplay ? 'bg-green-500 hover:bg-green-600 text-white' : 'text-teal-400 hover:text-teal-300 bg-slate-600 hover:bg-slate-500'}`} title="Set as DM's Active Display">
+ handleCopyToClipboard(encounter.id)} className="p-1 rounded transition-colors text-sky-400 hover:text-sky-300 bg-slate-600 hover:bg-slate-500" title="Copy Share Link for Players">
+ {copiedLinkEncounterId === encounter.id && Copied! }
+ { e.stopPropagation(); handleDeleteEncounter(encounter.id); }} className="p-1 rounded transition-colors text-red-400 hover:text-red-300 bg-slate-600 hover:bg-slate-500" title="Delete Encounter">
+
+
+ {selectedEncounterId === encounter.id && (
+
+
Shareable Link for Players:
+
+
+ handleCopyToClipboard(encounter.id)} className="px-4 py-2 text-xs font-medium text-slate-300 bg-slate-500 hover:bg-slate-400 rounded-md transition-colors p-1">
+ {copiedLinkEncounterId === encounter.id ? 'Copied!' : }
+
+
+
+ )}
+
+ );
+ })}
+
+ {showCreateEncounterModal &&
setShowCreateEncounterModal(false)} title="Create New Encounter"> setShowCreateEncounterModal(false)} /> }
+ {selectedEncounter && (
+
+
Managing Encounter: {selectedEncounter.name}
+
+
+
+ )}
+
+ );
+}
+
+function CreateEncounterForm({ onCreate, onCancel }) {
+ const [name, setName] = useState('');
+ return (
+
+ );
+}
+
+function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
+ const [participantName, setParticipantName] = useState('');
+ const [participantType, setParticipantType] = useState('monster');
+ const [selectedCharacterId, setSelectedCharacterId] = useState('');
+ const [initiative, setInitiative] = useState(10);
+ const [maxHp, setMaxHp] = useState(10);
+ const [editingParticipant, setEditingParticipant] = useState(null);
+ const [hpChangeValues, setHpChangeValues] = useState({});
+ const [draggedItemId, setDraggedItemId] = useState(null);
+
+ const participants = encounter.participants || [];
+
+ const handleAddParticipant = async () => {
+ if ((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
+
+ {participants.length === 0 &&
No participants.
}
+
+ {sortedAdminParticipants.map((p, index) => {
+ const isCurrentTurn = encounter.isStarted && p.id === encounter.currentTurnParticipantId;
+ const isDraggable = !encounter.isStarted && tiedInitiatives.includes(p.initiative);
+ return (
+ handleDragStart(e, p.id) : undefined}
+ onDragOver={isDraggable ? handleDragOver : undefined}
+ onDrop={isDraggable ? (e) => handleDrop(e, p.id) : undefined}
+ onDragEnd={isDraggable ? handleDragEnd : undefined}
+ className={`p-3 rounded-md flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 transition-all
+ ${isCurrentTurn ? 'bg-green-600 ring-2 ring-green-300 shadow-lg' : (p.type === 'character' ? 'bg-sky-800' : 'bg-red-800')}
+ ${!p.isActive ? 'opacity-50' : ''}
+ ${isDraggable ? 'cursor-grab' : ''}
+ ${draggedItemId === p.id ? 'opacity-50 ring-2 ring-yellow-400' : ''}
+ `}
+ >
+
+ {isDraggable &&
}
+
+
{p.name} ({p.type}) {isCurrentTurn && CURRENT }
+
Init: {p.initiative} | HP: {p.currentHp}/{p.maxHp}
+
+
+
+ {encounter.isStarted && p.isActive && (
handleHpInputChange(p.id, e.target.value)} className="w-16 p-1 text-sm bg-slate-600 border border-slate-500 rounded-md text-white focus:ring-sky-500 focus:border-sky-500" aria-label={`HP change for ${p.name}`}/> applyHpChange(p.id, 'damage')} className="p-1 bg-red-500 hover:bg-red-600 text-white rounded-md text-xs" title="Damage"> applyHpChange(p.id, 'heal')} className="p-1 bg-green-500 hover:bg-green-600 text-white rounded-md text-xs" title="Heal">
)}
+
toggleParticipantActive(p.id)} className={`p-1 rounded transition-colors ${p.isActive ? 'text-yellow-400 hover:text-yellow-300 bg-slate-600 hover:bg-slate-500' : 'text-slate-400 hover:text-slate-300 bg-slate-600 hover:bg-slate-500'}`} title={p.isActive ? "Mark Inactive" : "Mark Active"}>{p.isActive ? : }
+
setEditingParticipant(p)} className="p-1 rounded transition-colors text-yellow-400 hover:text-yellow-300 bg-slate-600 hover:bg-slate-500" title="Edit">
+
handleDeleteParticipant(p.id)} className="p-1 rounded transition-colors text-red-400 hover:text-red-300 bg-slate-600 hover:bg-slate-500" title="Remove">
+
+
+ );
+ })}
+
+ {editingParticipant &&
setEditingParticipant(null)} onSave={handleUpdateParticipant} />}
+
+ );
+}
+
+function EditParticipantModal({ participant, onClose, onSave }) {
+ const [name, setName] = useState(participant.name);
+ const [initiative, setInitiative] = useState(participant.initiative);
+ const [currentHp, setCurrentHp] = useState(participant.currentHp);
+ const [maxHp, setMaxHp] = useState(participant.maxHp);
+
+ const handleSubmit = (e) => {
+ e.preventDefault();
+ onSave({
+ name: name.trim(), initiative: parseInt(initiative, 10),
+ currentHp: parseInt(currentHp, 10), maxHp: parseInt(maxHp, 10),
+ });
+ };
+ return (
+
+
+
+ );
+}
+
+function InitiativeControls({ campaignId, encounter, encounterPath }) {
+ const handleStartEncounter = async () => {
+ if (!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 ? (
+
p.isActive).length === 0}> Start
+ ) : (
+ <>
+
Next Turn
+
End
+
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 (
-
-
-
-
- {viewMode === 'admin' && isAuthReady && userId && }
- {viewMode === 'display' && isAuthReady && }
- {!isAuthReady && Authenticating...
}
-
-
- TTRPG Initiative Tracker v0.1.1
-
-
- );
-}
-
-// --- 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
-
setShowCreateCampaignModal(true)}
- className="bg-green-500 hover:bg-green-600 text-white font-bold py-2 px-4 rounded-lg flex items-center transition-colors"
- >
- Create Campaign
-
-
- {campaigns.length === 0 &&
No campaigns yet. Create one to get started!
}
-
- {campaigns.map(campaign => (
-
-
setSelectedCampaignId(campaign.id)}>
-
{campaign.name}
-
ID: {campaign.id}
-
-
{ e.stopPropagation(); handleDeleteCampaign(campaign.id); }}
- className="mt-2 text-red-400 hover:text-red-300 text-xs flex items-center"
- aria-label="Delete campaign"
- >
- Delete Campaign
-
-
- ))}
-
-
-
- {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 (
-
- );
-}
-
-// --- 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
-
- {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
-
setShowCreateEncounterModal(true)}
- className="bg-orange-500 hover:bg-orange-600 text-white font-bold py-2 px-3 rounded-md flex items-center transition-colors"
- >
- Create Encounter
-
-
- {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 }
-
-
- handleSetEncounterAsActiveDisplay(encounter.id)}
- className={`p-1 rounded transition-colors ${isActiveOnDisplay ? 'bg-green-500 hover:bg-green-600 text-white' : 'bg-slate-600 hover:bg-slate-500 text-teal-400 hover:text-teal-300'}`}
- title="Set as Active Display"
- >
-
-
- { e.stopPropagation(); handleDeleteEncounter(encounter.id); }}
- className="text-red-400 hover:text-red-300 p-1 rounded bg-slate-600 hover:bg-slate-500"
- title="Delete Encounter"
- >
-
-
-
-
-
- );
- })}
-
-
- {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 (
-
- );
-}
-
-// --- 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 */}
-
-
- Type
- setParticipantType(e.target.value)} className="mt-1 w-full p-2 bg-slate-600 border-slate-500 rounded text-white">
- Monster
- Player Character
-
-
- {participantType === 'monster' && (
-
- Monster Name
- 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' && (
-
- Select Player
- setSelectedPlayerId(e.target.value)} className="mt-1 w-full p-2 bg-slate-600 border-slate-500 rounded text-white">
- -- Select from Campaign --
- {campaignPlayers.map(p => {p.name} )}
-
-
- )}
-
- Initiative
- setInitiative(e.target.value)} className="mt-1 w-full p-2 bg-slate-600 border-slate-500 rounded text-white" />
-
-
- Max HP
- 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.
}
-
- {sortedAdminParticipants.map(p => (
-
-
-
{p.name} ({p.type})
-
Init: {p.initiative} | HP: {p.currentHp}/{p.maxHp}
-
-
- {/* HP Change Controls - only if encounter started and participant active */}
- {encounter.isStarted && p.isActive && (
-
- handleHpInputChange(p.id, e.target.value)}
- className="w-16 p-1 text-sm bg-slate-600 border border-slate-500 rounded-md text-white focus:ring-sky-500 focus:border-sky-500"
- aria-label={`HP change for ${p.name}`}
- />
- applyHpChange(p.id, 'damage')}
- className="p-1 bg-red-500 hover:bg-red-600 text-white rounded-md text-xs"
- title="Apply Damage"
- >
-
-
- applyHpChange(p.id, 'heal')}
- className="p-1 bg-green-500 hover:bg-green-600 text-white rounded-md text-xs"
- title="Apply Healing"
- >
-
-
-
- )}
-
toggleParticipantActive(p.id)} className={`p-1 rounded ${p.isActive ? 'bg-yellow-500 hover:bg-yellow-600' : 'bg-gray-500 hover:bg-gray-600'}`} title={p.isActive ? "Mark Inactive" : "Mark Active"}>
- {p.isActive ? : }
-
-
setEditingParticipant(p)} className="text-yellow-300 hover:text-yellow-200 p-1 rounded bg-slate-600 hover:bg-slate-500" title="Edit Participant">
-
-
-
handleDeleteParticipant(p.id)} className="text-red-300 hover:text-red-200 p-1 rounded bg-slate-600 hover:bg-slate-500" title="Remove Participant">
-
-
-
-
- ))}
-
-
- {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 (
-
-
-
- );
-}
-
-// --- 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 ? (
-
p.isActive).length === 0}>
- Start Encounter
-
- ) : (
- <>
-
- Next Turn
-
-
- End Encounter
-
-
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