diff --git a/src/App.js b/src/App.js index 42ada35..4847e70 100644 --- a/src/App.js +++ b/src/App.js @@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef } from 'react'; import { initializeApp } from 'firebase/app'; import { getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken } from 'firebase/auth'; import { getFirestore, doc, setDoc, addDoc, getDoc, getDocs, collection, onSnapshot, updateDoc, deleteDoc, query, writeBatch } from 'firebase/firestore'; -import { PlusCircle, Users, Swords, Shield, Trash2, Eye, Edit3, Save, XCircle, ChevronsUpDown, UserCheck, UserX, HeartCrack, HeartPulse, Zap, Image as ImageIcon, EyeOff, ExternalLink } from 'lucide-react'; +import { PlusCircle, Users, Swords, Shield, Trash2, Eye, Edit3, Save, XCircle, ChevronsUpDown, UserCheck, UserX, HeartCrack, HeartPulse, Zap, Image as ImageIcon, EyeOff, ExternalLink, AlertTriangle } from 'lucide-react'; // Added AlertTriangle // --- Firebase Configuration --- const firebaseConfig = { @@ -126,7 +126,7 @@ function useFirestoreCollection(collectionPath, queryConstraints = []) { // --- Main App Component --- function App() { - const [userId, setUserId] = useState(null); + const [userId, setUserId] = useState(null); // Kept for potential future use, but not displayed const [isAuthReady, setIsAuthReady] = useState(false); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); @@ -218,7 +218,7 @@ function App() { TTRPG Initiative Tracker
- {userId && UID: {userId}} + {/* UID display removed from header */}
); } +// --- Confirmation Modal Component --- +function ConfirmationModal({ isOpen, onClose, onConfirm, title, message }) { + if (!isOpen) return null; + + return ( +
+
+
+ +

{title || "Confirm Action"}

+
+

{message || "Are you sure you want to proceed?"}

+
+ + +
+
+
+ ); +} + + // --- Admin View Component --- function AdminView({ userId }) { const { data: campaignsData, isLoading: isLoadingCampaigns } = useFirestoreCollection(getCampaignsCollectionPath()); @@ -248,6 +280,8 @@ function AdminView({ userId }) { const [campaigns, setCampaigns] = useState([]); const [selectedCampaignId, setSelectedCampaignId] = useState(null); const [showCreateCampaignModal, setShowCreateCampaignModal] = useState(false); + const [showDeleteCampaignConfirm, setShowDeleteCampaignConfirm] = useState(false); + const [itemToDelete, setItemToDelete] = useState(null); // { id, name, type: 'campaign' } useEffect(() => { if (campaignsData) { @@ -281,10 +315,14 @@ function AdminView({ userId }) { } catch (err) { console.error("Error creating campaign:", err); } }; - const handleDeleteCampaign = async (campaignId) => { - if (!db) return; - // TODO: Implement custom confirmation modal for deleting campaigns - console.warn("Attempting to delete campaign without confirmation:", campaignId); + const requestDeleteCampaign = (campaignId, campaignName) => { + setItemToDelete({ id: campaignId, name: campaignName, type: 'campaign' }); + setShowDeleteCampaignConfirm(true); + }; + + const confirmDeleteCampaign = async () => { + if (!db || !itemToDelete || itemToDelete.type !== 'campaign') return; + const campaignId = itemToDelete.id; try { const encountersPath = getEncountersCollectionPath(campaignId); const encountersSnapshot = await getDocs(collection(db, encountersPath)); @@ -300,6 +338,8 @@ function AdminView({ userId }) { await updateDoc(activeDisplayRef, { activeCampaignId: null, activeEncounterId: null }); } } catch (err) { console.error("Error deleting campaign:", err); } + setShowDeleteCampaignConfirm(false); + setItemToDelete(null); }; const selectedCampaign = campaigns.find(c => c.id === selectedCampaignId); @@ -309,55 +349,65 @@ function AdminView({ userId }) { } 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}

- {campaign.playerDisplayBackgroundUrl && } - +
+ {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'}`} > - Delete - -
- ))} +

{campaign.name}

