diff --git a/src/App.js b/src/App.js index 880f427..836d432 100644 --- a/src/App.js +++ b/src/App.js @@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef, useMemo } from 'react'; import { initializeApp } from 'firebase/app'; import { getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken } from 'firebase/auth'; import { getFirestore, doc, setDoc, getDoc, getDocs, collection, onSnapshot, updateDoc, deleteDoc, query, writeBatch } from 'firebase/firestore'; -import { PlusCircle, Users, Swords, Trash2, Eye, Edit3, Save, XCircle, ChevronsUpDown, UserCheck, UserX, HeartCrack, HeartPulse, Zap, EyeOff, ExternalLink, AlertTriangle, Play as PlayIcon, Pause as PauseIcon, SkipForward as SkipForwardIcon, StopCircle as StopCircleIcon, Users2, Dices } from 'lucide-react'; // ImageIcon was already removed, ensuring it stays removed. +import { PlusCircle, Users, Swords, Trash2, Eye, Edit3, Save, XCircle, ChevronsUpDown, UserCheck, UserX, HeartCrack, HeartPulse, Zap, EyeOff, ExternalLink, AlertTriangle, Play as PlayIcon, Pause as PauseIcon, SkipForward as SkipForwardIcon, StopCircle as StopCircleIcon, Users2, Dices } from 'lucide-react'; // --- Firebase Configuration --- const firebaseConfig = { @@ -88,8 +88,6 @@ function useFirestoreCollection(collectionPath, queryConstraints = []) { const [data, setData] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); - - // Memoize the stringified queryConstraints. This is the key to stabilizing the effect's dependency. const queryString = useMemo(() => JSON.stringify(queryConstraints), [queryConstraints]); useEffect(() => { @@ -101,10 +99,7 @@ function useFirestoreCollection(collectionPath, queryConstraints = []) { } setIsLoading(true); setError(null); - - // queryConstraints is used here to build the query. const q = query(collection(db, collectionPath), ...queryConstraints); - const unsubscribe = onSnapshot(q, (snapshot) => { const items = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() })); setData(items); @@ -115,14 +110,9 @@ function useFirestoreCollection(collectionPath, queryConstraints = []) { setIsLoading(false); setData([]); }); - return () => unsubscribe(); - // The effect depends on collectionPath and the memoized queryString. - // This prevents re-running the effect if queryConstraints is a new array reference - // but its content (and thus queryString) is the same. // eslint-disable-next-line react-hooks/exhaustive-deps }, [collectionPath, queryString]); - return { data, isLoading, error }; } @@ -224,14 +214,13 @@ function App() { {!isAuthReady && !error &&

Authenticating...

} ); } // --- Confirmation Modal Component --- -// ... (ConfirmationModal remains the same) function ConfirmationModal({ isOpen, onClose, onConfirm, title, message }) { if (!isOpen) return null; return ( @@ -252,7 +241,6 @@ function ConfirmationModal({ isOpen, onClose, onConfirm, title, message }) { } // --- Admin View Component --- -// ... (AdminView remains the same) function AdminView({ userId }) { const { data: campaignsData, isLoading: isLoadingCampaigns } = useFirestoreCollection(getCampaignsCollectionPath()); const { data: initialActiveInfoData } = useFirestoreDocument(getActiveDisplayDocPath()); @@ -344,7 +332,6 @@ function AdminView({ userId }) {
setSelectedCampaignId(campaign.id)} className={cardClasses} style={cardStyle}>

{campaign.name}

