commit 64717a36b03e8daee8f36cef73cbcc7739c391ac Author: robert Date: Sun May 25 10:22:24 2025 -0400 Initial commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..cee7a03 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,31 @@ +# Ignore Git directory +.git +.gitignore + +# Ignore Node.js modules (they will be installed in the Docker image) +node_modules + +# Ignore build output (it will be generated in the Docker image) +build +dist + +# Ignore Docker files themselves +Dockerfile +.dockerignore + +# Ignore any local environment files if you have them +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Ignore IDE and OS-specific files +.vscode/ +.idea/ +*.suo +*.user +*.userosscache +*.sln.docstates +Thumbs.db +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b418c78 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,71 @@ +# Stage 1: Build the React application +FROM node:18-alpine AS build + +# Set the working directory +WORKDIR /app + +# Copy package.json and package-lock.json (or yarn.lock) +COPY package*.json ./ +# If using yarn, uncomment the next line and comment out the npm ci line +# COPY yarn.lock ./ + +# Install dependencies +# If using yarn, replace 'npm ci' with 'yarn install --frozen-lockfile' +RUN npm ci + +# Copy the rest of the application source code +COPY . . + +# Build the application for production +# The REACT_APP_ID and REACT_APP_FIREBASE_CONFIG build arguments are optional. +# If you set them during your 'docker build' command, they will be baked into your static files. +# Otherwise, the application will rely on the __app_id and __firebase_config global variables +# being available in the environment where the built assets are served, or fall back to +# the hardcoded defaults in the React code if those globals are not present. +ARG REACT_APP_ID +ARG REACT_APP_FIREBASE_CONFIG +ENV VITE_APP_ID=$REACT_APP_ID +ENV VITE_FIREBASE_CONFIG=$REACT_APP_FIREBASE_CONFIG + +# If your project uses Create React App (CRA - typically uses react-scripts build) +# RUN npm run build + +# If your project uses Vite (which is common for modern React setups) +# Ensure your package.json's build script uses Vite. +# If you are using Vite, you might need to adjust environment variable prefixing +# (Vite uses VITE_ for env vars to be exposed on client). +# The React code I provided doesn't assume Vite or CRA specifically, but uses +# __app_id and __firebase_config which are meant to be injected at runtime/hosting. +# For a Docker build where these are baked in, you'd typically modify the React code +# to read from process.env.REACT_APP_... (for CRA) or import.meta.env.VITE_... (for Vite). + +# Assuming your React app uses environment variables like REACT_APP_ prefixed variables +# (common with Create React App) or VITE_ prefixed for Vite. +# The provided React code uses __app_id and __firebase_config which are expected +# to be injected by the hosting environment. If you want to bake these into the +# Docker image at build time, you would modify the React code to consume them +# from process.env (for CRA) or import.meta.env (for Vite) and then set them here. + +# For the current React code, it expects __app_id and __firebase_config to be +# globally available where it runs. If you want to hardcode them during Docker build, +# you'd need to modify the React code to read from standard env vars and then set them +# using ENV in the Dockerfile or pass them as build ARGs. + +# Let's assume a standard 'npm run build' script in your package.json +RUN npm run build + +# Stage 2: Serve the static files (Optional, if you want the image to be self-contained for serving) +# If you are handling Nginx externally, you might not need this stage. +# You would just copy the /app/build directory from the 'build' stage. +# However, for completeness or if you wanted an image that *can* serve itself: +FROM nginx:1.25-alpine + +# Copy the build output from the 'build' stage to Nginx's html directory +COPY --from=build /app/build /usr/share/nginx/html + +# Expose port 80 for Nginx +EXPOSE 80 + +# Start Nginx when the container launches +CMD ["nginx", "-g", "daemon off;"] + diff --git a/app.js b/app.js new file mode 100644 index 0000000..f1426a0 --- /dev/null +++ b/app.js @@ -0,0 +1,1090 @@ +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...

} +
+
+ 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

+ +
+ {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; +