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 */}
Authenticating...}
- TTRPG Initiative Tracker v0.1.19
+ TTRPG Initiative Tracker v0.1.23
);
}
+// --- 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?"}
+
+
+ Cancel
+
+
+ Confirm
+
+
+
+
+ );
+}
+
+
// --- 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
-
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}
- {campaign.playerDisplayBackgroundUrl &&
}
-
{
- e.stopPropagation();
- handleDeleteCampaign(campaign.id);
- }}
- className="mt-2 text-red-400 hover:text-red-300 text-xs flex items-center"
+ <>
+
+
+
+
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'}`}
>
- Delete
-
-
- ))}
+
{campaign.name}
+ {/* Campaign ID display removed */}
+ {campaign.playerDisplayBackgroundUrl &&
}
+
{
+ e.stopPropagation();
+ requestDeleteCampaign(campaign.id, campaign.name);
+ }}
+ 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}
+
+
+
+
+ )}
- {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 (
+ <>
+ 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 ? : }
- { 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">
+ { e.stopPropagation(); requestDeleteEncounter(encounter.id, encounter.name); }} className="p-1 rounded transition-colors text-red-400 hover:text-red-300 bg-slate-600 hover:bg-slate-500" title="Delete Encounter">
@@ -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 (
+ <>
);
@@ -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 }) {
) : (
<>
Next Turn
-
End
+
End
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=''}) =>
;