+ {/* Campaign ID display removed */} + {campaign.playerDisplayBackgroundUrl && } + +
+ ))} +
+ {showCreateCampaignModal && setShowCreateCampaignModal(false)} title="Create New Campaign"> setShowCreateCampaignModal(false)} />} + {selectedCampaign && ( +
+

Managing: {selectedCampaign.name}

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

Managing: {selectedCampaign.name}

- -
- -
- )} - + setShowDeleteCampaignConfirm(false)} + onConfirm={confirmDeleteCampaign} + title="Delete Campaign?" + message={`Are you sure you want to delete the campaign "${itemToDelete?.name}" and all its encounters? This action cannot be undone.`} + /> + ); } +// ... (CreateCampaignForm remains the same) function CreateCampaignForm({ onCreate, onCancel }) { const [name, setName] = useState(''); const [backgroundUrl, setBackgroundUrl] = useState(''); @@ -385,9 +435,14 @@ function CreateCampaignForm({ onCreate, onCancel }) { ); } + +// --- CharacterManager --- function CharacterManager({ campaignId, campaignCharacters }) { const [characterName, setCharacterName] = useState(''); const [editingCharacter, setEditingCharacter] = useState(null); + const [showDeleteCharConfirm, setShowDeleteCharConfirm] = useState(false); + const [itemToDelete, setItemToDelete] = useState(null); // { id, name, type: 'character' } + const handleAddCharacter = async () => { if (!db ||!characterName.trim() || !campaignId) return; @@ -407,17 +462,24 @@ function CharacterManager({ campaignId, campaignCharacters }) { } catch (err) { console.error("Error updating character:", err); } }; - const handleDeleteCharacter = async (characterId) => { - if (!db) return; - // TODO: Implement custom confirmation modal for deleting characters - console.warn("Attempting to delete character without confirmation:", characterId); + const requestDeleteCharacter = (characterId, charName) => { + setItemToDelete({ id: characterId, name: charName, type: 'character' }); + setShowDeleteCharConfirm(true); + }; + + const confirmDeleteCharacter = async () => { + if (!db || !itemToDelete || itemToDelete.type !== 'character') return; + const characterId = itemToDelete.id; const updatedCharacters = campaignCharacters.filter(c => c.id !== characterId); try { await updateDoc(doc(db, getCampaignDocPath(campaignId)), { players: updatedCharacters }); } catch (err) { console.error("Error deleting character:", err); } + setShowDeleteCharConfirm(false); + setItemToDelete(null); }; return ( + <>

Campaign Characters

{ e.preventDefault(); handleAddCharacter(); }} className="flex gap-2 mb-4"> @@ -433,24 +495,40 @@ function CharacterManager({ campaignId, campaignCharacters }) { ) : ( {character.name} )}
- +
))}
+ setShowDeleteCharConfirm(false)} + onConfirm={confirmDeleteCharacter} + title="Delete Character?" + message={`Are you sure you want to remove the character "${itemToDelete?.name}" from this campaign?`} + /> + ); } +// --- EncounterManager --- function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharacters }) { - const {data: encounters, isLoading: isLoadingEncounters } = useFirestoreCollection(campaignId ? getEncountersCollectionPath(campaignId) : null); + const {data: encountersData, isLoading: isLoadingEncounters } = useFirestoreCollection(campaignId ? getEncountersCollectionPath(campaignId) : null); const {data: activeDisplayInfo } = useFirestoreDocument(getActiveDisplayDocPath()); + const [encounters, setEncounters] = useState([]); const [selectedEncounterId, setSelectedEncounterId] = useState(null); const [showCreateEncounterModal, setShowCreateEncounterModal] = useState(false); + const [showDeleteEncounterConfirm, setShowDeleteEncounterConfirm] = useState(false); + const [itemToDelete, setItemToDelete] = useState(null); // { id, name, type: 'encounter' } const selectedEncounterIdRef = useRef(selectedEncounterId); + useEffect(() => { + if(encountersData) setEncounters(encountersData); + }, [encountersData]); + useEffect(() => { selectedEncounterIdRef.current = selectedEncounterId; }, [selectedEncounterId]); @@ -488,10 +566,14 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac } catch (err) { console.error("Error creating encounter:", err); } }; - const handleDeleteEncounter = async (encounterId) => { - if (!db) return; - // TODO: Implement custom confirmation modal for deleting encounters - console.warn("Attempting to delete encounter without confirmation:", encounterId); + const requestDeleteEncounter = (encounterId, encounterName) => { + setItemToDelete({ id: encounterId, name: encounterName, type: 'encounter' }); + setShowDeleteEncounterConfirm(true); + }; + + const confirmDeleteEncounter = async () => { + if (!db || !itemToDelete || itemToDelete.type !== 'encounter') return; + const encounterId = itemToDelete.id; try { await deleteDoc(doc(db, getEncounterDocPath(campaignId, encounterId))); if (selectedEncounterId === encounterId) setSelectedEncounterId(null); @@ -499,6 +581,8 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac await updateDoc(doc(db, getActiveDisplayDocPath()), { activeCampaignId: null, activeEncounterId: null }); } } catch (err) { console.error("Error deleting encounter:", err); } + setShowDeleteEncounterConfirm(false); + setItemToDelete(null); }; const handleTogglePlayerDisplayForEncounter = async (encounterId) => { @@ -532,6 +616,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac } return ( + <>

