More interations.
This commit is contained in:
parent
48bc3447b5
commit
0772a3a9e6
10
.gitignore
vendored
10
.gitignore
vendored
@ -1 +1,9 @@
|
||||
.env*
|
||||
# .gitignore
|
||||
node_modules
|
||||
build
|
||||
dist
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
946
App.js
Normal file
946
App.js
Normal file
@ -0,0 +1,946 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { initializeApp } from 'firebase/app';
|
||||
import { getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken } from 'firebase/auth';
|
||||
import { getFirestore, doc, setDoc, addDoc, getDoc, getDocs, collection, onSnapshot, updateDoc, deleteDoc, query, where, writeBatch } from 'firebase/firestore';
|
||||
import { ArrowLeft, PlusCircle, Users, Swords, Shield, Trash2, Eye, Edit3, Save, XCircle, ChevronsUpDown, ChevronDown, ChevronUp, UserCheck, UserX, HeartCrack, HeartPulse, Zap, Share2, Copy as CopyIcon } from 'lucide-react';
|
||||
|
||||
// --- Firebase Configuration ---
|
||||
// Read from environment variables
|
||||
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
|
||||
};
|
||||
|
||||
// --- Initialize Firebase ---
|
||||
// Check if all necessary Firebase config values are present
|
||||
const requiredFirebaseConfigKeys = [
|
||||
'apiKey', 'authDomain', 'projectId', 'appId'
|
||||
// storageBucket and messagingSenderId might be optional depending on usage
|
||||
];
|
||||
const missingKeys = requiredFirebaseConfigKeys.filter(key => !firebaseConfig[key]);
|
||||
|
||||
let app;
|
||||
if (missingKeys.length > 0) {
|
||||
console.error(`Missing Firebase config keys from environment variables: ${missingKeys.join(', ')}`);
|
||||
console.warn("Firebase is not initialized. Please set up your .env.local file with the necessary REACT_APP_FIREBASE_... variables.");
|
||||
// You might want to render an error message or a fallback UI here
|
||||
} else {
|
||||
app = initializeApp(firebaseConfig);
|
||||
}
|
||||
|
||||
const db = app ? getFirestore(app) : null; // Conditionally get Firestore
|
||||
const auth = app ? getAuth(app) : null; // Conditionally get Auth
|
||||
|
||||
// --- Firestore Paths ---
|
||||
const APP_ID = process.env.REACT_APP_TRACKER_APP_ID || 'ttrpg-initiative-tracker-default';
|
||||
// ... rest of your code
|
||||
|
||||
// --- Helper Functions ---
|
||||
const generateId = () => crypto.randomUUID();
|
||||
|
||||
function getShareableLinkBase() {
|
||||
return window.location.origin + window.location.pathname;
|
||||
}
|
||||
|
||||
// --- Main App Component ---
|
||||
function App() {
|
||||
const [userId, setUserId] = useState(null);
|
||||
const [isAuthReady, setIsAuthReady] = useState(false);
|
||||
const [viewMode, setViewMode] = useState('admin');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [directDisplayParams, setDirectDisplayParams] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleHashChange = () => {
|
||||
const hash = window.location.hash;
|
||||
if (hash.startsWith('#/display/')) {
|
||||
const parts = hash.substring('#/display/'.length).split('/');
|
||||
if (parts.length === 2 && parts[0] && parts[1]) {
|
||||
setDirectDisplayParams({ campaignId: parts[0], encounterId: parts[1] });
|
||||
setViewMode('display');
|
||||
} else {
|
||||
setDirectDisplayParams(null);
|
||||
}
|
||||
} else {
|
||||
setDirectDisplayParams(null);
|
||||
}
|
||||
};
|
||||
window.addEventListener('hashchange', handleHashChange);
|
||||
handleHashChange();
|
||||
return () => window.removeEventListener('hashchange', handleHashChange);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const initAuth = async () => {
|
||||
try {
|
||||
if (typeof __initial_auth_token !== 'undefined' && __initial_auth_token) {
|
||||
await signInWithCustomToken(auth, __initial_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 (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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-800 text-slate-100 font-sans">
|
||||
{!directDisplayParams && (
|
||||
<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">
|
||||
{userId && <span className="text-xs text-slate-400">UID: {userId}</span>}
|
||||
{viewMode !== 'display' && ( // Only show Admin View button if not in display mode (when header is visible)
|
||||
<button
|
||||
onClick={() => { setViewMode('admin'); setDirectDisplayParams(null); window.location.hash = '';}}
|
||||
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${viewMode === 'admin' && !directDisplayParams ? 'bg-sky-500 text-white' : 'bg-slate-700 hover:bg-slate-600'}`}
|
||||
>
|
||||
Admin View
|
||||
</button>
|
||||
)}
|
||||
{viewMode !== 'admin' && ( // Only show Player Display button if not in admin mode
|
||||
<button
|
||||
onClick={() => { setViewMode('display'); setDirectDisplayParams(null); window.location.hash = '';}}
|
||||
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${viewMode === 'display' && !directDisplayParams ? 'bg-teal-500 text-white' : 'bg-slate-700 hover:bg-slate-600'}`}
|
||||
>
|
||||
Player Display
|
||||
</button>
|
||||
)}
|
||||
{/* If in admin mode, show player display button. If in player mode, show admin button (unless hidden above) */}
|
||||
{/* This logic seems a bit complex, let's simplify: always show both if header is visible, style active one */}
|
||||
{viewMode === 'admin' && ( // Show Player Display button if in Admin mode
|
||||
<button
|
||||
onClick={() => { setViewMode('display'); setDirectDisplayParams(null); window.location.hash = '';}}
|
||||
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors bg-slate-700 hover:bg-slate-600`}
|
||||
>
|
||||
Player Display
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
|
||||
<main className={`container mx-auto p-4 md:p-8 ${directDisplayParams ? 'pt-8' : ''}`}>
|
||||
{directDisplayParams && isAuthReady && (
|
||||
<DisplayView campaignIdFromUrl={directDisplayParams.campaignId} encounterIdFromUrl={directDisplayParams.encounterId} />
|
||||
)}
|
||||
{!directDisplayParams && viewMode === 'admin' && isAuthReady && userId && <AdminView userId={userId} />}
|
||||
{!directDisplayParams && viewMode === 'display' && isAuthReady && <DisplayView />}
|
||||
{!isAuthReady && <p>Authenticating...</p>}
|
||||
</main>
|
||||
{!directDisplayParams && (
|
||||
<footer className="bg-slate-900 p-4 text-center text-sm text-slate-400 mt-8">
|
||||
TTRPG Initiative Tracker v0.1.12
|
||||
</footer>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Admin View Component ---
|
||||
function AdminView({ userId }) {
|
||||
const [campaigns, setCampaigns] = useState([]);
|
||||
const [selectedCampaignId, setSelectedCampaignId] = useState(null);
|
||||
const [showCreateCampaignModal, setShowCreateCampaignModal] = useState(false);
|
||||
const [initialActiveInfo, setInitialActiveInfo] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const campaignsCollectionRef = collection(db, CAMPAIGNS_COLLECTION);
|
||||
const q = query(campaignsCollectionRef);
|
||||
const unsubscribeCampaigns = onSnapshot(q, (snapshot) => {
|
||||
setCampaigns(snapshot.docs.map(doc => ({ id: doc.id, ...doc.data(), characters: doc.data().players || [] })));
|
||||
}, (err) => console.error("Error fetching campaigns:", err));
|
||||
|
||||
const activeDisplayRef = doc(db, ACTIVE_DISPLAY_DOC);
|
||||
const unsubscribeActiveDisplay = onSnapshot(activeDisplayRef, (docSnap) => {
|
||||
if (docSnap.exists()) {
|
||||
setInitialActiveInfo(docSnap.data());
|
||||
} else {
|
||||
setInitialActiveInfo(null);
|
||||
}
|
||||
}, (err) => {
|
||||
console.error("Error fetching initial active display info for AdminView:", err);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribeCampaigns();
|
||||
unsubscribeActiveDisplay();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialActiveInfo && initialActiveInfo.activeCampaignId && campaigns.length > 0 && !selectedCampaignId) {
|
||||
const campaignExists = campaigns.some(c => c.id === initialActiveInfo.activeCampaignId);
|
||||
if (campaignExists) {
|
||||
setSelectedCampaignId(initialActiveInfo.activeCampaignId);
|
||||
}
|
||||
}
|
||||
}, [initialActiveInfo, campaigns, selectedCampaignId]);
|
||||
|
||||
|
||||
const handleCreateCampaign = async (name) => {
|
||||
if (!name.trim()) return;
|
||||
const newCampaignId = generateId();
|
||||
try {
|
||||
await setDoc(doc(db, CAMPAIGNS_COLLECTION, newCampaignId), {
|
||||
name: name.trim(), ownerId: userId, createdAt: new Date().toISOString(), players: [],
|
||||
});
|
||||
setShowCreateCampaignModal(false);
|
||||
setSelectedCampaignId(newCampaignId);
|
||||
} catch (err) { console.error("Error creating campaign:", err); }
|
||||
};
|
||||
|
||||
const handleDeleteCampaign = async (campaignId) => {
|
||||
// TODO: Implement custom confirmation modal for deleting campaigns
|
||||
console.warn("Attempting to delete campaign without confirmation:", campaignId);
|
||||
try {
|
||||
const encountersPath = `${CAMPAIGNS_COLLECTION}/${campaignId}/encounters`;
|
||||
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, CAMPAIGNS_COLLECTION, campaignId));
|
||||
if (selectedCampaignId === campaignId) setSelectedCampaignId(null);
|
||||
const activeDisplayRef = doc(db, ACTIVE_DISPLAY_DOC);
|
||||
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); }
|
||||
};
|
||||
|
||||
const selectedCampaign = campaigns.find(c => c.id === selectedCampaignId);
|
||||
|
||||
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>
|
||||
{campaigns.length === 0 && <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">
|
||||
{campaigns.map(campaign => (
|
||||
<div
|
||||
key={campaign.id}
|
||||
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>
|
||||
<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
|
||||
</button>
|
||||
</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={initialActiveInfo && initialActiveInfo.activeCampaignId === selectedCampaignId ? initialActiveInfo.activeEncounterId : null}
|
||||
campaignCharacters={selectedCampaign.players || []}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateCampaignForm({ onCreate, onCancel }) {
|
||||
const [name, setName] = useState('');
|
||||
return (
|
||||
<form onSubmit={(e) => { e.preventDefault(); onCreate(name); }} 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 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 [editingCharacter, setEditingCharacter] = useState(null);
|
||||
|
||||
const handleAddCharacter = async () => {
|
||||
if (!characterName.trim() || !campaignId) return;
|
||||
const newCharacter = { id: generateId(), name: characterName.trim() };
|
||||
try {
|
||||
await updateDoc(doc(db, CAMPAIGNS_COLLECTION, campaignId), { players: [...campaignCharacters, newCharacter] });
|
||||
setCharacterName('');
|
||||
} catch (err) { console.error("Error adding character:", err); }
|
||||
};
|
||||
|
||||
const handleUpdateCharacter = async (characterId, newName) => {
|
||||
if (!newName.trim() || !campaignId) return;
|
||||
const updatedCharacters = campaignCharacters.map(c => c.id === characterId ? { ...c, name: newName.trim() } : c);
|
||||
try {
|
||||
await updateDoc(doc(db, CAMPAIGNS_COLLECTION, campaignId), { players: updatedCharacters });
|
||||
setEditingCharacter(null);
|
||||
} catch (err) { console.error("Error updating character:", err); }
|
||||
};
|
||||
|
||||
const handleDeleteCharacter = async (characterId) => {
|
||||
// TODO: Implement custom confirmation modal for deleting characters
|
||||
console.warn("Attempting to delete character without confirmation:", characterId);
|
||||
const updatedCharacters = campaignCharacters.filter(c => c.id !== characterId);
|
||||
try {
|
||||
await updateDoc(doc(db, CAMPAIGNS_COLLECTION, campaignId), { players: updatedCharacters });
|
||||
} catch (err) { console.error("Error deleting character:", err); }
|
||||
};
|
||||
|
||||
return (
|
||||
<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>
|
||||
<form onSubmit={(e) => { e.preventDefault(); handleAddCharacter(); }} className="flex gap-2 mb-4">
|
||||
<input type="text" value={characterName} onChange={(e) => setCharacterName(e.target.value)} placeholder="New character name" className="flex-grow 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" />
|
||||
<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 flex items-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 ? (
|
||||
<input type="text" defaultValue={character.name} onBlur={(e) => handleUpdateCharacter(character.id, e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') handleUpdateCharacter(character.id, e.target.value); if (e.key === 'Escape') setEditingCharacter(null); }} autoFocus className="flex-grow px-3 py-2 bg-slate-600 border border-slate-500 rounded-md shadow-sm focus:outline-none focus:ring-sky-500 focus:border-sky-500 sm:text-sm text-white"/>
|
||||
) : ( <span className="text-slate-100">{character.name}</span> )}
|
||||
<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={() => 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>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharacters }) {
|
||||
const [encounters, setEncounters] = useState([]);
|
||||
const [selectedEncounterId, setSelectedEncounterId] = useState(null);
|
||||
const [showCreateEncounterModal, setShowCreateEncounterModal] = useState(false);
|
||||
const [activeDisplayInfo, setActiveDisplayInfo] = useState(null);
|
||||
const [copiedLinkEncounterId, setCopiedLinkEncounterId] = useState(null);
|
||||
const encountersPath = `${CAMPAIGNS_COLLECTION}/${campaignId}/encounters`;
|
||||
const selectedEncounterIdRef = React.useRef(selectedEncounterId); // Ref to track current selection for initial set
|
||||
|
||||
useEffect(() => {
|
||||
selectedEncounterIdRef.current = selectedEncounterId;
|
||||
}, [selectedEncounterId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!campaignId) {
|
||||
setEncounters([]);
|
||||
setSelectedEncounterId(null);
|
||||
return;
|
||||
}
|
||||
const unsubEncounters = onSnapshot(query(collection(db, encountersPath)), (snapshot) => {
|
||||
const fetchedEncounters = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
|
||||
setEncounters(fetchedEncounters);
|
||||
|
||||
if (selectedEncounterIdRef.current === null || !fetchedEncounters.some(e => e.id === selectedEncounterIdRef.current)) {
|
||||
if (initialActiveEncounterId && fetchedEncounters.some(e => e.id === initialActiveEncounterId)) {
|
||||
setSelectedEncounterId(initialActiveEncounterId);
|
||||
} else if (activeDisplayInfo && activeDisplayInfo.activeCampaignId === campaignId &&
|
||||
fetchedEncounters.some(e => e.id === activeDisplayInfo.activeEncounterId)) {
|
||||
setSelectedEncounterId(activeDisplayInfo.activeEncounterId);
|
||||
}
|
||||
}
|
||||
}, (err) => console.error(`Error fetching encounters for campaign ${campaignId}:`, err));
|
||||
|
||||
return () => unsubEncounters();
|
||||
}, [campaignId, initialActiveEncounterId, activeDisplayInfo]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = onSnapshot(doc(db, ACTIVE_DISPLAY_DOC), (docSnap) => {
|
||||
setActiveDisplayInfo(docSnap.exists() ? docSnap.data() : null);
|
||||
}, (err) => { console.error("Error fetching active display info:", err); setActiveDisplayInfo(null); });
|
||||
return () => unsub();
|
||||
}, []);
|
||||
|
||||
|
||||
const handleCreateEncounter = async (name) => {
|
||||
if (!name.trim() || !campaignId) return;
|
||||
const newEncounterId = generateId();
|
||||
try {
|
||||
await setDoc(doc(db, encountersPath, newEncounterId), {
|
||||
name: name.trim(), createdAt: new Date().toISOString(), participants: [], round: 0, currentTurnParticipantId: null, isStarted: false,
|
||||
});
|
||||
setShowCreateEncounterModal(false);
|
||||
setSelectedEncounterId(newEncounterId);
|
||||
} catch (err) { console.error("Error creating encounter:", err); }
|
||||
};
|
||||
|
||||
const handleDeleteEncounter = async (encounterId) => {
|
||||
// TODO: Implement custom confirmation modal for deleting encounters
|
||||
console.warn("Attempting to delete encounter without confirmation:", encounterId);
|
||||
try {
|
||||
await deleteDoc(doc(db, encountersPath, encounterId));
|
||||
if (selectedEncounterId === encounterId) setSelectedEncounterId(null);
|
||||
if (activeDisplayInfo && activeDisplayInfo.activeEncounterId === encounterId) {
|
||||
await updateDoc(doc(db, ACTIVE_DISPLAY_DOC), { activeEncounterId: null });
|
||||
}
|
||||
} catch (err) { console.error("Error deleting encounter:", err); }
|
||||
};
|
||||
|
||||
const handleSetEncounterAsActiveDisplay = async (encounterId) => {
|
||||
try {
|
||||
await setDoc(doc(db, ACTIVE_DISPLAY_DOC), { activeCampaignId: campaignId, activeEncounterId: encounterId }, { merge: true });
|
||||
console.log("Encounter set as active for DM's main display!");
|
||||
} catch (err) { console.error("Error setting active display:", err); }
|
||||
};
|
||||
|
||||
const handleCopyToClipboard = (encounterId) => {
|
||||
const link = `${getShareableLinkBase()}#/display/${campaignId}/${encounterId}`;
|
||||
navigator.clipboard.writeText(link).then(() => {
|
||||
setCopiedLinkEncounterId(encounterId);
|
||||
setTimeout(() => setCopiedLinkEncounterId(null), 2000);
|
||||
}).catch(err => console.error('Failed to copy link: ', err));
|
||||
};
|
||||
|
||||
const selectedEncounter = encounters.find(e => e.id === selectedEncounterId);
|
||||
|
||||
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.length === 0 && <p className="text-sm text-slate-400">No encounters yet.</p>}
|
||||
<div className="space-y-3">
|
||||
{encounters.map(encounter => {
|
||||
const isDmActiveDisplay = activeDisplayInfo && activeDisplayInfo.activeCampaignId === campaignId && activeDisplayInfo.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'} ${isDmActiveDisplay ? '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>
|
||||
{isDmActiveDisplay && <span className="text-xs text-green-400 font-semibold block mt-1">LIVE ON DM DISPLAY</span>}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button onClick={() => handleSetEncounterAsActiveDisplay(encounter.id)} className={`p-1 rounded transition-colors ${isDmActiveDisplay ? 'bg-green-500 hover:bg-green-600 text-white' : 'text-teal-400 hover:text-teal-300 bg-slate-600 hover:bg-slate-500'}`} title="Set as DM's Active Display"><Eye size={18} /></button>
|
||||
<button onClick={() => handleCopyToClipboard(encounter.id)} className="p-1 rounded transition-colors text-sky-400 hover:text-sky-300 bg-slate-600 hover:bg-slate-500" title="Copy Share Link for Players"><Share2 size={18} /></button>
|
||||
{copiedLinkEncounterId === encounter.id && <span className="text-xs text-green-400">Copied!</span>}
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
{selectedEncounterId === encounter.id && (
|
||||
<div className="mt-2 pt-2 border-t border-slate-600">
|
||||
<p className="text-xs text-slate-400">Shareable Link for Players:</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="text" readOnly value={`${getShareableLinkBase()}#/display/${campaignId}/${encounter.id}`} className="text-xs w-full bg-slate-600 px-3 py-2 border border-slate-500 rounded-md shadow-sm focus:outline-none focus:ring-sky-500 focus:border-sky-500 sm:text-sm text-white" />
|
||||
<button onClick={() => handleCopyToClipboard(encounter.id)} className="px-4 py-2 text-xs font-medium text-slate-300 bg-slate-500 hover:bg-slate-400 rounded-md transition-colors p-1">
|
||||
{copiedLinkEncounterId === encounter.id ? 'Copied!' : <CopyIcon size={14}/>}
|
||||
</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={`${encountersPath}/${selectedEncounterId}`} campaignCharacters={campaignCharacters} />
|
||||
<InitiativeControls campaignId={campaignId} encounter={selectedEncounter} encounterPath={`${encountersPath}/${selectedEncounterId}`} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 [initiative, setInitiative] = useState(10);
|
||||
const [maxHp, setMaxHp] = useState(10);
|
||||
const [editingParticipant, setEditingParticipant] = useState(null);
|
||||
const [hpChangeValues, setHpChangeValues] = useState({});
|
||||
const [draggedItemId, setDraggedItemId] = useState(null);
|
||||
|
||||
const participants = encounter.participants || [];
|
||||
|
||||
const handleAddParticipant = async () => {
|
||||
if ((participantType === 'monster' && !participantName.trim()) || (participantType === 'character' && !selectedCharacterId)) return;
|
||||
let nameToAdd = participantName.trim();
|
||||
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;
|
||||
}
|
||||
const newParticipant = {
|
||||
id: generateId(), name: nameToAdd, type: participantType,
|
||||
originalCharacterId: participantType === 'character' ? selectedCharacterId : null,
|
||||
initiative: parseInt(initiative, 10) || 0, maxHp: parseInt(maxHp, 10) || 1, currentHp: parseInt(maxHp, 10) || 1,
|
||||
conditions: [], isActive: true,
|
||||
};
|
||||
try {
|
||||
await updateDoc(doc(db, encounterPath), { participants: [...participants, newParticipant] });
|
||||
setParticipantName(''); setInitiative(10); setMaxHp(10); setSelectedCharacterId('');
|
||||
} catch (err) { console.error("Error adding participant:", err); }
|
||||
};
|
||||
|
||||
const handleUpdateParticipant = async (updatedData) => {
|
||||
if (!editingParticipant) return;
|
||||
const { flavorText, ...restOfData } = updatedData;
|
||||
const updatedParticipants = participants.map(p => p.id === editingParticipant.id ? { ...p, ...restOfData } : p );
|
||||
try {
|
||||
await updateDoc(doc(db, encounterPath), { participants: updatedParticipants });
|
||||
setEditingParticipant(null);
|
||||
} catch (err) { console.error("Error updating participant:", err); }
|
||||
};
|
||||
|
||||
const handleDeleteParticipant = async (participantId) => {
|
||||
// TODO: Implement custom confirmation modal for deleting participants
|
||||
console.warn("Attempting to delete participant without confirmation:", participantId);
|
||||
const updatedParticipants = participants.filter(p => p.id !== participantId);
|
||||
try {
|
||||
await updateDoc(doc(db, encounterPath), { participants: updatedParticipants });
|
||||
} catch (err) { console.error("Error deleting participant:", err); }
|
||||
};
|
||||
|
||||
const toggleParticipantActive = async (participantId) => {
|
||||
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) => {
|
||||
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 (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) {
|
||||
setDraggedItemId(null); return;
|
||||
}
|
||||
const draggedItem = currentParticipants[draggedItemIndex];
|
||||
const targetItem = currentParticipants[targetItemIndex];
|
||||
|
||||
if (draggedItem.initiative !== targetItem.initiative) {
|
||||
console.log("Cannot drag between different initiative groups for tie-breaking.");
|
||||
setDraggedItemId(null); return;
|
||||
}
|
||||
const reorderedParticipants = [...currentParticipants];
|
||||
const [removedItem] = reorderedParticipants.splice(draggedItemIndex, 1);
|
||||
reorderedParticipants.splice(targetItemIndex, 0, removedItem);
|
||||
try {
|
||||
await updateDoc(doc(db, encounterPath), { participants: reorderedParticipants });
|
||||
} catch (err) { console.error("Error updating participants after drag-drop:", 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">
|
||||
<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">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300">Type</label>
|
||||
<select value={participantType} onChange={(e) => setParticipantType(e.target.value)} 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-2">
|
||||
<label className="block text-sm font-medium text-slate-300">Monster Name</label>
|
||||
<input type="text" 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>
|
||||
)}
|
||||
{participantType === 'character' && (<div><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}</option>)}</select></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 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">Max HP</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-2 flex justify-end">
|
||||
<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">
|
||||
<PlusCircle size={18} className="mr-1" /> Add to Encounter
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{participants.length === 0 && <p className="text-sm text-slate-400">No participants.</p>}
|
||||
<ul className="space-y-2">
|
||||
{sortedAdminParticipants.map((p, index) => {
|
||||
const isCurrentTurn = encounter.isStarted && p.id === encounter.currentTurnParticipantId;
|
||||
const isDraggable = !encounter.isStarted && tiedInitiatives.includes(p.initiative);
|
||||
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
|
||||
${isCurrentTurn ? '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' : ''}
|
||||
`}
|
||||
>
|
||||
<div className="flex-1 flex items-center">
|
||||
{isDraggable && <ChevronsUpDown size={18} className="mr-2 text-slate-400 flex-shrink-0" />}
|
||||
<div>
|
||||
<p className={`font-semibold text-lg ${isCurrentTurn ? 'text-white' : 'text-white'}`}>{p.name} <span className="text-xs">({p.type})</span>{isCurrentTurn && <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 ? '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={() => 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>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{editingParticipant && <EditParticipantModal participant={editingParticipant} onClose={() => setEditingParticipant(null)} onSave={handleUpdateParticipant} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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),
|
||||
});
|
||||
};
|
||||
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>
|
||||
<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 handleStartEncounter = async () => {
|
||||
if (!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, round: 1, currentTurnParticipantId: sortedPs[0].id, turnOrderIds: sortedPs.map(p => p.id)
|
||||
// Participants array in DB already reflects D&D order, so no need to update it here again unless sorting changes it.
|
||||
// The `participants` field in the database should be the source of truth for the D&D order.
|
||||
// The `turnOrderIds` is derived from this for active combat.
|
||||
});
|
||||
await setDoc(doc(db, ACTIVE_DISPLAY_DOC), {
|
||||
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 handleNextTurn = async () => {
|
||||
if (!encounter.isStarted || !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, 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 handleEndEncounter = async () => {
|
||||
// TODO: Implement custom confirmation modal for ending encounter
|
||||
console.warn("Attempting to end encounter without confirmation");
|
||||
try {
|
||||
await updateDoc(doc(db, encounterPath), { isStarted: false, currentTurnParticipantId: null, round: 0, turnOrderIds: [] });
|
||||
} catch (err) { console.error("Error ending encounter:", err); }
|
||||
};
|
||||
|
||||
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={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>
|
||||
<p className="text-slate-300 self-center">Round: {encounter.round}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DisplayView({ campaignIdFromUrl, encounterIdFromUrl }) {
|
||||
const [activeEncounterData, setActiveEncounterData] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true); setError(null); setActiveEncounterData(null);
|
||||
let unsubscribeEncounter;
|
||||
|
||||
if (campaignIdFromUrl && encounterIdFromUrl) {
|
||||
const encounterPath = `${CAMPAIGNS_COLLECTION}/${campaignIdFromUrl}/encounters/${encounterIdFromUrl}`;
|
||||
unsubscribeEncounter = onSnapshot(doc(db, encounterPath), (encDocSnap) => {
|
||||
if (encDocSnap.exists()) setActiveEncounterData({ id: encDocSnap.id, ...encDocSnap.data() });
|
||||
else setError("The requested encounter was not found or is not accessible.");
|
||||
setIsLoading(false);
|
||||
}, (err) => { console.error("Error fetching specific encounter for display:", err); setError("Error loading encounter data from link."); setIsLoading(false); });
|
||||
} else {
|
||||
const activeDisplayRef = doc(db, ACTIVE_DISPLAY_DOC);
|
||||
const unsubDisplayConfig = onSnapshot(activeDisplayRef, async (docSnap) => {
|
||||
if (docSnap.exists()) {
|
||||
const { activeCampaignId, activeEncounterId } = docSnap.data();
|
||||
if (activeCampaignId && activeEncounterId) {
|
||||
const encounterPath = `${CAMPAIGNS_COLLECTION}/${activeCampaignId}/encounters/${activeEncounterId}`;
|
||||
if(unsubscribeEncounter) unsubscribeEncounter();
|
||||
unsubscribeEncounter = onSnapshot(doc(db, encounterPath), (encDocSnap) => {
|
||||
if (encDocSnap.exists()) setActiveEncounterData({ id: encDocSnap.id, ...encDocSnap.data() });
|
||||
else { setActiveEncounterData(null); setError("Active encounter not found. The DM might have deleted it.");}
|
||||
setIsLoading(false);
|
||||
}, (err) => { console.error("Error fetching active encounter details:", err); setError("Error loading active encounter data."); setIsLoading(false);});
|
||||
} else { setActiveEncounterData(null); setIsLoading(false); }
|
||||
} else { setActiveEncounterData(null); setIsLoading(false); }
|
||||
}, (err) => { console.error("Error fetching active display config:", err); setError("Could not load display configuration."); setIsLoading(false); });
|
||||
return () => { unsubDisplayConfig(); if (unsubscribeEncounter) unsubscribeEncounter(); };
|
||||
}
|
||||
return () => { if (unsubscribeEncounter) unsubscribeEncounter(); };
|
||||
}, [campaignIdFromUrl, encounterIdFromUrl]);
|
||||
|
||||
if (isLoading) return <div className="text-center py-10 text-2xl text-slate-300">Loading Player Display...</div>;
|
||||
if (error) return <div className="text-center py-10 text-2xl text-red-400">{error}</div>;
|
||||
if (!activeEncounterData) return <div className="text-center py-10 text-3xl text-slate-400">No active encounter to display.</div>;
|
||||
|
||||
const { name, participants, round, currentTurnParticipantId, isStarted } = activeEncounterData;
|
||||
|
||||
let displayParticipants = [];
|
||||
if (isStarted && activeEncounterData.turnOrderIds?.length > 0 && participants) {
|
||||
displayParticipants = activeEncounterData.turnOrderIds
|
||||
.map(id => participants.find(p => p.id === id)).filter(p => p && p.isActive);
|
||||
} else if (participants) {
|
||||
displayParticipants = [...participants].filter(p => p.isActive)
|
||||
.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;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-8 bg-slate-900 rounded-xl shadow-2xl">
|
||||
<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>}
|
||||
{!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>}
|
||||
{displayParticipants.length === 0 && isStarted && <p className="text-xl text-slate-400">No active participants.</p>}
|
||||
<div className="space-y-4 max-w-3xl mx-auto">
|
||||
{displayParticipants.map(p => (
|
||||
<div key={p.id} 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">
|
||||
<h3 className={`text-2xl md:text-3xl font-bold ${p.id === currentTurnParticipantId && isStarted ? 'text-white' : (p.type === 'character' ? 'text-sky-100' : 'text-red-100')}`}>{p.name}{p.id === currentTurnParticipantId && isStarted && <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 ? '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' && ( // Only show HP text if not a monster
|
||||
<span className="absolute inset-0 flex items-center justify-center text-xs md:text-sm font-medium text-white mix-blend-difference 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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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 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>;
|
||||
|
||||
export default App;
|
9
env.example
Normal file
9
env.example
Normal file
@ -0,0 +1,9 @@
|
||||
# .env.example (This file IS committed to Git)
|
||||
REACT_APP_FIREBASE_API_KEY="YOUR_FIREBASE_API_KEY_HERE"
|
||||
REACT_APP_FIREBASE_AUTH_DOMAIN="YOUR_FIREBASE_AUTH_DOMAIN_HERE"
|
||||
REACT_APP_FIREBASE_PROJECT_ID="YOUR_FIREBASE_PROJECT_ID_HERE"
|
||||
REACT_APP_FIREBASE_STORAGE_BUCKET="YOUR_FIREBASE_STORAGE_BUCKET_HERE"
|
||||
REACT_APP_FIREBASE_MESSAGING_SENDER_ID="YOUR_FIREBASE_MESSAGING_SENDER_ID_HERE"
|
||||
REACT_APP_FIREBASE_APP_ID="YOUR_FIREBASE_APP_ID_HERE"
|
||||
|
||||
REACT_APP_TRACKER_APP_ID="ttrpg-initiative-tracker-default"
|
40
package.json
Normal file
40
package.json
Normal file
@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "ttrpg-initiative-tracker",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@testing-library/jest-dom": "^5.17.0", // Optional: For testing
|
||||
"@testing-library/react": "^13.4.0", // Optional: For testing
|
||||
"@testing-library/user-event": "^13.5.0", // Optional: For testing
|
||||
"firebase": "^10.12.2", // Firebase SDK
|
||||
"lucide-react": "^0.395.0", // Icons
|
||||
"react": "^18.3.1", // React library
|
||||
"react-dom": "^18.3.1", // React DOM for web
|
||||
"react-scripts": "5.0.1", // Scripts and configuration for Create React App
|
||||
"web-vitals": "^2.1.4" // Optional: For measuring web vitals
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test", // Optional: For testing
|
||||
"eject": "react-scripts eject" // Optional: For Create React App
|
||||
},
|
||||
"eslintConfig": { // Optional: Basic ESLint setup
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": { // Optional: Defines browser support
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user