Removed UUIDs and added delete confirmation boxes.

This commit is contained in:
Robert Johnson 2025-05-26 09:41:50 -04:00
parent a317038345
commit e09739fc01

View File

@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef } from 'react';
import { initializeApp } from 'firebase/app'; import { initializeApp } from 'firebase/app';
import { getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken } from 'firebase/auth'; 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 { 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 --- // --- Firebase Configuration ---
const firebaseConfig = { const firebaseConfig = {
@ -126,7 +126,7 @@ function useFirestoreCollection(collectionPath, queryConstraints = []) {
// --- Main App Component --- // --- Main App Component ---
function App() { 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 [isAuthReady, setIsAuthReady] = useState(false);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
@ -218,7 +218,7 @@ function App() {
TTRPG Initiative Tracker TTRPG Initiative Tracker
</h1> </h1>
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
{userId && <span className="text-xs text-slate-400">UID: {userId}</span>} {/* UID display removed from header */}
<button <button
onClick={openPlayerWindow} onClick={openPlayerWindow}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors bg-teal-500 hover:bg-teal-600 text-white flex items-center`} className={`px-4 py-2 rounded-md text-sm font-medium transition-colors bg-teal-500 hover:bg-teal-600 text-white flex items-center`}
@ -234,12 +234,44 @@ function App() {
{!isAuthReady && !error && <p>Authenticating...</p>} {!isAuthReady && !error && <p>Authenticating...</p>}
</main> </main>
<footer className="bg-slate-900 p-4 text-center text-sm text-slate-400 mt-8"> <footer className="bg-slate-900 p-4 text-center text-sm text-slate-400 mt-8">
TTRPG Initiative Tracker v0.1.19 TTRPG Initiative Tracker v0.1.23
</footer> </footer>
</div> </div>
); );
} }
// --- Confirmation Modal Component ---
function ConfirmationModal({ isOpen, onClose, onConfirm, title, message }) {
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center p-4 z-50 transition-opacity duration-300 ease-in-out">
<div className="bg-slate-800 p-6 rounded-lg shadow-xl w-full max-w-md transform transition-all duration-300 ease-in-out scale-100 opacity-100">
<div className="flex items-center mb-4">
<AlertTriangle size={24} className="text-yellow-400 mr-3 flex-shrink-0" />
<h2 className="text-xl font-semibold text-yellow-300">{title || "Confirm Action"}</h2>
</div>
<p className="text-slate-300 mb-6">{message || "Are you sure you want to proceed?"}</p>
<div className="flex justify-end space-x-3">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-slate-300 bg-slate-600 hover:bg-slate-500 rounded-md transition-colors"
>
Cancel
</button>
<button
onClick={onConfirm}
className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-md transition-colors"
>
Confirm
</button>
</div>
</div>
</div>
);
}
// --- Admin View Component --- // --- Admin View Component ---
function AdminView({ userId }) { function AdminView({ userId }) {
const { data: campaignsData, isLoading: isLoadingCampaigns } = useFirestoreCollection(getCampaignsCollectionPath()); const { data: campaignsData, isLoading: isLoadingCampaigns } = useFirestoreCollection(getCampaignsCollectionPath());
@ -248,6 +280,8 @@ function AdminView({ userId }) {
const [campaigns, setCampaigns] = useState([]); const [campaigns, setCampaigns] = useState([]);
const [selectedCampaignId, setSelectedCampaignId] = useState(null); const [selectedCampaignId, setSelectedCampaignId] = useState(null);
const [showCreateCampaignModal, setShowCreateCampaignModal] = useState(false); const [showCreateCampaignModal, setShowCreateCampaignModal] = useState(false);
const [showDeleteCampaignConfirm, setShowDeleteCampaignConfirm] = useState(false);
const [itemToDelete, setItemToDelete] = useState(null); // { id, name, type: 'campaign' }
useEffect(() => { useEffect(() => {
if (campaignsData) { if (campaignsData) {
@ -281,10 +315,14 @@ function AdminView({ userId }) {
} catch (err) { console.error("Error creating campaign:", err); } } catch (err) { console.error("Error creating campaign:", err); }
}; };
const handleDeleteCampaign = async (campaignId) => { const requestDeleteCampaign = (campaignId, campaignName) => {
if (!db) return; setItemToDelete({ id: campaignId, name: campaignName, type: 'campaign' });
// TODO: Implement custom confirmation modal for deleting campaigns setShowDeleteCampaignConfirm(true);
console.warn("Attempting to delete campaign without confirmation:", campaignId); };
const confirmDeleteCampaign = async () => {
if (!db || !itemToDelete || itemToDelete.type !== 'campaign') return;
const campaignId = itemToDelete.id;
try { try {
const encountersPath = getEncountersCollectionPath(campaignId); const encountersPath = getEncountersCollectionPath(campaignId);
const encountersSnapshot = await getDocs(collection(db, encountersPath)); const encountersSnapshot = await getDocs(collection(db, encountersPath));
@ -300,6 +338,8 @@ function AdminView({ userId }) {
await updateDoc(activeDisplayRef, { activeCampaignId: null, activeEncounterId: null }); await updateDoc(activeDisplayRef, { activeCampaignId: null, activeEncounterId: null });
} }
} catch (err) { console.error("Error deleting campaign:", err); } } catch (err) { console.error("Error deleting campaign:", err); }
setShowDeleteCampaignConfirm(false);
setItemToDelete(null);
}; };
const selectedCampaign = campaigns.find(c => c.id === selectedCampaignId); const selectedCampaign = campaigns.find(c => c.id === selectedCampaignId);
@ -309,55 +349,65 @@ function AdminView({ userId }) {
} }
return ( return (
<div className="space-y-6"> <>
<div> <div className="space-y-6">
<div className="flex justify-between items-center mb-4"> <div>
<h2 className="text-2xl font-semibold text-sky-300">Campaigns</h2> <div className="flex justify-between items-center mb-4">
<button onClick={() => 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"> <h2 className="text-2xl font-semibold text-sky-300">Campaigns</h2>
<PlusCircle size={20} className="mr-2" /> Create Campaign <button onClick={() => 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">
</button> <PlusCircle size={20} className="mr-2" /> Create Campaign
</div> </button>
{campaigns.length === 0 && <p className="text-slate-400">No campaigns yet.</p>} </div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> {campaigns.length === 0 && <p className="text-slate-400">No campaigns yet.</p>}
{campaigns.map(campaign => ( <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div {campaigns.map(campaign => (
key={campaign.id} <div
onClick={() => setSelectedCampaignId(campaign.id)} key={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'}`} onClick={() => 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'}`}
<h3 className="text-xl font-semibold text-white">{campaign.name}</h3>
<p className="text-xs text-slate-400">ID: {campaign.id}</p>
{campaign.playerDisplayBackgroundUrl && <ImageIcon size={14} className="inline-block mr-1 text-slate-400" title="Has custom background"/>}
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteCampaign(campaign.id);
}}
className="mt-2 text-red-400 hover:text-red-300 text-xs flex items-center"
> >
<Trash2 size={14} className="mr-1" /> Delete <h3 className="text-xl font-semibold text-white">{campaign.name}</h3>
</button> {/* Campaign ID display removed */}
</div> {campaign.playerDisplayBackgroundUrl && <ImageIcon size={14} className="inline-block mr-1 text-slate-400" title="Has custom background"/>}
))} <button
onClick={(e) => {
e.stopPropagation();
requestDeleteCampaign(campaign.id, campaign.name);
}}
className="mt-2 text-red-400 hover:text-red-300 text-xs flex items-center"
>
<Trash2 size={14} className="mr-1" /> Delete
</button>
</div>
))}
</div>
</div> </div>
{showCreateCampaignModal && <Modal onClose={() => setShowCreateCampaignModal(false)} title="Create New Campaign"><CreateCampaignForm onCreate={handleCreateCampaign} onCancel={() => setShowCreateCampaignModal(false)} /></Modal>}
{selectedCampaign && (
<div className="mt-6 p-6 bg-slate-750 rounded-lg shadow-xl">
<h2 className="text-2xl font-semibold text-amber-300 mb-4">Managing: {selectedCampaign.name}</h2>
<CharacterManager campaignId={selectedCampaignId} campaignCharacters={selectedCampaign.players || []} />
<hr className="my-6 border-slate-600" />
<EncounterManager
campaignId={selectedCampaignId}
initialActiveEncounterId={initialActiveInfoData && initialActiveInfoData.activeCampaignId === selectedCampaignId ? initialActiveInfoData.activeEncounterId : null}
campaignCharacters={selectedCampaign.players || []}
/>
</div>
)}
</div> </div>
{showCreateCampaignModal && <Modal onClose={() => setShowCreateCampaignModal(false)} title="Create New Campaign"><CreateCampaignForm onCreate={handleCreateCampaign} onCancel={() => setShowCreateCampaignModal(false)} /></Modal>} <ConfirmationModal
{selectedCampaign && ( isOpen={showDeleteCampaignConfirm}
<div className="mt-6 p-6 bg-slate-750 rounded-lg shadow-xl"> onClose={() => setShowDeleteCampaignConfirm(false)}
<h2 className="text-2xl font-semibold text-amber-300 mb-4">Managing: {selectedCampaign.name}</h2> onConfirm={confirmDeleteCampaign}
<CharacterManager campaignId={selectedCampaignId} campaignCharacters={selectedCampaign.players || []} /> title="Delete Campaign?"
<hr className="my-6 border-slate-600" /> message={`Are you sure you want to delete the campaign "${itemToDelete?.name}" and all its encounters? This action cannot be undone.`}
<EncounterManager />
campaignId={selectedCampaignId} </>
initialActiveEncounterId={initialActiveInfoData && initialActiveInfoData.activeCampaignId === selectedCampaignId ? initialActiveInfoData.activeEncounterId : null}
campaignCharacters={selectedCampaign.players || []}
/>
</div>
)}
</div>
); );
} }
// ... (CreateCampaignForm remains the same)
function CreateCampaignForm({ onCreate, onCancel }) { function CreateCampaignForm({ onCreate, onCancel }) {
const [name, setName] = useState(''); const [name, setName] = useState('');
const [backgroundUrl, setBackgroundUrl] = useState(''); const [backgroundUrl, setBackgroundUrl] = useState('');
@ -385,9 +435,14 @@ function CreateCampaignForm({ onCreate, onCancel }) {
); );
} }
// --- CharacterManager ---
function CharacterManager({ campaignId, campaignCharacters }) { function CharacterManager({ campaignId, campaignCharacters }) {
const [characterName, setCharacterName] = useState(''); const [characterName, setCharacterName] = useState('');
const [editingCharacter, setEditingCharacter] = useState(null); const [editingCharacter, setEditingCharacter] = useState(null);
const [showDeleteCharConfirm, setShowDeleteCharConfirm] = useState(false);
const [itemToDelete, setItemToDelete] = useState(null); // { id, name, type: 'character' }
const handleAddCharacter = async () => { const handleAddCharacter = async () => {
if (!db ||!characterName.trim() || !campaignId) return; if (!db ||!characterName.trim() || !campaignId) return;
@ -407,17 +462,24 @@ function CharacterManager({ campaignId, campaignCharacters }) {
} catch (err) { console.error("Error updating character:", err); } } catch (err) { console.error("Error updating character:", err); }
}; };
const handleDeleteCharacter = async (characterId) => { const requestDeleteCharacter = (characterId, charName) => {
if (!db) return; setItemToDelete({ id: characterId, name: charName, type: 'character' });
// TODO: Implement custom confirmation modal for deleting characters setShowDeleteCharConfirm(true);
console.warn("Attempting to delete character without confirmation:", characterId); };
const confirmDeleteCharacter = async () => {
if (!db || !itemToDelete || itemToDelete.type !== 'character') return;
const characterId = itemToDelete.id;
const updatedCharacters = campaignCharacters.filter(c => c.id !== characterId); const updatedCharacters = campaignCharacters.filter(c => c.id !== characterId);
try { try {
await updateDoc(doc(db, getCampaignDocPath(campaignId)), { players: updatedCharacters }); await updateDoc(doc(db, getCampaignDocPath(campaignId)), { players: updatedCharacters });
} catch (err) { console.error("Error deleting character:", err); } } catch (err) { console.error("Error deleting character:", err); }
setShowDeleteCharConfirm(false);
setItemToDelete(null);
}; };
return ( return (
<>
<div className="p-4 bg-slate-800 rounded-lg shadow"> <div className="p-4 bg-slate-800 rounded-lg shadow">
<h3 className="text-xl font-semibold text-sky-300 mb-3 flex items-center"><Users size={24} className="mr-2" /> Campaign Characters</h3> <h3 className="text-xl font-semibold text-sky-300 mb-3 flex items-center"><Users size={24} className="mr-2" /> Campaign Characters</h3>
<form onSubmit={(e) => { e.preventDefault(); handleAddCharacter(); }} className="flex gap-2 mb-4"> <form onSubmit={(e) => { e.preventDefault(); handleAddCharacter(); }} className="flex gap-2 mb-4">
@ -433,24 +495,40 @@ function CharacterManager({ campaignId, campaignCharacters }) {
) : ( <span className="text-slate-100">{character.name}</span> )} ) : ( <span className="text-slate-100">{character.name}</span> )}
<div className="flex space-x-2"> <div className="flex space-x-2">
<button onClick={() => setEditingCharacter(character)} className="p-1 rounded transition-colors text-yellow-400 hover:text-yellow-300 bg-slate-600 hover:bg-slate-500" aria-label="Edit character"><Edit3 size={18} /></button> <button onClick={() => setEditingCharacter(character)} className="p-1 rounded transition-colors text-yellow-400 hover:text-yellow-300 bg-slate-600 hover:bg-slate-500" aria-label="Edit character"><Edit3 size={18} /></button>
<button onClick={() => handleDeleteCharacter(character.id)} className="p-1 rounded transition-colors text-red-400 hover:text-red-300 bg-slate-600 hover:bg-slate-500" aria-label="Delete character"><Trash2 size={18} /></button> <button onClick={() => requestDeleteCharacter(character.id, character.name)} className="p-1 rounded transition-colors text-red-400 hover:text-red-300 bg-slate-600 hover:bg-slate-500" aria-label="Delete character"><Trash2 size={18} /></button>
</div> </div>
</li> </li>
))} ))}
</ul> </ul>
</div> </div>
<ConfirmationModal
isOpen={showDeleteCharConfirm}
onClose={() => 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 }) { 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 {data: activeDisplayInfo } = useFirestoreDocument(getActiveDisplayDocPath());
const [encounters, setEncounters] = useState([]);
const [selectedEncounterId, setSelectedEncounterId] = useState(null); const [selectedEncounterId, setSelectedEncounterId] = useState(null);
const [showCreateEncounterModal, setShowCreateEncounterModal] = useState(false); const [showCreateEncounterModal, setShowCreateEncounterModal] = useState(false);
const [showDeleteEncounterConfirm, setShowDeleteEncounterConfirm] = useState(false);
const [itemToDelete, setItemToDelete] = useState(null); // { id, name, type: 'encounter' }
const selectedEncounterIdRef = useRef(selectedEncounterId); const selectedEncounterIdRef = useRef(selectedEncounterId);
useEffect(() => {
if(encountersData) setEncounters(encountersData);
}, [encountersData]);
useEffect(() => { useEffect(() => {
selectedEncounterIdRef.current = selectedEncounterId; selectedEncounterIdRef.current = selectedEncounterId;
}, [selectedEncounterId]); }, [selectedEncounterId]);
@ -488,10 +566,14 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
} catch (err) { console.error("Error creating encounter:", err); } } catch (err) { console.error("Error creating encounter:", err); }
}; };
const handleDeleteEncounter = async (encounterId) => { const requestDeleteEncounter = (encounterId, encounterName) => {
if (!db) return; setItemToDelete({ id: encounterId, name: encounterName, type: 'encounter' });
// TODO: Implement custom confirmation modal for deleting encounters setShowDeleteEncounterConfirm(true);
console.warn("Attempting to delete encounter without confirmation:", encounterId); };
const confirmDeleteEncounter = async () => {
if (!db || !itemToDelete || itemToDelete.type !== 'encounter') return;
const encounterId = itemToDelete.id;
try { try {
await deleteDoc(doc(db, getEncounterDocPath(campaignId, encounterId))); await deleteDoc(doc(db, getEncounterDocPath(campaignId, encounterId)));
if (selectedEncounterId === encounterId) setSelectedEncounterId(null); if (selectedEncounterId === encounterId) setSelectedEncounterId(null);
@ -499,6 +581,8 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
await updateDoc(doc(db, getActiveDisplayDocPath()), { activeCampaignId: null, activeEncounterId: null }); await updateDoc(doc(db, getActiveDisplayDocPath()), { activeCampaignId: null, activeEncounterId: null });
} }
} catch (err) { console.error("Error deleting encounter:", err); } } catch (err) { console.error("Error deleting encounter:", err); }
setShowDeleteEncounterConfirm(false);
setItemToDelete(null);
}; };
const handleTogglePlayerDisplayForEncounter = async (encounterId) => { const handleTogglePlayerDisplayForEncounter = async (encounterId) => {
@ -532,6 +616,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
} }
return ( return (
<>
<div className="mt-6 p-4 bg-slate-800 rounded-lg shadow"> <div className="mt-6 p-4 bg-slate-800 rounded-lg shadow">
<div className="flex justify-between items-center mb-3"> <div className="flex justify-between items-center mb-3">
<h3 className="text-xl font-semibold text-sky-300 flex items-center"><Swords size={24} className="mr-2" /> Encounters</h3> <h3 className="text-xl font-semibold text-sky-300 flex items-center"><Swords size={24} className="mr-2" /> Encounters</h3>
@ -557,7 +642,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
> >
{isLiveOnPlayerDisplay ? <EyeOff size={18} /> : <Eye size={18} />} {isLiveOnPlayerDisplay ? <EyeOff size={18} /> : <Eye size={18} />}
</button> </button>
<button onClick={(e) => { 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"><Trash2 size={18} /></button> <button onClick={(e) => { 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"><Trash2 size={18} /></button>
</div> </div>
</div> </div>
</div> </div>
@ -573,9 +658,21 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
</div> </div>
)} )}
</div> </div>
<ConfirmationModal
isOpen={showDeleteEncounterConfirm}
onClose={() => 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 }) { function CreateEncounterForm({ onCreate, onCancel }) {
const [name, setName] = useState(''); const [name, setName] = useState('');
return ( return (
@ -601,6 +698,9 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
const [editingParticipant, setEditingParticipant] = useState(null); const [editingParticipant, setEditingParticipant] = useState(null);
const [hpChangeValues, setHpChangeValues] = useState({}); const [hpChangeValues, setHpChangeValues] = useState({});
const [draggedItemId, setDraggedItemId] = useState(null); const [draggedItemId, setDraggedItemId] = useState(null);
const [showDeleteParticipantConfirm, setShowDeleteParticipantConfirm] = useState(false);
const [itemToDelete, setItemToDelete] = useState(null); // { id, name, type: 'participant' }
const participants = encounter.participants || []; const participants = encounter.participants || [];
@ -637,14 +737,20 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
} catch (err) { console.error("Error updating participant:", err); } } catch (err) { console.error("Error updating participant:", err); }
}; };
const handleDeleteParticipant = async (participantId) => { const requestDeleteParticipant = (participantId, participantName) => {
if (!db) return; setItemToDelete({ id: participantId, name: participantName, type: 'participant' });
// TODO: Implement custom confirmation modal for deleting participants setShowDeleteParticipantConfirm(true);
console.warn("Attempting to delete participant without confirmation:", participantId); };
const confirmDeleteParticipant = async () => {
if (!db || !itemToDelete || itemToDelete.type !== 'participant') return;
const participantId = itemToDelete.id;
const updatedParticipants = participants.filter(p => p.id !== participantId); const updatedParticipants = participants.filter(p => p.id !== participantId);
try { try {
await updateDoc(doc(db, encounterPath), { participants: updatedParticipants }); await updateDoc(doc(db, encounterPath), { participants: updatedParticipants });
} catch (err) { console.error("Error deleting participant:", err); } } catch (err) { console.error("Error deleting participant:", err); }
setShowDeleteParticipantConfirm(false);
setItemToDelete(null);
}; };
const toggleParticipantActive = async (participantId) => { const toggleParticipantActive = async (participantId) => {
@ -676,7 +782,6 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
} catch (err) { console.error("Error applying HP change:", err); } } catch (err) { console.error("Error applying HP change:", err); }
}; };
// --- Drag and Drop Handlers ---
const handleDragStart = (e, id) => { const handleDragStart = (e, id) => {
setDraggedItemId(id); setDraggedItemId(id);
e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.effectAllowed = 'move';
@ -746,6 +851,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
return ( return (
<>
<div className="p-3 bg-slate-800 rounded-md mt-4"> <div className="p-3 bg-slate-800 rounded-md mt-4">
<h4 className="text-lg font-medium text-sky-200 mb-3">Participants</h4> <h4 className="text-lg font-medium text-sky-200 mb-3">Participants</h4>
<form onSubmit={(e) => { e.preventDefault(); handleAddParticipant(); }} className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4 p-3 bg-slate-700 rounded"> <form onSubmit={(e) => { 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 && (<div className="flex items-center space-x-1 bg-slate-700 p-1 rounded-md"><input type="number" placeholder="HP" value={hpChangeValues[p.id] || ''} onChange={(e) => 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}`}/><button onClick={() => applyHpChange(p.id, 'damage')} className="p-1 bg-red-500 hover:bg-red-600 text-white rounded-md text-xs" title="Damage"><HeartCrack size={16}/></button><button onClick={() => applyHpChange(p.id, 'heal')} className="p-1 bg-green-500 hover:bg-green-600 text-white rounded-md text-xs" title="Heal"><HeartPulse size={16}/></button></div>)} {encounter.isStarted && p.isActive && (<div className="flex items-center space-x-1 bg-slate-700 p-1 rounded-md"><input type="number" placeholder="HP" value={hpChangeValues[p.id] || ''} onChange={(e) => 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}`}/><button onClick={() => applyHpChange(p.id, 'damage')} className="p-1 bg-red-500 hover:bg-red-600 text-white rounded-md text-xs" title="Damage"><HeartCrack size={16}/></button><button onClick={() => applyHpChange(p.id, 'heal')} className="p-1 bg-green-500 hover:bg-green-600 text-white rounded-md text-xs" title="Heal"><HeartPulse size={16}/></button></div>)}
<button onClick={() => 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 ? <UserCheck size={18} /> : <UserX size={18} />}</button> <button onClick={() => 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 ? <UserCheck size={18} /> : <UserX size={18} />}</button>
<button onClick={() => setEditingParticipant(p)} className="p-1 rounded transition-colors text-yellow-400 hover:text-yellow-300 bg-slate-600 hover:bg-slate-500" title="Edit"><Edit3 size={18} /></button> <button onClick={() => setEditingParticipant(p)} className="p-1 rounded transition-colors text-yellow-400 hover:text-yellow-300 bg-slate-600 hover:bg-slate-500" title="Edit"><Edit3 size={18} /></button>
<button onClick={() => 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"><Trash2 size={18} /></button> <button onClick={() => requestDeleteParticipant(p.id, p.name)} className="p-1 rounded transition-colors text-red-400 hover:text-red-300 bg-slate-600 hover:bg-slate-500" title="Remove"><Trash2 size={18} /></button>
</div> </div>
</li> </li>
); );
@ -813,6 +919,14 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
</ul> </ul>
{editingParticipant && <EditParticipantModal participant={editingParticipant} onClose={() => setEditingParticipant(null)} onSave={handleUpdateParticipant} />} {editingParticipant && <EditParticipantModal participant={editingParticipant} onClose={() => setEditingParticipant(null)} onSave={handleUpdateParticipant} />}
</div> </div>
<ConfirmationModal
isOpen={showDeleteParticipantConfirm}
onClose={() => 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 }) { function InitiativeControls({ campaignId, encounter, encounterPath }) {
const [showEndEncounterConfirm, setShowEndEncounterConfirm] = useState(false);
const handleStartEncounter = async () => { const handleStartEncounter = async () => {
if (!db ||!encounter.participants || encounter.participants.length === 0) { alert("Add participants first."); return; } if (!db ||!encounter.participants || encounter.participants.length === 0) { alert("Add participants first."); return; }
const activePs = encounter.participants.filter(p => p.isActive); 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); } } catch (err) { console.error("Error advancing turn:", err); }
}; };
const handleEndEncounter = async () => { const requestEndEncounter = () => {
setShowEndEncounterConfirm(true);
};
const confirmEndEncounter = async () => {
if (!db) return; if (!db) return;
// TODO: Implement custom confirmation modal for ending encounter
console.warn("Attempting to end encounter without confirmation");
try { try {
await updateDoc(doc(db, encounterPath), { isStarted: false, currentTurnParticipantId: null, round: 0, turnOrderIds: [] }); await updateDoc(doc(db, encounterPath), { isStarted: false, currentTurnParticipantId: null, round: 0, turnOrderIds: [] });
await setDoc(doc(db, getActiveDisplayDocPath()), { await setDoc(doc(db, getActiveDisplayDocPath()), {
@ -903,10 +1021,12 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
}, { merge: true }); }, { merge: true });
console.log("Encounter ended and deactivated from Player Display."); console.log("Encounter ended and deactivated from Player Display.");
} catch (err) { console.error("Error ending encounter:", err); } } catch (err) { console.error("Error ending encounter:", err); }
setShowEndEncounterConfirm(false);
}; };
if (!encounter || !encounter.participants) return null; if (!encounter || !encounter.participants) return null;
return ( return (
<>
<div className="mt-6 p-3 bg-slate-800 rounded-md"> <div className="mt-6 p-3 bg-slate-800 rounded-md">
<h4 className="text-lg font-medium text-sky-200 mb-3">Combat Controls</h4> <h4 className="text-lg font-medium text-sky-200 mb-3">Combat Controls</h4>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
@ -915,15 +1035,24 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
) : ( ) : (
<> <>
<button onClick={handleNextTurn} className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition-colors flex items-center" disabled={!encounter.currentTurnParticipantId}><SkipForwardIcon size={18} className="mr-2" /> Next Turn</button> <button onClick={handleNextTurn} className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition-colors flex items-center" disabled={!encounter.currentTurnParticipantId}><SkipForwardIcon size={18} className="mr-2" /> Next Turn</button>
<button onClick={handleEndEncounter} className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-md transition-colors flex items-center"><StopCircleIcon size={18} className="mr-2" /> End</button> <button onClick={requestEndEncounter} className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-md transition-colors flex items-center"><StopCircleIcon size={18} className="mr-2" /> End</button>
<p className="text-slate-300 self-center">Round: {encounter.round}</p> <p className="text-slate-300 self-center">Round: {encounter.round}</p>
</> </>
)} )}
</div> </div>
</div> </div>
<ConfirmationModal
isOpen={showEndEncounterConfirm}
onClose={() => 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() { function DisplayView() {
const { data: activeDisplayData, isLoading: isLoadingActiveDisplay, error: activeDisplayError } = useFirestoreDocument(getActiveDisplayDocPath()); const { data: activeDisplayData, isLoading: isLoadingActiveDisplay, error: activeDisplayError } = useFirestoreDocument(getActiveDisplayDocPath());
@ -932,7 +1061,6 @@ function DisplayView() {
const [encounterError, setEncounterError] = useState(null); const [encounterError, setEncounterError] = useState(null);
const [campaignBackgroundUrl, setCampaignBackgroundUrl] = useState(''); const [campaignBackgroundUrl, setCampaignBackgroundUrl] = useState('');
const [isPlayerDisplayActive, setIsPlayerDisplayActive] = useState(false); const [isPlayerDisplayActive, setIsPlayerDisplayActive] = useState(false);
// currentTurnRef removed as we are reverting the scroll-to-view logic for now
useEffect(() => { useEffect(() => {
if (!db) { if (!db) {
@ -994,7 +1122,6 @@ function DisplayView() {
}; };
}, [activeDisplayData, isLoadingActiveDisplay]); }, [activeDisplayData, isLoadingActiveDisplay]);
// useEffect for scrollIntoView removed
if (isLoadingActiveDisplay || (isPlayerDisplayActive && isLoadingEncounter)) { if (isLoadingActiveDisplay || (isPlayerDisplayActive && isLoadingEncounter)) {
return <div className="text-center py-10 text-2xl text-slate-300">Loading Player Display...</div>; return <div className="text-center py-10 text-2xl text-slate-300">Loading Player Display...</div>;
@ -1016,9 +1143,8 @@ function DisplayView() {
const { name, participants, round, currentTurnParticipantId, isStarted } = activeEncounterData; const { name, participants, round, currentTurnParticipantId, isStarted } = activeEncounterData;
// Reverted to showing all active participants, no focused view logic let participantsToRender = []; // Renamed from displayParticipants for clarity
let participantsToRender = []; if (participants) {
if (participants) {
if (isStarted && activeEncounterData.turnOrderIds?.length > 0 ) { if (isStarted && activeEncounterData.turnOrderIds?.length > 0 ) {
participantsToRender = activeEncounterData.turnOrderIds participantsToRender = activeEncounterData.turnOrderIds
.map(id => participants.find(p => p.id === id)).filter(p => p && p.isActive); .map(id => participants.find(p => p.id === id)).filter(p => p && p.isActive);
@ -1052,18 +1178,15 @@ function DisplayView() {
> >
<div className={campaignBackgroundUrl ? 'bg-slate-900 bg-opacity-75 p-4 md:p-6 rounded-lg' : ''}> <div className={campaignBackgroundUrl ? 'bg-slate-900 bg-opacity-75 p-4 md:p-6 rounded-lg' : ''}>
<h2 className="text-4xl md:text-5xl font-bold text-center text-amber-400 mb-2">{name}</h2> <h2 className="text-4xl md:text-5xl font-bold text-center text-amber-400 mb-2">{name}</h2>
{isStarted && <p className="text-2xl text-center text-sky-300 mb-6">Round: {round}</p>} {/* Adjusted margin */} {isStarted && <p className="text-2xl text-center text-sky-300 mb-6">Round: {round}</p>}
{/* Focused view indicator removed */}
{!isStarted && participants?.length > 0 && <p className="text-2xl text-center text-slate-400 mb-6">Awaiting Start</p>} {!isStarted && participants?.length > 0 && <p className="text-2xl text-center text-slate-400 mb-6">Awaiting Start</p>}
{!isStarted && (!participants || participants.length === 0) && <p className="text-2xl text-slate-500 mb-6">No participants.</p>} {!isStarted && (!participants || participants.length === 0) && <p className="text-2xl text-slate-500 mb-6">No participants.</p>}
{participantsToRender.length === 0 && isStarted && <p className="text-xl text-slate-400">No active participants.</p>} {participantsToRender.length === 0 && isStarted && <p className="text-xl text-slate-400">No active participants.</p>}
{/* Reverted to simpler list container */}
<div className="space-y-4 max-w-3xl mx-auto"> <div className="space-y-4 max-w-3xl mx-auto">
{participantsToRender.map(p => ( {participantsToRender.map(p => (
<div <div
key={p.id} key={p.id}
// ref removed
className={`p-4 md:p-6 rounded-lg shadow-lg transition-all ${p.id === currentTurnParticipantId && isStarted ? 'bg-green-700 ring-4 ring-green-400 scale-105' : (p.type === 'character' ? 'bg-sky-700' : 'bg-red-700')} ${!p.isActive ? 'opacity-40 grayscale' : ''}`} className={`p-4 md:p-6 rounded-lg shadow-lg transition-all ${p.id === currentTurnParticipantId && isStarted ? 'bg-green-700 ring-4 ring-green-400 scale-105' : (p.type === 'character' ? 'bg-sky-700' : 'bg-red-700')} ${!p.isActive ? 'opacity-40 grayscale' : ''}`}
> >
<div className="flex justify-between items-center mb-2"> <div className="flex justify-between items-center mb-2">
@ -1090,6 +1213,7 @@ function DisplayView() {
); );
} }
// --- Modal Component --- (Standard modal for forms, etc.)
function Modal({ onClose, title, children }) { function Modal({ onClose, title, children }) {
useEffect(() => { useEffect(() => {
const handleEsc = (event) => { if (event.key === 'Escape') onClose(); }; const handleEsc = (event) => { if (event.key === 'Escape') onClose(); };
@ -1109,6 +1233,7 @@ function Modal({ onClose, title, children }) {
); );
} }
// --- Icons ---
const PlayIcon = ({ size = 24, className = '' }) => <svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><polygon points="5 3 19 12 5 21 5 3"></polygon></svg>; const PlayIcon = ({ size = 24, className = '' }) => <svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><polygon points="5 3 19 12 5 21 5 3"></polygon></svg>;
const SkipForwardIcon = ({ size = 24, className = '' }) => <svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><polygon points="5 4 15 12 5 20 5 4"></polygon><line x1="19" y1="5" x2="19" y2="19"></line></svg>; const SkipForwardIcon = ({ size = 24, className = '' }) => <svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><polygon points="5 4 15 12 5 20 5 4"></polygon><line x1="19" y1="5" x2="19" y2="19"></line></svg>;
const StopCircleIcon = ({size=24, className=''}) => <svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><circle cx="12" cy="12" r="10"></circle><rect x="9" y="9" width="6" height="6"></rect></svg>; const StopCircleIcon = ({size=24, className=''}) => <svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><circle cx="12" cy="12" r="10"></circle><rect x="9" y="9" width="6" height="6"></rect></svg>;