Encounters

@@ -557,7 +642,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac > {isLiveOnPlayerDisplay ? : } - +
@@ -573,9 +658,21 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac )} + setShowDeleteEncounterConfirm(false)} + onConfirm={confirmDeleteEncounter} + title="Delete Encounter?" + message={`Are you sure you want to delete the encounter "${itemToDelete?.name}"? This action cannot be undone.`} + /> + ); } +// ... (CreateEncounterForm, ParticipantManager, EditParticipantModal, InitiativeControls, DisplayView, Modal, Icons) +// The rest of the components remain the same as in v0.1.22, with the following changes in ParticipantManager and InitiativeControls +// to use the ConfirmationModal for delete/end actions. + function CreateEncounterForm({ onCreate, onCancel }) { const [name, setName] = useState(''); return ( @@ -601,6 +698,9 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) { const [editingParticipant, setEditingParticipant] = useState(null); const [hpChangeValues, setHpChangeValues] = useState({}); const [draggedItemId, setDraggedItemId] = useState(null); + const [showDeleteParticipantConfirm, setShowDeleteParticipantConfirm] = useState(false); + const [itemToDelete, setItemToDelete] = useState(null); // { id, name, type: 'participant' } + const participants = encounter.participants || []; @@ -637,14 +737,20 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) { } catch (err) { console.error("Error updating participant:", err); } }; - const handleDeleteParticipant = async (participantId) => { - if (!db) return; - // TODO: Implement custom confirmation modal for deleting participants - console.warn("Attempting to delete participant without confirmation:", participantId); + const requestDeleteParticipant = (participantId, participantName) => { + setItemToDelete({ id: participantId, name: participantName, type: 'participant' }); + setShowDeleteParticipantConfirm(true); + }; + + const confirmDeleteParticipant = async () => { + if (!db || !itemToDelete || itemToDelete.type !== 'participant') return; + const participantId = itemToDelete.id; const updatedParticipants = participants.filter(p => p.id !== participantId); try { await updateDoc(doc(db, encounterPath), { participants: updatedParticipants }); } catch (err) { console.error("Error deleting participant:", err); } + setShowDeleteParticipantConfirm(false); + setItemToDelete(null); }; const toggleParticipantActive = async (participantId) => { @@ -676,7 +782,6 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) { } catch (err) { console.error("Error applying HP change:", err); } }; - // --- Drag and Drop Handlers --- const handleDragStart = (e, id) => { setDraggedItemId(id); e.dataTransfer.effectAllowed = 'move'; @@ -746,6 +851,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) { return ( + <>

Participants

