1085 lines
74 KiB
JavaScript
1085 lines
74 KiB
JavaScript
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, ChevronDown, ChevronUp } from 'lucide-react'; // Added ChevronDown, ChevronUp
|
|
|
|
// --- Firebase Configuration ---
|
|
const firebaseConfig = {
|
|
apiKey: process.env.REACT_APP_FIREBASE_API_KEY,
|
|
authDomain: process.env.REACT_APP_FIREBASE_AUTH_DOMAIN,
|
|
projectId: process.env.REACT_APP_FIREBASE_PROJECT_ID,
|
|
storageBucket: process.env.REACT_APP_FIREBASE_STORAGE_BUCKET,
|
|
messagingSenderId: process.env.REACT_APP_FIREBASE_MESSAGING_SENDER_ID,
|
|
appId: process.env.REACT_APP_FIREBASE_APP_ID
|
|
};
|
|
|
|
let app;
|
|
let db;
|
|
let auth;
|
|
|
|
const requiredFirebaseConfigKeys = ['apiKey', 'authDomain', 'projectId', 'appId'];
|
|
const missingKeys = requiredFirebaseConfigKeys.filter(key => !firebaseConfig[key]);
|
|
|
|
if (missingKeys.length > 0) {
|
|
console.error(`CRITICAL: Missing Firebase config values from environment variables: ${missingKeys.join(', ')}`);
|
|
console.error("Firebase cannot be initialized. Please ensure all REACT_APP_FIREBASE_... variables are set in your .env.local file and accessible during the build.");
|
|
} else {
|
|
try {
|
|
app = initializeApp(firebaseConfig);
|
|
db = getFirestore(app);
|
|
auth = getAuth(app);
|
|
} catch (error) {
|
|
console.error("Error initializing Firebase:", error);
|
|
}
|
|
}
|
|
|
|
// --- Firestore Paths ---
|
|
const APP_ID = process.env.REACT_APP_TRACKER_APP_ID || 'ttrpg-initiative-tracker-default';
|
|
const PUBLIC_DATA_PATH = `artifacts/${APP_ID}/public/data`;
|
|
|
|
// --- Firestore Path Helpers ---
|
|
const getCampaignsCollectionPath = () => `${PUBLIC_DATA_PATH}/campaigns`;
|
|
const getCampaignDocPath = (campaignId) => `${PUBLIC_DATA_PATH}/campaigns/${campaignId}`;
|
|
const getEncountersCollectionPath = (campaignId) => `${PUBLIC_DATA_PATH}/campaigns/${campaignId}/encounters`;
|
|
const getEncounterDocPath = (campaignId, encounterId) => `${PUBLIC_DATA_PATH}/campaigns/${campaignId}/encounters/${encounterId}`;
|
|
const getActiveDisplayDocPath = () => `${PUBLIC_DATA_PATH}/activeDisplay/status`;
|
|
|
|
|
|
// --- Helper Functions ---
|
|
const generateId = () => crypto.randomUUID();
|
|
const rollD20 = () => Math.floor(Math.random() * 20) + 1;
|
|
|
|
// --- Custom Hooks for Firestore ---
|
|
function useFirestoreDocument(docPath) {
|
|
const [data, setData] = useState(null);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [error, setError] = useState(null);
|
|
|
|
useEffect(() => {
|
|
if (!db || !docPath) {
|
|
setData(null);
|
|
setIsLoading(false);
|
|
setError(docPath ? "Firestore not available." : "Document path not provided.");
|
|
return;
|
|
}
|
|
setIsLoading(true);
|
|
setError(null);
|
|
const docRef = doc(db, docPath);
|
|
const unsubscribe = onSnapshot(docRef, (docSnap) => {
|
|
if (docSnap.exists()) {
|
|
setData({ id: docSnap.id, ...docSnap.data() });
|
|
} else {
|
|
setData(null);
|
|
}
|
|
setIsLoading(false);
|
|
}, (err) => {
|
|
console.error(`Error fetching document ${docPath}:`, err);
|
|
setError(err.message || "Failed to fetch document.");
|
|
setIsLoading(false);
|
|
setData(null);
|
|
});
|
|
return () => unsubscribe();
|
|
}, [docPath]);
|
|
return { data, isLoading, error };
|
|
}
|
|
|
|
function useFirestoreCollection(collectionPath, queryConstraints = []) {
|
|
const [data, setData] = useState([]);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [error, setError] = useState(null);
|
|
const queryString = useMemo(() => JSON.stringify(queryConstraints), [queryConstraints]);
|
|
|
|
useEffect(() => {
|
|
if (!db || !collectionPath) {
|
|
setData([]);
|
|
setIsLoading(false);
|
|
setError(collectionPath ? "Firestore not available." : "Collection path not provided.");
|
|
return;
|
|
}
|
|
setIsLoading(true);
|
|
setError(null);
|
|
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);
|
|
setIsLoading(false);
|
|
}, (err) => {
|
|
console.error(`Error fetching collection ${collectionPath}:`, err);
|
|
setError(err.message || "Failed to fetch collection.");
|
|
setIsLoading(false);
|
|
setData([]);
|
|
});
|
|
return () => unsubscribe();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [collectionPath, queryString]);
|
|
return { data, isLoading, error };
|
|
}
|
|
|
|
|
|
// --- Main App Component ---
|
|
function App() {
|
|
const [userId, setUserId] = useState(null);
|
|
const [isAuthReady, setIsAuthReady] = useState(false);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [error, setError] = useState(null);
|
|
const [isPlayerViewOnlyMode, setIsPlayerViewOnlyMode] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const queryParams = new URLSearchParams(window.location.search);
|
|
if (queryParams.get('playerView') === 'true') {
|
|
setIsPlayerViewOnlyMode(true);
|
|
}
|
|
if (!auth) {
|
|
setError("Firebase Auth not initialized. Check your Firebase configuration.");
|
|
setIsLoading(false);
|
|
setIsAuthReady(false);
|
|
return;
|
|
}
|
|
const initAuth = async () => {
|
|
try {
|
|
const token = window.__initial_auth_token;
|
|
if (token) {
|
|
await signInWithCustomToken(auth, token);
|
|
} else {
|
|
await signInAnonymously(auth);
|
|
}
|
|
} catch (err) {
|
|
console.error("Authentication error:", err);
|
|
setError("Failed to authenticate. Please try again later.");
|
|
}
|
|
};
|
|
const unsubscribe = onAuthStateChanged(auth, (user) => {
|
|
setUserId(user ? user.uid : null);
|
|
setIsAuthReady(true);
|
|
setIsLoading(false);
|
|
});
|
|
initAuth();
|
|
return () => unsubscribe();
|
|
}, []);
|
|
|
|
if (!db || !auth) {
|
|
return (
|
|
<div className="min-h-screen bg-red-900 text-white flex flex-col items-center justify-center p-4">
|
|
<h1 className="text-3xl font-bold">Configuration Error</h1>
|
|
<p className="mt-4 text-xl">Firebase is not properly configured or initialized.</p>
|
|
<p>Please check your `.env.local` file and ensure all `REACT_APP_FIREBASE_...` variables are correctly set.</p>
|
|
<p>Also, check the browser console for more specific error messages.</p>
|
|
{error && <p className="mt-2 text-yellow-300">{error}</p>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (isLoading || !isAuthReady) {
|
|
return (
|
|
<div className="min-h-screen bg-slate-900 text-white flex flex-col items-center justify-center p-4">
|
|
<div className="animate-spin rounded-full h-16 w-16 border-t-4 border-blue-500 border-solid"></div>
|
|
<p className="mt-4 text-xl">Loading Initiative Tracker...</p>
|
|
{error && <p className="mt-2 text-red-400">{error}</p>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const openPlayerWindow = () => {
|
|
const playerViewUrl = window.location.origin + window.location.pathname + '?playerView=true';
|
|
window.open(playerViewUrl, '_blank', 'noopener,noreferrer,width=1024,height=768');
|
|
};
|
|
|
|
if (isPlayerViewOnlyMode) {
|
|
return (
|
|
<div className="min-h-screen bg-slate-800 text-slate-100 font-sans">
|
|
{isAuthReady && <DisplayView />}
|
|
{!isAuthReady && !error && <p>Authenticating for Player Display...</p>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-slate-800 text-slate-100 font-sans">
|
|
<header className="bg-slate-900 p-4 shadow-lg">
|
|
<div className="container mx-auto flex justify-between items-center">
|
|
<h1 className="text-3xl font-bold text-sky-400">TTRPG Initiative Tracker</h1>
|
|
<div className="flex items-center space-x-4">
|
|
<button
|
|
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`}
|
|
>
|
|
<ExternalLink size={16} className="mr-2"/> Open Player Window
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
<main className={`container mx-auto p-4 md:p-8`}>
|
|
{isAuthReady && userId && <AdminView userId={userId} />}
|
|
{!isAuthReady && !error && <p>Authenticating...</p>}
|
|
</main>
|
|
<footer className="bg-slate-900 p-4 text-center text-sm text-slate-400 mt-8">
|
|
TTRPG Initiative Tracker v0.2.1
|
|
</footer>
|
|
</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 ---
|
|
function AdminView({ userId }) {
|
|
const { data: campaignsData, isLoading: isLoadingCampaigns, error: campaignsError } = useFirestoreCollection(getCampaignsCollectionPath());
|
|
const { data: initialActiveInfoData } = useFirestoreDocument(getActiveDisplayDocPath());
|
|
const [campaignsWithDetails, setCampaignsWithDetails] = useState([]);
|
|
const [selectedCampaignId, setSelectedCampaignId] = useState(null);
|
|
const [showCreateCampaignModal, setShowCreateCampaignModal] = useState(false);
|
|
const [showDeleteCampaignConfirm, setShowDeleteCampaignConfirm] = useState(false);
|
|
const [itemToDelete, setItemToDelete] = useState(null);
|
|
|
|
useEffect(() => {
|
|
if (campaignsData && db) {
|
|
const fetchDetails = async () => {
|
|
const detailedCampaigns = await Promise.all(
|
|
campaignsData.map(async (campaign) => {
|
|
const characters = campaign.players || [];
|
|
let encounterCount = 0;
|
|
try {
|
|
const encountersSnapshot = await getDocs(collection(db, getEncountersCollectionPath(campaign.id)));
|
|
encounterCount = encountersSnapshot.size;
|
|
} catch (err) {
|
|
console.error(`Failed to fetch encounters for campaign ${campaign.id} (${campaign.name}):`, err);
|
|
}
|
|
return { ...campaign, characters, encounterCount };
|
|
})
|
|
);
|
|
setCampaignsWithDetails(detailedCampaigns);
|
|
};
|
|
fetchDetails();
|
|
} else if (campaignsData) {
|
|
setCampaignsWithDetails(campaignsData.map(c => ({ ...c, characters: c.players || [], encounterCount: 0 })));
|
|
}
|
|
}, [campaignsData]);
|
|
|
|
useEffect(() => {
|
|
if (initialActiveInfoData && initialActiveInfoData.activeCampaignId && campaignsWithDetails.length > 0 && !selectedCampaignId) {
|
|
const campaignExists = campaignsWithDetails.some(c => c.id === initialActiveInfoData.activeCampaignId);
|
|
if (campaignExists) {
|
|
setSelectedCampaignId(initialActiveInfoData.activeCampaignId);
|
|
}
|
|
}
|
|
}, [initialActiveInfoData, campaignsWithDetails, selectedCampaignId]);
|
|
|
|
const handleCreateCampaign = async (name, backgroundUrl) => {
|
|
if (!db || !name.trim()) return;
|
|
const newCampaignId = generateId();
|
|
try {
|
|
await setDoc(doc(db, getCampaignDocPath(newCampaignId)), {
|
|
name: name.trim(),
|
|
playerDisplayBackgroundUrl: backgroundUrl.trim() || '',
|
|
ownerId: userId,
|
|
createdAt: new Date().toISOString(),
|
|
players: [],
|
|
});
|
|
setShowCreateCampaignModal(false);
|
|
setSelectedCampaignId(newCampaignId);
|
|
} catch (err) { console.error("Error creating campaign:", err); }
|
|
};
|
|
|
|
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));
|
|
const batch = writeBatch(db);
|
|
encountersSnapshot.docs.forEach(encounterDoc => batch.delete(encounterDoc.ref));
|
|
await batch.commit();
|
|
await deleteDoc(doc(db, getCampaignDocPath(campaignId)));
|
|
if (selectedCampaignId === campaignId) setSelectedCampaignId(null);
|
|
const activeDisplayRef = doc(db, getActiveDisplayDocPath());
|
|
const activeDisplaySnap = await getDoc(activeDisplayRef);
|
|
if (activeDisplaySnap.exists() && activeDisplaySnap.data().activeCampaignId === campaignId) {
|
|
await updateDoc(activeDisplayRef, { activeCampaignId: null, activeEncounterId: null });
|
|
}
|
|
} catch (err) { console.error("Error deleting campaign:", err); }
|
|
setShowDeleteCampaignConfirm(false);
|
|
setItemToDelete(null);
|
|
};
|
|
|
|
const selectedCampaign = campaignsWithDetails.find(c => c.id === selectedCampaignId);
|
|
|
|
if (isLoadingCampaigns) {
|
|
return <p className="text-center text-slate-300">Loading campaigns...</p>;
|
|
}
|
|
if (campaignsError) {
|
|
return <p className="text-center text-red-400">Error loading campaigns: {campaignsError.message || String(campaignsError)}</p>;
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div className="space-y-6">
|
|
<div>
|
|
<div className="flex justify-between items-center mb-4">
|
|
<h2 className="text-2xl font-semibold text-sky-300">Campaigns</h2>
|
|
<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">
|
|
<PlusCircle size={20} className="mr-2" /> Create Campaign
|
|
</button>
|
|
</div>
|
|
{campaignsWithDetails.length === 0 && !isLoadingCampaigns && <p className="text-slate-400">No campaigns yet.</p>}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{campaignsWithDetails.map(campaign => {
|
|
const cardStyle = campaign.playerDisplayBackgroundUrl ? { backgroundImage: `url(${campaign.playerDisplayBackgroundUrl})`} : {};
|
|
const cardClasses = `h-40 flex flex-col justify-between rounded-lg shadow-md cursor-pointer transition-all relative overflow-hidden bg-cover bg-center ${selectedCampaignId === campaign.id ? 'ring-4 ring-sky-400' : ''} ${!campaign.playerDisplayBackgroundUrl ? 'bg-slate-700 hover:bg-slate-600' : 'hover:shadow-xl'}`;
|
|
return (
|
|
<div key={campaign.id} onClick={() => setSelectedCampaignId(campaign.id)} className={cardClasses} style={cardStyle}>
|
|
<div className={`relative z-10 flex flex-col justify-between h-full ${campaign.playerDisplayBackgroundUrl ? 'bg-black bg-opacity-60 p-3' : 'p-4'}`}>
|
|
<div>
|
|
<h3 className="text-xl font-semibold text-white">{campaign.name}</h3>
|
|
<div className="text-xs text-slate-100 mt-1 space-x-3">
|
|
<span className="inline-flex items-center">
|
|
<Users size={12} className="mr-1"/> {campaign.characters?.length || 0} Characters
|
|
</span>
|
|
<span className="inline-flex items-center">
|
|
<Swords size={12} className="mr-1"/> {campaign.encounterCount === undefined ? '...' : campaign.encounterCount} Encounters
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<button onClick={(e) => { e.stopPropagation(); requestDeleteCampaign(campaign.id, campaign.name); }} className="mt-auto text-red-300 hover:text-red-100 text-xs flex items-center self-start bg-black bg-opacity-50 hover:bg-opacity-70 px-2 py-1 rounded"><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.characters || []} />
|
|
<hr className="my-6 border-slate-600" />
|
|
<EncounterManager campaignId={selectedCampaignId} initialActiveEncounterId={initialActiveInfoData && initialActiveInfoData.activeCampaignId === selectedCampaignId ? initialActiveInfoData.activeEncounterId : null} campaignCharacters={selectedCampaign.characters || []} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
<ConfirmationModal isOpen={showDeleteCampaignConfirm} onClose={() => 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.`}/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function CreateCampaignForm({ onCreate, onCancel }) {
|
|
const [name, setName] = useState('');
|
|
const [backgroundUrl, setBackgroundUrl] = useState('');
|
|
const handleSubmit = (e) => { e.preventDefault(); onCreate(name, backgroundUrl); };
|
|
return (
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
<div>
|
|
<label htmlFor="campaignName" className="block text-sm font-medium text-slate-300">Campaign Name</label>
|
|
<input type="text" id="campaignName" value={name} onChange={(e) => setName(e.target.value)} className="mt-1 block w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-md shadow-sm focus:outline-none focus:ring-sky-500 focus:border-sky-500 sm:text-sm text-white" required />
|
|
</div>
|
|
<div>
|
|
<label htmlFor="backgroundUrl" className="block text-sm font-medium text-slate-300">Player Display Background URL (Optional)</label>
|
|
<input type="url" id="backgroundUrl" value={backgroundUrl} onChange={(e) => setBackgroundUrl(e.target.value)} placeholder="https://example.com/image.jpg" 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" />
|
|
</div>
|
|
<div className="flex justify-end space-x-3">
|
|
<button type="button" onClick={onCancel} 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 type="submit" className="px-4 py-2 text-sm font-medium text-white bg-green-500 hover:bg-green-600 rounded-md transition-colors">Create</button>
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
|
|
function CharacterManager({ campaignId, campaignCharacters }) {
|
|
const [characterName, setCharacterName] = useState('');
|
|
const [defaultMaxHp, setDefaultMaxHp] = useState(10);
|
|
const [defaultInitMod, setDefaultInitMod] = useState(0);
|
|
const [editingCharacter, setEditingCharacter] = useState(null);
|
|
const [showDeleteCharConfirm, setShowDeleteCharConfirm] = useState(false);
|
|
const [itemToDelete, setItemToDelete] = useState(null);
|
|
const [isCharactersSectionOpen, setIsCharactersSectionOpen] = useState(true); // ADDED THIS LINE
|
|
|
|
const handleAddCharacter = async () => {
|
|
if (!db ||!characterName.trim() || !campaignId) return;
|
|
const hp = parseInt(defaultMaxHp, 10);
|
|
const initMod = parseInt(defaultInitMod, 10);
|
|
if (isNaN(hp) || hp <= 0) { alert("Please enter a valid positive number for Default Max HP."); return; }
|
|
if (isNaN(initMod)) { alert("Please enter a valid number for Default Initiative Modifier."); return; }
|
|
const newCharacter = { id: generateId(), name: characterName.trim(), defaultMaxHp: hp, defaultInitMod: initMod };
|
|
try { await updateDoc(doc(db, getCampaignDocPath(campaignId)), { players: [...campaignCharacters, newCharacter] }); setCharacterName(''); setDefaultMaxHp(10); setDefaultInitMod(0); } catch (err) { console.error("Error adding character:", err); }
|
|
};
|
|
const handleUpdateCharacter = async (characterId, newName, newDefaultMaxHp, newDefaultInitMod) => {
|
|
if (!db ||!newName.trim() || !campaignId) return;
|
|
const hp = parseInt(newDefaultMaxHp, 10);
|
|
const initMod = parseInt(newDefaultInitMod, 10);
|
|
if (isNaN(hp) || hp <= 0) { alert("Please enter a valid positive number for Default Max HP."); setEditingCharacter(null); return; }
|
|
if (isNaN(initMod)) { alert("Please enter a valid number for Default Initiative Modifier."); setEditingCharacter(null); return; }
|
|
const updatedCharacters = campaignCharacters.map(c => c.id === characterId ? { ...c, name: newName.trim(), defaultMaxHp: hp, defaultInitMod: initMod } : c);
|
|
try { await updateDoc(doc(db, getCampaignDocPath(campaignId)), { players: updatedCharacters }); setEditingCharacter(null); } catch (err) { console.error("Error updating character:", err); }
|
|
};
|
|
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 (
|
|
<>
|
|
<div className="p-4 bg-slate-800 rounded-lg shadow">
|
|
<div className="flex justify-between items-center mb-3"> {/* ADDED THIS DIV FOR HEADER AND TOGGLE */}
|
|
<h3 className="text-xl font-semibold text-sky-300 flex items-center"><Users size={24} className="mr-2" /> Campaign Characters</h3>
|
|
<button
|
|
onClick={() => setIsCharactersSectionOpen(!isCharactersSectionOpen)}
|
|
className="p-1 text-slate-400 hover:text-slate-200"
|
|
aria-label={isCharactersSectionOpen ? "Collapse Characters Section" : "Expand Characters Section"}
|
|
>
|
|
{isCharactersSectionOpen ? <ChevronUp size={20} /> : <ChevronDown size={20} />}
|
|
</button>
|
|
</div>
|
|
|
|
{isCharactersSectionOpen && ( // ADDED CONDITIONAL RENDERING
|
|
<>
|
|
<form onSubmit={(e) => { e.preventDefault(); handleAddCharacter(); }} className="grid grid-cols-1 sm:grid-cols-3 gap-2 mb-4 items-end">
|
|
<div className="sm:col-span-1">
|
|
<label htmlFor="characterName" className="block text-xs font-medium text-slate-400">Name</label>
|
|
<input type="text" id="characterName" value={characterName} onChange={(e) => setCharacterName(e.target.value)} placeholder="New character name" className="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" />
|
|
</div>
|
|
<div className="w-full sm:w-auto">
|
|
<label htmlFor="defaultMaxHp" className="block text-xs font-medium text-slate-400">Default HP</label>
|
|
<input type="number" id="defaultMaxHp" value={defaultMaxHp} onChange={(e) => setDefaultMaxHp(e.target.value)} placeholder="HP" className="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" />
|
|
</div>
|
|
<div className="w-full sm:w-auto">
|
|
<label htmlFor="defaultInitMod" className="block text-xs font-medium text-slate-400">Init Mod</label>
|
|
<input type="number" id="defaultInitMod" value={defaultInitMod} onChange={(e) => setDefaultInitMod(e.target.value)} placeholder="+0" className="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" />
|
|
</div>
|
|
<button type="submit" className="sm:col-span-3 sm:w-auto sm:justify-self-end px-4 py-2 text-sm font-medium text-white bg-sky-500 hover:bg-sky-600 rounded-md transition-colors flex items-center justify-center"><PlusCircle size={18} className="mr-1" /> Add Character</button>
|
|
</form>
|
|
{campaignCharacters.length === 0 && <p className="text-sm text-slate-400">No characters added.</p>}
|
|
<ul className="space-y-2">
|
|
{campaignCharacters.map(character => (
|
|
<li key={character.id} className="flex justify-between items-center p-3 bg-slate-700 rounded-md">
|
|
{editingCharacter && editingCharacter.id === character.id ? (
|
|
<form onSubmit={(e) => {e.preventDefault(); handleUpdateCharacter(character.id, editingCharacter.name, editingCharacter.defaultMaxHp, editingCharacter.defaultInitMod);}} className="flex-grow flex flex-wrap gap-2 items-center">
|
|
<input type="text" value={editingCharacter.name} onChange={(e) => setEditingCharacter({...editingCharacter, name: e.target.value})} className="flex-grow min-w-[100px] px-2 py-1 bg-slate-600 border border-slate-500 rounded-md text-white"/>
|
|
<input type="number" value={editingCharacter.defaultMaxHp} onChange={(e) => setEditingCharacter({...editingCharacter, defaultMaxHp: e.target.value})} className="w-20 px-2 py-1 bg-slate-600 border border-slate-500 rounded-md text-white" title="Default Max HP"/>
|
|
<input type="number" value={editingCharacter.defaultInitMod} onChange={(e) => setEditingCharacter({...editingCharacter, defaultInitMod: e.target.value})} className="w-20 px-2 py-1 bg-slate-600 border border-slate-500 rounded-md text-white" title="Default Init Mod"/>
|
|
<button type="submit" className="p-1 text-green-400 hover:text-green-300"><Save size={18}/></button>
|
|
<button type="button" onClick={() => setEditingCharacter(null)} className="p-1 text-slate-400 hover:text-slate-200"><XCircle size={18}/></button>
|
|
</form>
|
|
) : (
|
|
<>
|
|
<span className="text-slate-100">{character.name} <span className="text-xs text-slate-400">(HP: {character.defaultMaxHp || 'N/A'}, Init Mod: {character.defaultInitMod !== undefined ? (character.defaultInitMod >= 0 ? `+${character.defaultInitMod}` : character.defaultInitMod) : 'N/A'})</span></span>
|
|
<div className="flex space-x-2">
|
|
<button onClick={() => setEditingCharacter({id: character.id, name: character.name, defaultMaxHp: character.defaultMaxHp || 10, defaultInitMod: character.defaultInitMod || 0})} 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={() => 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>
|
|
</>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</>
|
|
)}
|
|
</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, CreateEncounterForm, ParticipantManager, EditParticipantModal, InitiativeControls, DisplayView, Modal, Icons)
|
|
// The rest of the components are assumed to be the same as v0.1.30 for this update.
|
|
|
|
function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharacters }) {
|
|
const {data: encountersData, isLoading: isLoadingEncounters } = useFirestoreCollection(campaignId ? getEncountersCollectionPath(campaignId) : null);
|
|
const {data: activeDisplayInfoFromHook } = 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);
|
|
const selectedEncounterIdRef = useRef(selectedEncounterId);
|
|
useEffect(() => { if(encountersData) setEncounters(encountersData); }, [encountersData]);
|
|
useEffect(() => { selectedEncounterIdRef.current = selectedEncounterId; }, [selectedEncounterId]);
|
|
useEffect(() => {
|
|
if (!campaignId) { setSelectedEncounterId(null); return; }
|
|
if (encounters && encounters.length > 0) {
|
|
const currentSelection = selectedEncounterIdRef.current;
|
|
if (currentSelection === null || !encounters.some(e => e.id === currentSelection)) {
|
|
if (initialActiveEncounterId && encounters.some(e => e.id === initialActiveEncounterId)) { setSelectedEncounterId(initialActiveEncounterId); }
|
|
else if (activeDisplayInfoFromHook && activeDisplayInfoFromHook.activeCampaignId === campaignId && encounters.some(e => e.id === activeDisplayInfoFromHook.activeEncounterId)) { setSelectedEncounterId(activeDisplayInfoFromHook.activeEncounterId); }
|
|
}
|
|
} else if (encounters && encounters.length === 0) { setSelectedEncounterId(null); }
|
|
}, [campaignId, initialActiveEncounterId, activeDisplayInfoFromHook, encounters]);
|
|
const handleCreateEncounter = async (name) => {
|
|
if (!db ||!name.trim() || !campaignId) return;
|
|
const newEncounterId = generateId();
|
|
try { await setDoc(doc(db, getEncountersCollectionPath(campaignId), newEncounterId), { name: name.trim(), createdAt: new Date().toISOString(), participants: [], round: 0, currentTurnParticipantId: null, isStarted: false, isPaused: false }); setShowCreateEncounterModal(false); setSelectedEncounterId(newEncounterId); } catch (err) { console.error("Error creating encounter:", err); }
|
|
};
|
|
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); if (activeDisplayInfoFromHook && activeDisplayInfoFromHook.activeEncounterId === encounterId) { 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) => {
|
|
if (!db) return;
|
|
try {
|
|
const currentActiveCampaign = activeDisplayInfoFromHook?.activeCampaignId;
|
|
const currentActiveEncounter = activeDisplayInfoFromHook?.activeEncounterId;
|
|
if (currentActiveCampaign === campaignId && currentActiveEncounter === encounterId) { await setDoc(doc(db, getActiveDisplayDocPath()), { activeCampaignId: null, activeEncounterId: null, }, { merge: true }); console.log("Player Display for this encounter turned OFF."); }
|
|
else { await setDoc(doc(db, getActiveDisplayDocPath()), { activeCampaignId: campaignId, activeEncounterId: encounterId, }, { merge: true }); console.log("Encounter set as active for Player Display!"); }
|
|
} catch (err) { console.error("Error toggling Player Display for encounter:", err); }
|
|
};
|
|
const selectedEncounter = encounters?.find(e => e.id === selectedEncounterId);
|
|
if (isLoadingEncounters && campaignId) { return <p className="text-center text-slate-300 mt-4">Loading encounters...</p>; }
|
|
return (
|
|
<>
|
|
<div className="mt-6 p-4 bg-slate-800 rounded-lg shadow">
|
|
<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>
|
|
<button onClick={() => setShowCreateEncounterModal(true)} className="px-4 py-2 text-sm font-medium text-white bg-orange-500 hover:bg-orange-600 rounded-md transition-colors flex items-center"><PlusCircle size={18} className="mr-1" /> Create Encounter</button>
|
|
</div>
|
|
{(!encounters || encounters.length === 0) && <p className="text-sm text-slate-400">No encounters yet.</p>}
|
|
<div className="space-y-3">
|
|
{encounters?.map(encounter => {
|
|
const isLiveOnPlayerDisplay = activeDisplayInfoFromHook && activeDisplayInfoFromHook.activeCampaignId === campaignId && activeDisplayInfoFromHook.activeEncounterId === encounter.id;
|
|
return (
|
|
<div key={encounter.id} className={`p-3 rounded-md shadow transition-all ${selectedEncounterId === encounter.id ? 'bg-sky-600 ring-2 ring-sky-400' : 'bg-slate-700 hover:bg-slate-650'} ${isLiveOnPlayerDisplay ? 'ring-2 ring-green-500 shadow-md shadow-green-500/30' : ''}`}>
|
|
<div className="flex justify-between items-center">
|
|
<div onClick={() => setSelectedEncounterId(encounter.id)} className="cursor-pointer flex-grow">
|
|
<h4 className="font-medium text-white">{encounter.name}</h4>
|
|
<p className="text-xs text-slate-300">Participants: {encounter.participants?.length || 0}</p>
|
|
{isLiveOnPlayerDisplay && <span className="text-xs text-green-400 font-semibold block mt-1">LIVE ON PLAYER DISPLAY</span>}
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<button onClick={() => handleTogglePlayerDisplayForEncounter(encounter.id)} className={`p-1 rounded transition-colors ${isLiveOnPlayerDisplay ? 'bg-red-500 hover:bg-red-600 text-white' : 'text-teal-400 hover:text-teal-300 bg-slate-600 hover:bg-slate-500'}`} title={isLiveOnPlayerDisplay ? "Deactivate for Player Display" : "Activate for Player Display"}>{isLiveOnPlayerDisplay ? <EyeOff size={18} /> : <Eye 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>
|
|
{showCreateEncounterModal && <Modal onClose={() => setShowCreateEncounterModal(false)} title="Create New Encounter"><CreateEncounterForm onCreate={handleCreateEncounter} onCancel={() => setShowCreateEncounterModal(false)} /></Modal>}
|
|
{selectedEncounter && (
|
|
<div className="mt-6 p-4 bg-slate-750 rounded-lg shadow-inner">
|
|
<h3 className="text-xl font-semibold text-amber-300 mb-3">Managing Encounter: {selectedEncounter.name}</h3>
|
|
<ParticipantManager encounter={selectedEncounter} encounterPath={getEncounterDocPath(campaignId, selectedEncounter.id)} campaignCharacters={campaignCharacters} />
|
|
<InitiativeControls campaignId={campaignId} encounter={selectedEncounter} encounterPath={getEncounterDocPath(campaignId, selectedEncounter.id)} />
|
|
</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.`}/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function CreateEncounterForm({ onCreate, onCancel }) {
|
|
const [name, setName] = useState('');
|
|
return (
|
|
<form onSubmit={(e) => { e.preventDefault(); onCreate(name); }} className="space-y-4">
|
|
<div>
|
|
<label htmlFor="encounterName" className="block text-sm font-medium text-slate-300">Encounter Name</label>
|
|
<input type="text" id="encounterName" value={name} onChange={(e) => setName(e.target.value)} className="mt-1 block w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-md shadow-sm focus:outline-none focus:ring-sky-500 focus:border-sky-500 sm:text-sm text-white" required />
|
|
</div>
|
|
<div className="flex justify-end space-x-3">
|
|
<button type="button" onClick={onCancel} 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 type="submit" className="px-4 py-2 text-sm font-medium text-white bg-orange-500 hover:bg-orange-600 rounded-md transition-colors">Create</button>
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
|
|
const [participantName, setParticipantName] = useState('');
|
|
const [participantType, setParticipantType] = useState('monster');
|
|
const [selectedCharacterId, setSelectedCharacterId] = useState('');
|
|
const [monsterInitMod, setMonsterInitMod] = useState(2);
|
|
const [maxHp, setMaxHp] = useState(10);
|
|
const [isNpc, setIsNpc] = useState(false);
|
|
const [editingParticipant, setEditingParticipant] = useState(null);
|
|
const [hpChangeValues, setHpChangeValues] = useState({});
|
|
const [draggedItemId, setDraggedItemId] = useState(null);
|
|
const [showDeleteParticipantConfirm, setShowDeleteParticipantConfirm] = useState(false);
|
|
const [itemToDelete, setItemToDelete] = useState(null);
|
|
const [lastRollDetails, setLastRollDetails] = useState(null);
|
|
const participants = encounter.participants || [];
|
|
const MONSTER_DEFAULT_INIT_MOD = 2;
|
|
|
|
useEffect(() => {
|
|
if (participantType === 'character' && selectedCharacterId) {
|
|
const selectedChar = campaignCharacters.find(c => c.id === selectedCharacterId);
|
|
if (selectedChar && selectedChar.defaultMaxHp) { setMaxHp(selectedChar.defaultMaxHp); } else { setMaxHp(10); }
|
|
setIsNpc(false);
|
|
} else if (participantType === 'monster') {
|
|
setMaxHp(10);
|
|
setMonsterInitMod(MONSTER_DEFAULT_INIT_MOD);
|
|
}
|
|
}, [selectedCharacterId, participantType, campaignCharacters]);
|
|
|
|
const handleAddParticipant = async () => {
|
|
if (!db || (participantType === 'monster' && !participantName.trim()) || (participantType === 'character' && !selectedCharacterId)) return;
|
|
let nameToAdd = participantName.trim();
|
|
const initiativeRoll = rollD20();
|
|
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);
|
|
if (!character) { console.error("Selected character not found"); return; }
|
|
if (participants.some(p => p.type === 'character' && p.originalCharacterId === selectedCharacterId)) { alert(`${character.name} is already in this encounter.`); return; }
|
|
nameToAdd = character.name;
|
|
currentMaxHp = character.defaultMaxHp || currentMaxHp;
|
|
modifier = character.defaultInitMod || 0;
|
|
finalInitiative = initiativeRoll + modifier;
|
|
} else {
|
|
modifier = parseInt(monsterInitMod, 10) || 0;
|
|
finalInitiative = initiativeRoll + modifier;
|
|
participantIsNpc = isNpc;
|
|
}
|
|
const newParticipant = {
|
|
id: generateId(), name: nameToAdd, type: participantType,
|
|
originalCharacterId: participantType === 'character' ? selectedCharacterId : null,
|
|
initiative: finalInitiative, maxHp: currentMaxHp, currentHp: currentMaxHp,
|
|
isNpc: participantType === 'monster' ? participantIsNpc : false,
|
|
conditions: [], isActive: true,
|
|
};
|
|
try {
|
|
await updateDoc(doc(db, encounterPath), { participants: [...participants, newParticipant] });
|
|
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); 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;
|
|
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 };
|
|
});
|
|
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 updatedParticipants = participants.map(p => p.id === editingParticipant.id ? { ...p, ...updatedData } : p );
|
|
try { await updateDoc(doc(db, encounterPath), { participants: updatedParticipants }); setEditingParticipant(null); } catch (err) { console.error("Error updating participant:", err); }
|
|
};
|
|
const 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) => {
|
|
if (!db) return;
|
|
const pToToggle = participants.find(p => p.id === participantId);
|
|
if (!pToToggle) return;
|
|
const updatedPs = participants.map(p => p.id === participantId ? { ...p, isActive: !p.isActive } : p);
|
|
try { await updateDoc(doc(db, encounterPath), { participants: updatedPs }); } catch (err) { console.error("Error toggling active state:", err); }
|
|
};
|
|
const handleHpInputChange = (participantId, value) => setHpChangeValues(prev => ({ ...prev, [participantId]: value }));
|
|
const applyHpChange = async (participantId, changeType) => {
|
|
if (!db) return;
|
|
const amountStr = hpChangeValues[participantId];
|
|
if (amountStr === undefined || amountStr.trim() === '') return;
|
|
const amount = parseInt(amountStr, 10);
|
|
if (isNaN(amount) || amount === 0) { setHpChangeValues(prev => ({ ...prev, [participantId]: '' })); return; }
|
|
const pToChange = participants.find(p => p.id === participantId);
|
|
if (!pToChange) return;
|
|
let newHp = pToChange.currentHp;
|
|
if (changeType === 'damage') newHp = Math.max(0, pToChange.currentHp - amount);
|
|
else if (changeType === 'heal') newHp = Math.min(pToChange.maxHp, pToChange.currentHp + amount);
|
|
const updatedPs = participants.map(p => p.id === participantId ? { ...p, currentHp: newHp } : p);
|
|
try { await updateDoc(doc(db, encounterPath), { participants: updatedPs }); setHpChangeValues(prev => ({ ...prev, [participantId]: '' })); } catch (err) { console.error("Error applying HP change:", err); }
|
|
};
|
|
const handleDragStart = (e, id) => { setDraggedItemId(id); e.dataTransfer.effectAllowed = 'move'; };
|
|
const handleDragOver = (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; };
|
|
const handleDrop = async (e, targetId) => {
|
|
e.preventDefault();
|
|
if (!db || draggedItemId === null || draggedItemId === targetId) { setDraggedItemId(null); return; }
|
|
const currentParticipants = [...participants];
|
|
const draggedItemIndex = currentParticipants.findIndex(p => p.id === draggedItemId);
|
|
const targetItemIndex = currentParticipants.findIndex(p => p.id === targetId);
|
|
if (draggedItemIndex === -1 || targetItemIndex === -1) { console.error("Dragged or target item not found."); setDraggedItemId(null); return; }
|
|
const draggedItem = currentParticipants[draggedItemIndex];
|
|
const targetItem = currentParticipants[targetItemIndex];
|
|
if (draggedItem.initiative !== targetItem.initiative) { console.log("Drag-drop only for same initiative."); setDraggedItemId(null); return; }
|
|
const [removedItem] = currentParticipants.splice(draggedItemIndex, 1);
|
|
currentParticipants.splice(targetItemIndex, 0, removedItem);
|
|
try { await updateDoc(doc(db, encounterPath), { participants: currentParticipants }); console.log("Participants reordered."); } catch (err) { console.error("Error D&D update:", err); }
|
|
setDraggedItemId(null);
|
|
};
|
|
const handleDragEnd = () => { setDraggedItemId(null); };
|
|
const sortedAdminParticipants = [...participants].sort((a, b) => {
|
|
if (a.initiative === b.initiative) { const indexA = participants.findIndex(p => p.id === a.id); const indexB = participants.findIndex(p => p.id === b.id); return indexA - indexB; }
|
|
return b.initiative - a.initiative;
|
|
});
|
|
const initiativeGroups = participants.reduce((acc, p) => { acc[p.initiative] = (acc[p.initiative] || 0) + 1; return acc; }, {});
|
|
const tiedInitiatives = Object.keys(initiativeGroups).filter(init => initiativeGroups[init] > 1).map(Number);
|
|
return (
|
|
<>
|
|
<div className="p-3 bg-slate-800 rounded-md mt-4">
|
|
<div className="flex justify-between items-center mb-3">
|
|
<h4 className="text-lg font-medium text-sky-200">Add Participants</h4>
|
|
<button onClick={handleAddAllCampaignCharacters} className="px-3 py-1.5 text-xs font-medium text-white bg-indigo-600 hover:bg-indigo-700 rounded-md transition-colors flex items-center" disabled={!campaignCharacters || campaignCharacters.length === 0}>
|
|
<Users2 size={16} className="mr-1.5"/><Dices size={16} className="mr-1.5"/> Add All (Roll Init)
|
|
</button>
|
|
</div>
|
|
<form onSubmit={(e) => { e.preventDefault(); handleAddParticipant(); }} className="grid grid-cols-1 md:grid-cols-6 gap-x-4 gap-y-2 mb-4 p-3 bg-slate-700 rounded items-end">
|
|
<div className="md:col-span-2">
|
|
<label className="block text-sm font-medium text-slate-300">Type</label>
|
|
<select value={participantType} onChange={(e) => {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">
|
|
<option value="monster">Monster</option>
|
|
<option value="character">Character</option>
|
|
</select>
|
|
</div>
|
|
|
|
{participantType === 'monster' && (
|
|
<>
|
|
<div className="md:col-span-4">
|
|
<label htmlFor="monsterName" className="block text-sm font-medium text-slate-300">Monster Name</label>
|
|
<input type="text" id="monsterName" value={participantName} onChange={(e) => 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" />
|
|
</div>
|
|
<div className="md:col-span-2">
|
|
<label htmlFor="monsterInitMod" className="block text-sm font-medium text-slate-300">Monster Init Mod</label>
|
|
<input type="number" id="monsterInitMod" value={monsterInitMod} onChange={(e) => 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" />
|
|
</div>
|
|
<div className="md:col-span-2">
|
|
<label htmlFor="monsterMaxHp" className="block text-sm font-medium text-slate-300">Max HP</label>
|
|
<input type="number" id="monsterMaxHp" value={maxHp} onChange={(e) => setMaxHp(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" />
|
|
</div>
|
|
<div className="md:col-span-2 flex items-center pt-5">
|
|
<input type="checkbox" id="isNpc" checked={isNpc} onChange={(e) => setIsNpc(e.target.checked)} className="h-4 w-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500" />
|
|
<label htmlFor="isNpc" className="ml-2 block text-sm text-slate-300">Is NPC?</label>
|
|
</div>
|
|
</>
|
|
)}
|
|
{participantType === 'character' && (
|
|
<>
|
|
<div className="md:col-span-4">
|
|
<label className="block text-sm font-medium text-slate-300">Select Character</label>
|
|
<select value={selectedCharacterId} onChange={(e) => setSelectedCharacterId(e.target.value)} className="mt-1 w-full px-3 py-2 bg-slate-600 border-slate-500 rounded text-white">
|
|
<option value="">-- Select from Campaign --</option>
|
|
{campaignCharacters.map(c => <option key={c.id} value={c.id}>{c.name} (HP: {c.defaultMaxHp || 'N/A'}, Mod: {c.defaultInitMod >=0 ? `+${c.defaultInitMod}` : c.defaultInitMod})</option>)}
|
|
</select>
|
|
</div>
|
|
<div className="md:col-span-2">
|
|
<label className="block text-sm font-medium text-slate-300">Max HP (Encounter)</label>
|
|
<input type="number" value={maxHp} onChange={(e) => setMaxHp(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" />
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
<div className="md:col-span-6 flex justify-end mt-2">
|
|
<button type="submit" className="px-4 py-2 text-sm font-medium text-white bg-green-500 hover:bg-green-600 rounded-md transition-colors flex items-center">
|
|
<Dices size={18} className="mr-1.5" /> Add to Encounter (Roll Init)
|
|
</button>
|
|
</div>
|
|
</form>
|
|
{lastRollDetails && (
|
|
<p className="text-sm text-green-400 mt-2 mb-2 text-center">
|
|
{lastRollDetails.name} ({lastRollDetails.type === 'character' ? 'Character' : lastRollDetails.type}): Rolled D20 ({lastRollDetails.roll}) {lastRollDetails.mod >= 0 ? '+' : ''} {lastRollDetails.mod} (mod) = {lastRollDetails.total} Initiative
|
|
</p>
|
|
)}
|
|
{participants.length === 0 && <p className="text-sm text-slate-400">No participants.</p>}
|
|
<ul className="space-y-2">
|
|
{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-[#8e351c]');
|
|
if (isCurrentTurn && !encounter.isPaused) bgColor = 'bg-green-600';
|
|
|
|
return (
|
|
<li key={p.id} draggable={isDraggable} onDragStart={isDraggable ? (e) => 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 ${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' : ''}`}>
|
|
<div className="flex-1 flex items-center">
|
|
{isDraggable && <ChevronsUpDown size={18} className="mr-2 text-slate-400 flex-shrink-0" title="Drag to reorder in tie"/>}
|
|
<div>
|
|
<p className={`font-semibold text-lg text-white`}>{p.name} <span className="text-xs">({participantDisplayType})</span>{isCurrentTurn && !encounter.isPaused && <span className="ml-2 px-2 py-0.5 bg-yellow-400 text-black text-xs font-bold rounded-full inline-flex items-center"><Zap size={12} className="mr-1"/> CURRENT</span>}</p>
|
|
<p className={`text-sm ${isCurrentTurn && !encounter.isPaused ? 'text-green-100' : 'text-slate-200'}`}>Init: {p.initiative} | HP: {p.currentHp}/{p.maxHp}</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-wrap items-center space-x-2 mt-2 sm:mt-0">
|
|
{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={() => 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={() => 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>
|
|
</li>);
|
|
})}
|
|
</ul>
|
|
{editingParticipant && <EditParticipantModal participant={editingParticipant} onClose={() => setEditingParticipant(null)} onSave={handleUpdateParticipant} />}
|
|
</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?`}/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
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 [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,
|
|
});
|
|
};
|
|
return (
|
|
<Modal onClose={onClose} title={`Edit ${participant.name}`}>
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
<div><label className="block text-sm font-medium text-slate-300">Name</label><input type="text" value={name} onChange={(e) => setName(e.target.value)} className="mt-1 block w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-md shadow-sm focus:outline-none focus:ring-sky-500 focus:border-sky-500 sm:text-sm text-white" /></div>
|
|
<div><label className="block text-sm font-medium text-slate-300">Initiative</label><input type="number" value={initiative} onChange={(e) => setInitiative(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" /></div>
|
|
<div className="flex gap-4">
|
|
<div className="flex-1"><label className="block text-sm font-medium text-slate-300">Current HP</label><input type="number" value={currentHp} onChange={(e) => 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" /></div>
|
|
<div className="flex-1"><label className="block text-sm font-medium text-slate-300">Max HP</label><input type="number" value={maxHp} onChange={(e) => 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" /></div>
|
|
</div>
|
|
{participant.type === 'monster' && (
|
|
<div className="flex items-center">
|
|
<input type="checkbox" id="editIsNpc" checked={isNpc} onChange={(e) => setIsNpc(e.target.checked)} className="h-4 w-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500"/>
|
|
<label htmlFor="editIsNpc" className="ml-2 block text-sm text-slate-300">Is NPC?</label>
|
|
</div>
|
|
)}
|
|
<div className="flex justify-end space-x-3 pt-2">
|
|
<button type="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 type="submit" className="px-4 py-2 text-sm font-medium text-white bg-sky-500 hover:bg-sky-600 rounded-md transition-colors"><Save size={18} className="mr-1 inline-block" /> Save</button>
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
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);
|
|
if (activePs.length === 0) { alert("No active participants."); return; }
|
|
const sortedPs = [...activePs].sort((a, b) => {
|
|
if (a.initiative === b.initiative) { const indexA = encounter.participants.findIndex(p => p.id === a.id); const indexB = encounter.participants.findIndex(p => p.id === b.id); return indexA - indexB; }
|
|
return b.initiative - a.initiative;
|
|
});
|
|
try {
|
|
await updateDoc(doc(db, encounterPath), { isStarted: true, isPaused: false, round: 1, currentTurnParticipantId: sortedPs[0].id, turnOrderIds: sortedPs.map(p => p.id) });
|
|
await setDoc(doc(db, getActiveDisplayDocPath()), { activeCampaignId: campaignId, activeEncounterId: encounter.id }, { merge: true });
|
|
console.log("Encounter started and set as active display.");
|
|
} catch (err) { console.error("Error starting encounter:", err); }
|
|
};
|
|
const handleTogglePause = async () => {
|
|
if (!db || !encounter || !encounter.isStarted) return;
|
|
const newPausedState = !encounter.isPaused;
|
|
let newTurnOrderIds = encounter.turnOrderIds;
|
|
if (!newPausedState && encounter.isPaused) {
|
|
const activeParticipants = encounter.participants.filter(p => p.isActive);
|
|
const sortedActiveParticipants = [...activeParticipants].sort((a, b) => {
|
|
if (a.initiative === b.initiative) { const indexA = encounter.participants.findIndex(p => p.id === a.id); const indexB = encounter.participants.findIndex(p => p.id === b.id); return indexA - indexB; }
|
|
return b.initiative - a.initiative;
|
|
});
|
|
newTurnOrderIds = sortedActiveParticipants.map(p => p.id);
|
|
}
|
|
try { await updateDoc(doc(db, encounterPath), { isPaused: newPausedState, turnOrderIds: newTurnOrderIds }); console.log(`Encounter ${newPausedState ? 'paused' : 'resumed'}.`); } catch (err) { console.error("Error toggling pause state:", err); }
|
|
};
|
|
const handleNextTurn = async () => {
|
|
if (!db ||!encounter.isStarted || encounter.isPaused || !encounter.currentTurnParticipantId || !encounter.turnOrderIds || encounter.turnOrderIds.length === 0) return;
|
|
const activePsInOrder = encounter.turnOrderIds.map(id => encounter.participants.find(p => p.id === id && p.isActive)).filter(Boolean);
|
|
if (activePsInOrder.length === 0) { alert("No active participants left."); await updateDoc(doc(db, encounterPath), { isStarted: false, isPaused: false, currentTurnParticipantId: null, round: encounter.round }); return; }
|
|
const currentIndex = activePsInOrder.findIndex(p => p.id === encounter.currentTurnParticipantId);
|
|
let nextIndex = (currentIndex + 1) % activePsInOrder.length;
|
|
let nextRound = encounter.round;
|
|
if (nextIndex === 0 && currentIndex !== -1) nextRound += 1;
|
|
try { await updateDoc(doc(db, encounterPath), { currentTurnParticipantId: activePsInOrder[nextIndex].id, round: nextRound }); } catch (err) { console.error("Error advancing turn:", err); }
|
|
};
|
|
const requestEndEncounter = () => { setShowEndEncounterConfirm(true); };
|
|
const confirmEndEncounter = async () => {
|
|
if (!db) return;
|
|
try {
|
|
await updateDoc(doc(db, encounterPath), { isStarted: false, isPaused: false, currentTurnParticipantId: null, round: 0, turnOrderIds: [] });
|
|
await setDoc(doc(db, getActiveDisplayDocPath()), { activeCampaignId: null, activeEncounterId: null }, { 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 (
|
|
<>
|
|
<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>
|
|
<div className="flex flex-wrap gap-3">
|
|
{!encounter.isStarted ? (
|
|
<button onClick={handleStartEncounter} className="px-4 py-2 text-sm font-medium text-white bg-green-600 hover:bg-green-700 rounded-md transition-colors flex items-center" disabled={!encounter.participants || encounter.participants.filter(p=>p.isActive).length === 0}><PlayIcon size={18} className="mr-2" /> Start</button>
|
|
) : (
|
|
<>
|
|
<button onClick={handleTogglePause} className={`px-4 py-2 text-sm font-medium text-white rounded-md transition-colors flex items-center ${encounter.isPaused ? 'bg-green-500 hover:bg-green-600' : 'bg-yellow-500 hover:bg-yellow-600'}`}>
|
|
{encounter.isPaused ? <PlayIcon size={18} className="mr-2" /> : <PauseIcon size={18} className="mr-2" />}
|
|
{encounter.isPaused ? 'Resume' : 'Pause'}
|
|
</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 || encounter.isPaused}><SkipForwardIcon size={18} className="mr-2" /> Next Turn</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>
|
|
{encounter.isPaused && <p className="text-yellow-400 font-semibold self-center">(Paused)</p>}
|
|
</>
|
|
)}
|
|
</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.`}/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function DisplayView() {
|
|
const { data: activeDisplayData, isLoading: isLoadingActiveDisplay, error: activeDisplayError } = useFirestoreDocument(getActiveDisplayDocPath());
|
|
const [activeEncounterData, setActiveEncounterData] = useState(null);
|
|
const [isLoadingEncounter, setIsLoadingEncounter] = useState(true);
|
|
const [encounterError, setEncounterError] = useState(null);
|
|
const [campaignBackgroundUrl, setCampaignBackgroundUrl] = useState('');
|
|
const [isPlayerDisplayActive, setIsPlayerDisplayActive] = useState(false);
|
|
useEffect(() => {
|
|
if (!db) { setEncounterError("Firestore not available."); setIsLoadingEncounter(false); setIsPlayerDisplayActive(false); return; }
|
|
let unsubscribeEncounter;
|
|
let unsubscribeCampaign;
|
|
if (activeDisplayData) {
|
|
const { activeCampaignId, activeEncounterId } = activeDisplayData;
|
|
if (activeCampaignId && activeEncounterId) {
|
|
setIsPlayerDisplayActive(true); setIsLoadingEncounter(true); setEncounterError(null);
|
|
const campaignDocRef = doc(db, getCampaignDocPath(activeCampaignId));
|
|
unsubscribeCampaign = onSnapshot(campaignDocRef, (campSnap) => { if (campSnap.exists()) { setCampaignBackgroundUrl(campSnap.data().playerDisplayBackgroundUrl || ''); } else { setCampaignBackgroundUrl(''); } }, (err) => console.error("Error fetching campaign background:", err));
|
|
const encounterPath = getEncounterDocPath(activeCampaignId, activeEncounterId);
|
|
unsubscribeEncounter = onSnapshot(doc(db, encounterPath), (encDocSnap) => {
|
|
if (encDocSnap.exists()) { setActiveEncounterData({ id: encDocSnap.id, ...encDocSnap.data() }); }
|
|
else { setActiveEncounterData(null); setEncounterError("Active encounter data not found."); }
|
|
setIsLoadingEncounter(false);
|
|
}, (err) => { console.error("Error fetching active encounter details:", err); setEncounterError("Error loading active encounter data."); setIsLoadingEncounter(false);});
|
|
} else { setActiveEncounterData(null); setCampaignBackgroundUrl(''); setIsPlayerDisplayActive(false); setIsLoadingEncounter(false); }
|
|
} else if (!isLoadingActiveDisplay) { setActiveEncounterData(null); setCampaignBackgroundUrl(''); setIsPlayerDisplayActive(false); setIsLoadingEncounter(false); }
|
|
return () => { if (unsubscribeEncounter) unsubscribeEncounter(); if (unsubscribeCampaign) unsubscribeCampaign(); };
|
|
}, [activeDisplayData, isLoadingActiveDisplay]);
|
|
if (isLoadingActiveDisplay || (isPlayerDisplayActive && isLoadingEncounter)) { return <div className="text-center py-10 text-2xl text-slate-300">Loading Player Display...</div>; }
|
|
if (activeDisplayError || (isPlayerDisplayActive && encounterError)) { return <div className="text-center py-10 text-2xl text-red-400">{activeDisplayError || encounterError}</div>; }
|
|
if (!isPlayerDisplayActive || !activeEncounterData) {
|
|
return (
|
|
<div className="min-h-screen bg-black text-slate-400 flex flex-col items-center justify-center p-4 text-center">
|
|
<EyeOff size={64} className="mb-4 text-slate-500" />
|
|
<h2 className="text-3xl font-semibold">Game Session Paused</h2>
|
|
<p className="text-xl mt-2">The Dungeon Master has not activated an encounter for display.</p>
|
|
</div>);
|
|
}
|
|
const { name, participants, round, currentTurnParticipantId, isStarted, isPaused } = activeEncounterData;
|
|
let participantsToRender = [];
|
|
if (participants) {
|
|
const activeParticipants = participants.filter(p => p.isActive);
|
|
participantsToRender = [...activeParticipants].sort((a, b) => {
|
|
if (a.initiative === b.initiative) {
|
|
const indexA = participants.findIndex(p => p.id === a.id);
|
|
const indexB = participants.findIndex(p => p.id === b.id);
|
|
return indexA - indexB;
|
|
}
|
|
return b.initiative - a.initiative;
|
|
});
|
|
}
|
|
const displayStyles = campaignBackgroundUrl ? { backgroundImage: `url(${campaignBackgroundUrl})`, backgroundSize: 'cover', backgroundPosition: 'center center', backgroundRepeat: 'no-repeat', minHeight: '100vh' } : { minHeight: '100vh' };
|
|
return (
|
|
<div className={`p-4 md:p-8 rounded-xl shadow-2xl ${!campaignBackgroundUrl ? 'bg-slate-900' : ''}`} style={displayStyles}>
|
|
<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>
|
|
{isStarted && <p className="text-2xl text-center text-sky-300 mb-1">Round: {round}</p>}
|
|
{isStarted && isPaused && <p className="text-xl text-center text-yellow-400 mb-4 font-semibold">(Combat Paused)</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>}
|
|
{participantsToRender.length === 0 && isStarted && <p className="text-xl text-slate-400">No active participants.</p>}
|
|
<div className="space-y-4 max-w-3xl mx-auto">
|
|
{participantsToRender.map(p => {
|
|
let participantBgColor = p.type === 'monster' ? (p.isNpc ? 'bg-slate-700' : 'bg-[#8e351c]') : 'bg-sky-700';
|
|
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 (
|
|
<div key={p.id} className={`p-4 md:p-6 rounded-lg shadow-lg transition-all ${participantBgColor} ${!p.isActive ? 'opacity-40 grayscale' : ''}`}>
|
|
<div className="flex justify-between items-center mb-2">
|
|
<h3 className={`text-2xl md:text-3xl font-bold ${p.id === currentTurnParticipantId && isStarted && !isPaused ? 'text-white' : (p.type === 'character' ? 'text-sky-100' : (p.isNpc ? 'text-slate-100' : 'text-white'))}`}>{p.name}{p.id === currentTurnParticipantId && isStarted && !isPaused && <span className="text-yellow-300 animate-pulse ml-2">(Current)</span>}</h3>
|
|
<span className={`text-xl md:text-2xl font-semibold ${p.id === currentTurnParticipantId && isStarted && !isPaused ? 'text-green-200' : 'text-slate-200'}`}>Init: {p.initiative}</span>
|
|
</div>
|
|
<div className="flex justify-between items-center">
|
|
<div className="w-full bg-slate-600 rounded-full h-6 md:h-8 relative overflow-hidden border-2 border-slate-500">
|
|
<div className={`h-full rounded-full transition-all ${p.currentHp <= p.maxHp / 4 ? 'bg-red-500' : (p.currentHp <= p.maxHp / 2 ? 'bg-yellow-500' : 'bg-green-500')}`} style={{ width: `${Math.max(0, (p.currentHp / p.maxHp) * 100)}%` }}></div>
|
|
{p.type !== 'monster' && ( <span className="absolute inset-0 flex items-center justify-center text-xs md:text-sm font-medium text-white px-2">HP: {p.currentHp} / {p.maxHp}</span>)}
|
|
</div>
|
|
</div>
|
|
{p.conditions?.length > 0 && <p className="text-sm text-yellow-300 mt-2">Conditions: {p.conditions.join(', ')}</p>}
|
|
{!p.isActive && <p className="text-center text-lg font-semibold text-slate-300 mt-2">(Inactive)</p>}
|
|
</div>);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>);
|
|
}
|
|
|
|
function Modal({ onClose, title, children }) {
|
|
useEffect(() => {
|
|
const handleEsc = (event) => { if (event.key === 'Escape') onClose(); };
|
|
window.addEventListener('keydown', handleEsc);
|
|
return () => window.removeEventListener('keydown', handleEsc);
|
|
}, [onClose]);
|
|
return (
|
|
<div className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center p-4 z-50">
|
|
<div className="bg-slate-800 p-6 rounded-lg shadow-xl w-full max-w-md">
|
|
<div className="flex justify-between items-center mb-4">
|
|
<h2 className="text-xl font-semibold text-sky-300">{title}</h2>
|
|
<button onClick={onClose} className="text-slate-400 hover:text-slate-200"><XCircle size={24} /></button>
|
|
</div>
|
|
{children}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// --- Icons ---
|
|
// PlayIcon, SkipForwardIcon, StopCircleIcon are now imported from lucide-react at the top
|
|
|
|
export default App;
|