- {/* ImageIcon display removed from here */}
); @@ -366,10 +353,6 @@ function AdminView({ userId }) { ); } -// --- CreateCampaignForm, CharacterManager, EncounterManager, CreateEncounterForm, ParticipantManager, EditParticipantModal, InitiativeControls, DisplayView, Modal, Icons --- -// These components are identical to the previous version (v0.1.24) and are included below for completeness. -// The changes for ESLint warnings were primarily in the imports at the top of App.js and in the useFirestoreCollection hook. - function CreateCampaignForm({ onCreate, onCancel }) { const [name, setName] = useState(''); const [backgroundUrl, setBackgroundUrl] = useState(''); @@ -392,7 +375,6 @@ function CreateCampaignForm({ onCreate, onCancel }) { ); } - function CharacterManager({ campaignId, campaignCharacters }) { const [characterName, setCharacterName] = useState(''); const [defaultMaxHp, setDefaultMaxHp] = useState(10); @@ -400,6 +382,7 @@ function CharacterManager({ campaignId, campaignCharacters }) { const [editingCharacter, setEditingCharacter] = useState(null); const [showDeleteCharConfirm, setShowDeleteCharConfirm] = useState(false); const [itemToDelete, setItemToDelete] = useState(null); + const handleAddCharacter = async () => { if (!db ||!characterName.trim() || !campaignId) return; const hp = parseInt(defaultMaxHp, 10); @@ -582,6 +565,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) { const [selectedCharacterId, setSelectedCharacterId] = useState(''); const [monsterInitMod, setMonsterInitMod] = useState(2); const [maxHp, setMaxHp] = useState(10); + const [isNpc, setIsNpc] = useState(false); // New state for NPC checkbox const [editingParticipant, setEditingParticipant] = useState(null); const [hpChangeValues, setHpChangeValues] = useState({}); const [draggedItemId, setDraggedItemId] = useState(null); @@ -595,7 +579,12 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) { if (participantType === 'character' && selectedCharacterId) { const selectedChar = campaignCharacters.find(c => c.id === selectedCharacterId); if (selectedChar && selectedChar.defaultMaxHp) { setMaxHp(selectedChar.defaultMaxHp); } else { setMaxHp(10); } - } else if (participantType === 'monster') { setMaxHp(10); setMonsterInitMod(MONSTER_DEFAULT_INIT_MOD); } + setIsNpc(false); // Characters cannot be NPCs in this model + } else if (participantType === 'monster') { + setMaxHp(10); + setMonsterInitMod(MONSTER_DEFAULT_INIT_MOD); + // setIsNpc(false); // Reset NPC status when switching to monster, or keep last state? Let's reset. + } }, [selectedCharacterId, participantType, campaignCharacters]); const handleAddParticipant = async () => { @@ -605,6 +594,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) { let modifier = 0; let finalInitiative; let currentMaxHp = parseInt(maxHp, 10) || 10; + let participantIsNpc = false; if (participantType === 'character') { const character = campaignCharacters.find(c => c.id === selectedCharacterId); @@ -614,40 +604,48 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) { currentMaxHp = character.defaultMaxHp || currentMaxHp; modifier = character.defaultInitMod || 0; finalInitiative = initiativeRoll + modifier; - } else { + } else { // Monster modifier = parseInt(monsterInitMod, 10) || 0; finalInitiative = initiativeRoll + modifier; + participantIsNpc = isNpc; // Use the state of the NPC checkbox } - const newParticipant = { id: generateId(), name: nameToAdd, type: participantType, originalCharacterId: participantType === 'character' ? selectedCharacterId : null, initiative: finalInitiative, maxHp: currentMaxHp, currentHp: currentMaxHp, conditions: [], isActive: true, }; + const newParticipant = { + id: generateId(), name: nameToAdd, type: participantType, + originalCharacterId: participantType === 'character' ? selectedCharacterId : null, + initiative: finalInitiative, maxHp: currentMaxHp, currentHp: currentMaxHp, + isNpc: participantType === 'monster' ? participantIsNpc : false, // Store isNpc for monsters + conditions: [], isActive: true, + }; try { await updateDoc(doc(db, encounterPath), { participants: [...participants, newParticipant] }); - setLastRollDetails({ name: nameToAdd, roll: initiativeRoll, mod: modifier, total: finalInitiative }); + setLastRollDetails({ name: nameToAdd, roll: initiativeRoll, mod: modifier, total: finalInitiative, type: participantIsNpc ? 'NPC' : participantType }); setTimeout(() => setLastRollDetails(null), 5000); - setParticipantName(''); setMaxHp(10); setSelectedCharacterId(''); setMonsterInitMod(MONSTER_DEFAULT_INIT_MOD); + setParticipantName(''); setMaxHp(10); setSelectedCharacterId(''); setMonsterInitMod(MONSTER_DEFAULT_INIT_MOD); setIsNpc(false); } catch (err) { console.error("Error adding participant:", err); } }; const handleAddAllCampaignCharacters = async () => { if (!db || !campaignCharacters || campaignCharacters.length === 0) return; const existingParticipantOriginalIds = participants.filter(p => p.type === 'character' && p.originalCharacterId).map(p => p.originalCharacterId); + let consoleRollLog = "Adding all campaign characters:\n"; const newParticipants = campaignCharacters .filter(char => !existingParticipantOriginalIds.includes(char.id)) .map(char => { const initiativeRoll = rollD20(); const modifier = char.defaultInitMod || 0; const finalInitiative = initiativeRoll + modifier; - console.log(`Adding ${char.name}: Rolled ${initiativeRoll} + ${modifier} (mod) = ${finalInitiative} init`); - return { id: generateId(), name: char.name, type: 'character', originalCharacterId: char.id, initiative: finalInitiative, maxHp: char.defaultMaxHp || 10, currentHp: char.defaultMaxHp || 10, conditions: [], isActive: true, }; + consoleRollLog += `${char.name}: Rolled D20 (${initiativeRoll}) + ${modifier} (mod) = ${finalInitiative} init\n`; + return { id: generateId(), name: char.name, type: 'character', originalCharacterId: char.id, initiative: finalInitiative, maxHp: char.defaultMaxHp || 10, currentHp: char.defaultMaxHp || 10, conditions: [], isActive: true, isNpc: false }; // Characters are not NPCs }); if (newParticipants.length === 0) { alert("All campaign characters are already in this encounter."); return; } + console.log(consoleRollLog); try { await updateDoc(doc(db, encounterPath), { participants: [...participants, ...newParticipants] }); console.log(`Added ${newParticipants.length} characters to the encounter.`); } catch (err) { console.error("Error adding all campaign characters:", err); } }; const handleUpdateParticipant = async (updatedData) => { if (!db || !editingParticipant) return; - const { flavorText, ...restOfData } = updatedData; - const updatedParticipants = participants.map(p => p.id === editingParticipant.id ? { ...p, ...restOfData } : p ); + const updatedParticipants = participants.map(p => p.id === editingParticipant.id ? { ...p, ...updatedData } : p ); // updatedData now includes isNpc try { await updateDoc(doc(db, encounterPath), { participants: updatedParticipants }); setEditingParticipant(null); } catch (err) { console.error("Error updating participant:", err); } }; const requestDeleteParticipant = (participantId, participantName) => { setItemToDelete({ id: participantId, name: participantName, type: 'participant' }); setShowDeleteParticipantConfirm(true); }; @@ -713,10 +711,10 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) { Add All (Roll Init) -
{ e.preventDefault(); handleAddParticipant(); }} className="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-2 mb-4 p-3 bg-slate-700 rounded"> + { e.preventDefault(); handleAddParticipant(); }} className="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3 mb-4 p-3 bg-slate-700 rounded">
- {setParticipantType(e.target.value); setSelectedCharacterId(''); setIsNpc(false);}} className="mt-1 w-full px-3 py-2 bg-slate-600 border-slate-500 rounded text-white"> @@ -724,12 +722,16 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) { {participantType === 'monster' && ( <>
- - setParticipantName(e.target.value)} placeholder="e.g., Dire Wolf" className="mt-1 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" /> + + setParticipantName(e.target.value)} placeholder="e.g., Dire Wolf" className="mt-1 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" />
- - setMonsterInitMod(e.target.value)} className="mt-1 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" /> + + setMonsterInitMod(e.target.value)} className="mt-1 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" /> +
+
+ setIsNpc(e.target.checked)} className="h-4 w-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500" /> +
)} @@ -739,7 +741,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) { {lastRollDetails && (

- {lastRollDetails.name}: Rolled D20 ({lastRollDetails.roll}) {lastRollDetails.mod >= 0 ? '+' : ''} {lastRollDetails.mod} (mod) = {lastRollDetails.total} Initiative + {lastRollDetails.name} ({lastRollDetails.type}): Rolled D20 ({lastRollDetails.roll}) {lastRollDetails.mod >= 0 ? '+' : ''} {lastRollDetails.mod} (mod) = {lastRollDetails.total} Initiative

)} {participants.length === 0 &&

No participants.

} @@ -747,13 +749,17 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) { {sortedAdminParticipants.map((p) => { const isCurrentTurn = encounter.isStarted && p.id === encounter.currentTurnParticipantId; const isDraggable = (!encounter.isStarted || encounter.isPaused) && tiedInitiatives.includes(Number(p.initiative)); + const participantDisplayType = p.type === 'monster' ? (p.isNpc ? 'NPC' : 'Monster') : 'Character'; + let bgColor = p.type === 'character' ? 'bg-sky-800' : (p.isNpc ? 'bg-slate-600' : 'bg-red-800'); + if (isCurrentTurn && !encounter.isPaused) bgColor = 'bg-green-600'; + return (
  • handleDragStart(e, p.id) : undefined} onDragOver={isDraggable ? handleDragOver : undefined} onDrop={isDraggable ? (e) => handleDrop(e, p.id) : undefined} onDragEnd={isDraggable ? handleDragEnd : undefined} - className={`p-3 rounded-md flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 transition-all ${isCurrentTurn && !encounter.isPaused ? 'bg-green-600 ring-2 ring-green-300 shadow-lg' : (p.type === 'character' ? 'bg-sky-800' : 'bg-red-800')} ${!p.isActive ? 'opacity-50' : ''} ${isDraggable ? 'cursor-grab' : ''} ${draggedItemId === p.id ? 'opacity-50 ring-2 ring-yellow-400' : ''}`}> + className={`p-3 rounded-md flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 transition-all ${bgColor} ${isCurrentTurn && !encounter.isPaused ? 'ring-2 ring-green-300 shadow-lg' : ''} ${!p.isActive ? 'opacity-50' : ''} ${isDraggable ? 'cursor-grab' : ''} ${draggedItemId === p.id ? 'opacity-50 ring-2 ring-yellow-400' : ''}`}>
    {isDraggable && }
    -

    {p.name} ({p.type}){isCurrentTurn && !encounter.isPaused && CURRENT}

    +

    {p.name} ({participantDisplayType}){isCurrentTurn && !encounter.isPaused && CURRENT}

    Init: {p.initiative} | HP: {p.currentHp}/{p.maxHp}

    @@ -773,15 +779,23 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) { ); } -// ... (EditParticipantModal, InitiativeControls, DisplayView, Modal, Icons) -// The rest of the components are assumed to be the same as v0.1.25 for this update. - 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), }); }; + const [isNpc, setIsNpc] = useState(participant.type === 'monster' ? (participant.isNpc || false) : false); + + const handleSubmit = (e) => { + e.preventDefault(); + onSave({ + name: name.trim(), + initiative: parseInt(initiative, 10), + currentHp: parseInt(currentHp, 10), + maxHp: parseInt(maxHp, 10), + isNpc: participant.type === 'monster' ? isNpc : false, // Only save isNpc for monsters + }); + }; return (
    @@ -791,6 +805,12 @@ function EditParticipantModal({ participant, onClose, onSave }) {
    setCurrentHp(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" />
    setMaxHp(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" />
  • + {participant.type === 'monster' && ( +
    + setIsNpc(e.target.checked)} className="h-4 w-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500"/> + +
    + )}
    @@ -800,6 +820,10 @@ function EditParticipantModal({ participant, onClose, onSave }) { ); } +// ... (InitiativeControls, DisplayView, Modal, Icons) +// The rest of the components are assumed to be the same as v0.1.25 for this update. +// DisplayView will need to be updated to reflect NPC styling. + function InitiativeControls({ campaignId, encounter, encounterPath }) { const [showEndEncounterConfirm, setShowEndEncounterConfirm] = useState(false); const handleStartEncounter = async () => { @@ -936,10 +960,23 @@ function DisplayView() { {!isStarted && (!participants || participants.length === 0) &&

    No participants.

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

    No active participants.

    }
    - {participantsToRender.map(p => ( -
    + {participantsToRender.map(p => { + let participantBgColor = 'bg-red-700'; // Default for monster + if (p.type === 'character') { + participantBgColor = 'bg-sky-700'; + } else if (p.isNpc) { + participantBgColor = 'bg-slate-700'; // Muted gray for NPC + } + if (p.id === currentTurnParticipantId && isStarted && !isPaused) { + participantBgColor = 'bg-green-700 ring-4 ring-green-400 scale-105'; + } else if (isPaused && p.id === currentTurnParticipantId) { + participantBgColor += ' ring-2 ring-yellow-400'; + } + + return ( +
    -

    {p.name}{p.id === currentTurnParticipantId && isStarted && !isPaused && (Current)}

    +

    {p.name}{p.id === currentTurnParticipantId && isStarted && !isPaused && (Current)}

    Init: {p.initiative}
    @@ -950,7 +987,9 @@ function DisplayView() {
    {p.conditions?.length > 0 &&

    Conditions: {p.conditions.join(', ')}

    } {!p.isActive &&

    (Inactive)

    } -
    ))} +
    + ); + })}
    );