{ e.preventDefault(); handleAddParticipant(); }} className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4 p-3 bg-slate-700 rounded"> @@ -805,7 +911,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) { {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}`}/>
)} - +
); @@ -813,6 +919,14 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) { {editingParticipant && setEditingParticipant(null)} onSave={handleUpdateParticipant} />} + setShowDeleteParticipantConfirm(false)} + onConfirm={confirmDeleteParticipant} + title="Delete Participant?" + message={`Are you sure you want to remove "${itemToDelete?.name}" from this encounter?`} + /> + ); } @@ -848,6 +962,8 @@ function EditParticipantModal({ participant, onClose, onSave }) { } function InitiativeControls({ campaignId, encounter, encounterPath }) { + const [showEndEncounterConfirm, setShowEndEncounterConfirm] = useState(false); + const handleStartEncounter = async () => { if (!db ||!encounter.participants || encounter.participants.length === 0) { alert("Add participants first."); return; } const activePs = encounter.participants.filter(p => p.isActive); @@ -891,10 +1007,12 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) { } catch (err) { console.error("Error advancing turn:", err); } }; - const handleEndEncounter = async () => { + const requestEndEncounter = () => { + setShowEndEncounterConfirm(true); + }; + + const confirmEndEncounter = async () => { if (!db) return; - // 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: [] }); await setDoc(doc(db, getActiveDisplayDocPath()), { @@ -903,10 +1021,12 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) { }, { merge: true }); console.log("Encounter ended and deactivated from Player Display."); } catch (err) { console.error("Error ending encounter:", err); } + setShowEndEncounterConfirm(false); }; if (!encounter || !encounter.participants) return null; return ( + <>

Combat Controls

@@ -915,15 +1035,24 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) { ) : ( <> - +

Round: {encounter.round}

)}
+ setShowEndEncounterConfirm(false)} + onConfirm={confirmEndEncounter} + title="End Encounter?" + message={`Are you sure you want to end this encounter? Initiative order will be reset and it will be removed from the Player Display.`} + /> + ); } +// DisplayView remains the same as v0.1.19 (before focused view) function DisplayView() { const { data: activeDisplayData, isLoading: isLoadingActiveDisplay, error: activeDisplayError } = useFirestoreDocument(getActiveDisplayDocPath()); @@ -932,7 +1061,6 @@ function DisplayView() { const [encounterError, setEncounterError] = useState(null); const [campaignBackgroundUrl, setCampaignBackgroundUrl] = useState(''); const [isPlayerDisplayActive, setIsPlayerDisplayActive] = useState(false); - // currentTurnRef removed as we are reverting the scroll-to-view logic for now useEffect(() => { if (!db) { @@ -994,7 +1122,6 @@ function DisplayView() { }; }, [activeDisplayData, isLoadingActiveDisplay]); - // useEffect for scrollIntoView removed if (isLoadingActiveDisplay || (isPlayerDisplayActive && isLoadingEncounter)) { return
Loading Player Display...
; @@ -1016,9 +1143,8 @@ function DisplayView() { const { name, participants, round, currentTurnParticipantId, isStarted } = activeEncounterData; - // Reverted to showing all active participants, no focused view logic - let participantsToRender = []; - if (participants) { + let participantsToRender = []; // Renamed from displayParticipants for clarity + if (participants) { if (isStarted && activeEncounterData.turnOrderIds?.length > 0 ) { participantsToRender = activeEncounterData.turnOrderIds .map(id => participants.find(p => p.id === id)).filter(p => p && p.isActive); @@ -1052,18 +1178,15 @@ function DisplayView() { >

{name}

- {isStarted &&

Round: {round}

} {/* Adjusted margin */} - {/* Focused view indicator removed */} + {isStarted &&

Round: {round}

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

Awaiting Start

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

No participants.

} {participantsToRender.length === 0 && isStarted &&

No active participants.

} - {/* Reverted to simpler list container */}
{participantsToRender.map(p => (
@@ -1090,6 +1213,7 @@ function DisplayView() { ); } +// --- Modal Component --- (Standard modal for forms, etc.) function Modal({ onClose, title, children }) { useEffect(() => { const handleEsc = (event) => { if (event.key === 'Escape') onClose(); }; @@ -1109,6 +1233,7 @@ function Modal({ onClose, title, children }) { ); } +// --- Icons --- const PlayIcon = ({ size = 24, className = '' }) => ; const SkipForwardIcon = ({ size = 24, className = '' }) => ; const StopCircleIcon = ({size=24, className=''}) => ;