Files
ttrpg-initiative-tracker/src/App.js
T

2918 lines
115 KiB
JavaScript
Raw Normal View History

import React, { useState, useEffect, useRef, useMemo } from 'react';
import { initializeApp } from './storage';
import { getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken } from './storage';
import { getFirestore, doc, setDoc, addDoc, collection, onSnapshot, updateDoc, deleteDoc, query, orderBy, limit, writeBatch, getStorage } from './storage';
2026-05-16 10:25:17 -04:00
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, ChevronUp, ChevronDown, ScrollText,
Maximize2, Minimize2, Moon, Coffee
} from 'lucide-react';
// Custom CSS for death animation (player view only)
const deathAnimationStyles = `
@keyframes death-dissolve {
0% {
opacity: 1;
transform: scale(1) translateY(0);
}
50% {
opacity: 0.5;
transform: scale(0.95) translateY(-5px);
filter: blur(2px);
}
100% {
opacity: 0;
transform: scale(0.8) translateY(-10px);
filter: blur(4px);
}
}
.animate-death-dissolve {
animation: death-dissolve 2s ease-in-out forwards;
}
`;
// Inject styles
if (typeof document !== 'undefined') {
const styleElement = document.createElement('style');
styleElement.innerHTML = deathAnimationStyles;
document.head.appendChild(styleElement);
}
// ============================================================================
// CONSTANTS
// ============================================================================
2026-06-27 20:15:20 -04:00
const APP_VERSION = 'v0.3';
const DEFAULT_MAX_HP = 10;
const DEFAULT_INIT_MOD = 0;
const MONSTER_DEFAULT_INIT_MOD = 2;
const ROLL_DISPLAY_DURATION = 5000;
2026-04-26 10:37:25 -04:00
const CONDITIONS = [
{ id: 'alchemist_fire', label: 'Alchemist Fire', emoji: '🔥' },
{ id: 'bardic_inspiration', label: 'Bardic Inspiration', emoji: '🎵' },
{ id: 'blinded', label: 'Blinded', emoji: '🙈' },
{ id: 'charmed', label: 'Charmed', emoji: '💘' },
{ id: 'deafened', label: 'Deafened', emoji: '🔇' },
{ id: 'exhaustion', label: 'Exhaustion', emoji: '😴' },
{ id: 'frightened', label: 'Frightened', emoji: '😱' },
{ id: 'grappled', label: 'Grappled', emoji: '🤜' },
{ id: 'grazed', label: 'Grazed', emoji: '🩹' },
{ id: 'incapacitated', label: 'Incapacitated', emoji: '💫' },
{ id: 'invisible', label: 'Invisible', emoji: '👻' },
{ id: 'paralyzed', label: 'Paralyzed', emoji: '⚡' },
{ id: 'petrified', label: 'Petrified', emoji: '🗿' },
{ id: 'poisoned', label: 'Poisoned', emoji: '🤢' },
{ id: 'prone', label: 'Prone', emoji: '⬇️' },
{ id: 'restrained', label: 'Restrained', emoji: '🕸️' },
{ id: 'sapped', label: 'Sapped', emoji: '🔨' },
2026-05-16 15:29:27 -04:00
{ id: 'shield', label: 'Shield', emoji: '🛡️' },
{ id: 'slowed', label: 'Slowed', emoji: '🐌' },
{ id: 'stunned', label: 'Stunned', emoji: '💥' },
{ id: 'unconscious', label: 'Unconscious', emoji: '💤' },
{ id: 'vexed', label: 'Vexed', emoji: '🎯' },
2026-04-26 10:37:25 -04:00
];
// ============================================================================
// FIREBASE CONFIGURATION
// ============================================================================
2025-05-25 21:19:22 -04:00
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
};
const APP_ID = process.env.REACT_APP_TRACKER_APP_ID || 'ttrpg-initiative-tracker-default';
const PUBLIC_DATA_PATH = `artifacts/${APP_ID}/public/data`;
2025-05-25 22:21:45 -04:00
let app;
let db;
let auth;
let storage;
2025-05-25 22:21:45 -04:00
// Initialize Firebase
const initializeFirebase = () => {
const requiredKeys = ['apiKey', 'authDomain', 'projectId', 'appId'];
const missingKeys = requiredKeys.filter(key => !firebaseConfig[key]);
if (missingKeys.length > 0) {
console.error(`CRITICAL: Missing Firebase config values: ${missingKeys.join(', ')}`);
return false;
}
2025-05-25 21:19:22 -04:00
2025-05-25 22:21:45 -04:00
try {
app = initializeApp(firebaseConfig);
db = getFirestore(app);
auth = getAuth(app);
storage = getStorage();
return true;
2025-05-25 22:21:45 -04:00
} catch (error) {
console.error("Error initializing Firebase:", error);
return false;
2025-05-25 22:21:45 -04:00
}
};
2025-05-25 21:19:22 -04:00
const isFirebaseInitialized = initializeFirebase();
2025-05-26 08:33:39 -04:00
// ============================================================================
// FIRESTORE PATH HELPERS
// ============================================================================
2025-05-26 08:33:39 -04:00
const getPath = {
campaigns: () => `${PUBLIC_DATA_PATH}/campaigns`,
campaign: (id) => `${PUBLIC_DATA_PATH}/campaigns/${id}`,
encounters: (campaignId) => `${PUBLIC_DATA_PATH}/campaigns/${campaignId}/encounters`,
encounter: (campaignId, encounterId) => `${PUBLIC_DATA_PATH}/campaigns/${campaignId}/encounters/${encounterId}`,
2026-05-16 10:25:17 -04:00
activeDisplay: () => `${PUBLIC_DATA_PATH}/activeDisplay/status`,
logs: () => `${PUBLIC_DATA_PATH}/logs`
};
// ============================================================================
// UTILITY FUNCTIONS
// ============================================================================
2025-05-25 21:19:22 -04:00
const generateId = () => crypto.randomUUID();
2025-05-26 22:31:43 -04:00
const rollD20 = () => Math.floor(Math.random() * 20) + 1;
2025-05-25 21:19:22 -04:00
const formatInitMod = (mod) => {
if (mod === undefined || mod === null) return 'N/A';
return mod >= 0 ? `+${mod}` : `${mod}`;
};
const sortParticipantsByInitiative = (participants, originalOrder) => {
return [...participants].sort((a, b) => {
if (a.initiative === b.initiative) {
const indexA = originalOrder.findIndex(p => p.id === a.id);
const indexB = originalOrder.findIndex(p => p.id === b.id);
return indexA - indexB;
}
return b.initiative - a.initiative;
});
};
2026-05-16 10:25:17 -04:00
const LOG_QUERY = [orderBy('timestamp', 'desc'), limit(500)];
const logAction = async (message, context = {}, undoData = null) => {
2026-05-16 10:25:17 -04:00
if (!db) return;
try {
const entry = { timestamp: Date.now(), message, ...context };
if (undoData) entry.undo = undoData;
await storage.addDoc(getPath.logs(), entry);
2026-05-16 10:25:17 -04:00
} catch (err) {
console.error('Error writing log:', err);
}
};
// Returns turnOrderIds/currentTurnParticipantId updates when a participant leaves active combat.
const computeTurnOrderAfterRemoval = (encounter, removedId, updatedParticipants) => {
if (!encounter.isStarted) return {};
const currentIds = encounter.turnOrderIds || [];
const newIds = currentIds.filter(id => id !== removedId);
const updates = { turnOrderIds: newIds };
if (encounter.currentTurnParticipantId === removedId) {
const removedPos = currentIds.indexOf(removedId);
const candidates = [...currentIds.slice(removedPos + 1), ...currentIds.slice(0, removedPos)];
const nextId = candidates.find(id => updatedParticipants.find(p => p.id === id && p.isActive)) ?? null;
updates.currentTurnParticipantId = nextId;
}
return updates;
};
// Returns turnOrderIds update when a participant re-enters active combat mid-encounter.
const computeTurnOrderAfterAddition = (encounter, addedId) => {
if (!encounter.isStarted) return {};
const currentIds = encounter.turnOrderIds || [];
if (currentIds.includes(addedId)) return {};
return { turnOrderIds: [...currentIds, addedId] };
};
// ============================================================================
// CUSTOM HOOKS
// ============================================================================
2025-05-26 08:33:39 -04:00
function useFirestoreDocument(docPath) {
const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
if (!docPath) {
2025-05-26 08:33:39 -04:00
setData(null);
setIsLoading(false);
setError("Document path not provided.");
2025-05-26 08:33:39 -04:00
return;
}
2025-05-26 08:33:39 -04:00
setIsLoading(true);
setError(null);
const storage = getStorage();
const unsubscribe = storage.subscribeDoc(docPath, (doc) => {
setData(doc);
setIsLoading(false);
});
return () => { if (typeof unsubscribe === 'function') unsubscribe(); };
2025-05-26 08:33:39 -04:00
}, [docPath]);
2025-05-26 08:33:39 -04:00
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]);
2025-05-26 08:33:39 -04:00
useEffect(() => {
if (!collectionPath) {
2025-05-26 08:33:39 -04:00
setData([]);
setIsLoading(false);
setError("Collection path not provided.");
2025-05-26 08:33:39 -04:00
return;
}
2025-05-26 08:33:39 -04:00
setIsLoading(true);
setError(null);
const storage = getStorage();
const unsubscribe = storage.subscribeCollection(collectionPath, (items) => {
setData(items);
setIsLoading(false);
});
return () => { if (typeof unsubscribe === 'function') unsubscribe(); };
// queryString, not array ref
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [collectionPath, queryString]);
2025-05-26 08:33:39 -04:00
return { data, isLoading, error };
}
// ============================================================================
// REUSABLE COMPONENTS
// ============================================================================
2025-05-26 08:33:39 -04:00
function Modal({ onClose, title, children }) {
2025-05-25 21:19:22 -04:00
useEffect(() => {
const handleEsc = (event) => {
if (event.key === 'Escape') onClose();
2025-05-25 21:19:22 -04:00
};
window.addEventListener('keydown', handleEsc);
return () => window.removeEventListener('keydown', handleEsc);
}, [onClose]);
2025-05-25 21:19:22 -04:00
return (
<div className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center p-4 z-50">
2026-04-25 20:25:34 -04:00
<div className="bg-stone-900 p-6 rounded-lg shadow-xl w-full max-w-md">
<div className="flex justify-between items-center mb-4">
2026-04-25 18:37:55 -04:00
<h2 className="text-xl font-semibold text-amber-300 font-cinzel tracking-wide">{title}</h2>
2026-04-25 20:25:34 -04:00
<button onClick={onClose} className="text-stone-400 hover:text-stone-200">
<XCircle size={24} />
</button>
2025-05-25 22:21:45 -04:00
</div>
{children}
2025-05-26 07:59:05 -04:00
</div>
2025-05-25 21:19:22 -04:00
</div>
);
}
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">
2026-04-25 20:25:34 -04:00
<div className="bg-stone-900 p-6 rounded-lg shadow-xl w-full max-w-md">
<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>
2026-04-25 20:25:34 -04:00
<p className="text-stone-300 mb-6">{message || "Are you sure you want to proceed?"}</p>
<div className="flex justify-end space-x-3">
<button
onClick={onClose}
2026-04-25 20:25:34 -04:00
className="px-4 py-2 text-sm font-medium text-stone-300 bg-stone-700 hover:bg-stone-600 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>
);
}
function LoadingSpinner({ message = "Loading..." }) {
return (
2026-04-25 20:25:34 -04:00
<div className="min-h-screen bg-stone-950 text-white flex flex-col items-center justify-center p-4">
2026-04-25 18:37:55 -04:00
<div className="animate-spin rounded-full h-16 w-16 border-t-4 border-amber-500 border-solid"></div>
<p className="mt-4 text-xl">{message}</p>
</div>
);
}
2025-05-26 08:33:39 -04:00
function ErrorDisplay({ message, critical = false }) {
2025-05-25 21:19:22 -04:00
return (
2026-04-25 20:25:34 -04:00
<div className={`min-h-screen ${critical ? 'bg-red-900' : 'bg-stone-950'} text-white flex flex-col items-center justify-center p-4`}>
<h1 className="text-3xl font-bold mb-4">
{critical ? 'Configuration Error' : 'Error'}
</h1>
<p className="text-xl text-center max-w-2xl">{message}</p>
</div>
2025-05-25 21:19:22 -04:00
);
}
// ============================================================================
// FORM COMPONENTS
// ============================================================================
2025-05-25 21:19:22 -04:00
function CreateCampaignForm({ onCreate, onCancel }) {
const [name, setName] = useState('');
const [backgroundUrl, setBackgroundUrl] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
if (name.trim()) {
onCreate(name, backgroundUrl);
}
};
2025-05-25 21:19:22 -04:00
return (
2025-05-25 23:28:36 -04:00
<form onSubmit={handleSubmit} className="space-y-4">
2025-05-25 21:19:22 -04:00
<div>
2026-04-25 20:25:34 -04:00
<label htmlFor="campaignName" className="block text-sm font-medium text-stone-300">
Campaign Name
</label>
<input
type="text"
id="campaignName"
value={name}
onChange={(e) => setName(e.target.value)}
2026-04-25 20:25:34 -04:00
className="mt-1 block w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
required
/>
2025-05-25 21:19:22 -04:00
</div>
2025-05-25 23:28:36 -04:00
<div>
2026-04-25 20:25:34 -04:00
<label htmlFor="backgroundUrl" className="block text-sm font-medium text-stone-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"
2026-04-25 20:25:34 -04:00
className="mt-1 block w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
/>
2025-05-25 23:28:36 -04:00
</div>
2025-05-25 21:19:22 -04:00
<div className="flex justify-end space-x-3">
<button
type="button"
onClick={onCancel}
2026-04-25 20:25:34 -04:00
className="px-4 py-2 text-sm font-medium text-stone-300 bg-stone-700 hover:bg-stone-600 rounded-md transition-colors"
>
Cancel
</button>
<button
type="submit"
2026-04-25 20:25:34 -04:00
className="px-4 py-2 text-sm font-medium text-white bg-red-700 hover:bg-red-800 rounded-md transition-colors"
>
Create
</button>
2025-05-25 21:19:22 -04:00
</div>
</form>
);
}
function CreateEncounterForm({ onCreate, onCancel }) {
const [name, setName] = useState('');
2025-05-28 12:23:10 -04:00
const handleSubmit = (e) => {
e.preventDefault();
if (name.trim()) {
onCreate(name);
}
2025-05-25 21:19:22 -04:00
};
2025-05-25 21:19:22 -04:00
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
2026-04-25 20:25:34 -04:00
<label htmlFor="encounterName" className="block text-sm font-medium text-stone-300">
Encounter Name
</label>
<input
type="text"
id="encounterName"
value={name}
onChange={(e) => setName(e.target.value)}
2026-04-25 20:25:34 -04:00
className="mt-1 block w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
required
/>
</div>
<div className="flex justify-end space-x-3">
<button
type="button"
onClick={onCancel}
2026-04-25 20:25:34 -04:00
className="px-4 py-2 text-sm font-medium text-stone-300 bg-stone-700 hover:bg-stone-600 rounded-md transition-colors"
>
Cancel
</button>
<button
type="submit"
2026-04-25 20:25:34 -04:00
className="px-4 py-2 text-sm font-medium text-white bg-amber-700 hover:bg-amber-800 rounded-md transition-colors"
2025-05-28 14:58:54 -04:00
>
Create
2025-05-28 14:58:54 -04:00
</button>
</div>
</form>
);
}
2025-05-28 14:58:54 -04:00
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>
2026-04-25 20:25:34 -04:00
<label className="block text-sm font-medium text-stone-300">Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
2026-04-25 20:25:34 -04:00
className="mt-1 block w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
/>
</div>
<div>
2026-04-25 20:25:34 -04:00
<label className="block text-sm font-medium text-stone-300">Initiative</label>
<input
type="number"
value={initiative}
onChange={(e) => setInitiative(e.target.value)}
2026-04-25 20:25:34 -04:00
className="mt-1 block w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
/>
</div>
<div className="flex gap-4">
<div className="flex-1">
2026-04-25 20:25:34 -04:00
<label className="block text-sm font-medium text-stone-300">Current HP</label>
<input
type="number"
value={currentHp}
onChange={(e) => setCurrentHp(e.target.value)}
2026-04-25 20:25:34 -04:00
className="mt-1 block w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
/>
</div>
<div className="flex-1">
2026-04-25 20:25:34 -04:00
<label className="block text-sm font-medium text-stone-300">Max HP</label>
<input
type="number"
value={maxHp}
onChange={(e) => setMaxHp(e.target.value)}
2026-04-25 20:25:34 -04:00
className="mt-1 block w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 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)}
2026-04-25 20:25:34 -04:00
className="h-4 w-4 text-violet-600 border-stone-400 rounded focus:ring-violet-500"
/>
2026-04-25 20:25:34 -04:00
<label htmlFor="editIsNpc" className="ml-2 block text-sm text-stone-300">
Is NPC?
</label>
</div>
)}
<div className="flex justify-end space-x-3 pt-2">
<button
type="button"
onClick={onClose}
2026-04-25 20:25:34 -04:00
className="px-4 py-2 text-sm font-medium text-stone-300 bg-stone-700 hover:bg-stone-600 rounded-md transition-colors"
>
Cancel
</button>
<button
type="submit"
2026-04-25 18:37:55 -04:00
className="px-4 py-2 text-sm font-medium text-white bg-amber-700 hover:bg-amber-800 rounded-md transition-colors"
>
<Save size={18} className="mr-1 inline-block" /> Save
</button>
</div>
</form>
</Modal>
2025-05-25 21:19:22 -04:00
);
}
// ============================================================================
// CHARACTER MANAGER COMPONENT
// ============================================================================
2025-05-28 14:58:54 -04:00
function CharacterManager({ campaignId, campaignCharacters }) {
const [characterName, setCharacterName] = useState('');
const [defaultMaxHp, setDefaultMaxHp] = useState(DEFAULT_MAX_HP);
const [defaultInitMod, setDefaultInitMod] = useState(DEFAULT_INIT_MOD);
const [editingCharacter, setEditingCharacter] = useState(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [itemToDelete, setItemToDelete] = useState(null);
const [isOpen, setIsOpen] = useState(true);
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 storage.updateDoc(getPath.campaign(campaignId), {
players: [...campaignCharacters, newCharacter]
});
setCharacterName('');
setDefaultMaxHp(DEFAULT_MAX_HP);
setDefaultInitMod(DEFAULT_INIT_MOD);
} catch (err) {
console.error("Error adding character:", err);
alert("Failed to add character. Please try again.");
}
2025-05-25 21:19:22 -04:00
};
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 storage.updateDoc(getPath.campaign(campaignId), { players: updatedCharacters });
setEditingCharacter(null);
} catch (err) {
console.error("Error updating character:", err);
alert("Failed to update character. Please try again.");
}
2025-05-25 21:19:22 -04:00
};
const requestDeleteCharacter = (characterId, charName) => {
setItemToDelete({ id: characterId, name: charName });
setShowDeleteConfirm(true);
};
const confirmDeleteCharacter = async () => {
if (!db || !itemToDelete) return;
const updatedCharacters = campaignCharacters.filter(c => c.id !== itemToDelete.id);
2025-05-25 21:19:22 -04:00
try {
await storage.updateDoc(getPath.campaign(campaignId), { players: updatedCharacters });
} catch (err) {
console.error("Error deleting character:", err);
alert("Failed to delete character. Please try again.");
}
setShowDeleteConfirm(false);
setItemToDelete(null);
2025-05-25 21:19:22 -04:00
};
2025-05-25 21:19:22 -04:00
return (
<>
2026-04-25 20:25:34 -04:00
<div className="p-4 bg-stone-900 rounded-lg shadow">
<div className="flex justify-between items-center mb-3">
2026-04-25 18:37:55 -04:00
<h3 className="text-xl font-semibold text-amber-300 font-cinzel tracking-wide flex items-center">
<Users size={24} className="mr-2" /> Campaign Characters
</h3>
<button
onClick={() => setIsOpen(!isOpen)}
2026-04-25 20:25:34 -04:00
className="p-1 text-stone-400 hover:text-stone-200"
aria-label={isOpen ? "Collapse" : "Expand"}
>
{isOpen ? <ChevronUp size={20} /> : <ChevronDown size={20} />}
</button>
</div>
{isOpen && (
<>
<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">
2026-04-25 20:25:34 -04:00
<label htmlFor="characterName" className="block text-xs font-medium text-stone-400">
Name
</label>
<input
type="text"
id="characterName"
value={characterName}
onChange={(e) => setCharacterName(e.target.value)}
placeholder="Character name"
2026-04-25 20:25:34 -04:00
className="w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
/>
2025-05-25 21:19:22 -04:00
</div>
<div className="w-full sm:w-auto">
2026-04-25 20:25:34 -04:00
<label htmlFor="defaultMaxHp" className="block text-xs font-medium text-stone-400">
Default HP
</label>
<input
type="number"
id="defaultMaxHp"
value={defaultMaxHp}
onChange={(e) => setDefaultMaxHp(e.target.value)}
2026-04-25 20:25:34 -04:00
className="w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
/>
</div>
<div className="w-full sm:w-auto">
2026-04-25 20:25:34 -04:00
<label htmlFor="defaultInitMod" className="block text-xs font-medium text-stone-400">
Init Mod
</label>
<input
type="number"
id="defaultInitMod"
value={defaultInitMod}
onChange={(e) => setDefaultInitMod(e.target.value)}
2026-04-25 20:25:34 -04:00
className="w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
/>
</div>
<button
type="submit"
2026-04-25 18:37:55 -04:00
className="sm:col-span-3 sm:w-auto sm:justify-self-end px-4 py-2 text-sm font-medium text-white bg-amber-700 hover:bg-amber-800 rounded-md transition-colors flex items-center justify-center"
>
<PlusCircle size={18} className="mr-1" /> Add Character
</button>
</form>
{campaignCharacters.length === 0 && (
2026-04-25 20:25:34 -04:00
<p className="text-sm text-stone-400">No characters added yet.</p>
)}
<ul className="space-y-2">
{campaignCharacters.map(character => (
2026-04-25 20:25:34 -04:00
<li key={character.id} className="flex justify-between items-center p-3 bg-stone-800 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 })}
2026-04-25 20:25:34 -04:00
className="flex-grow min-w-[100px] px-2 py-1 bg-stone-700 border border-stone-600 rounded-md text-white"
/>
<input
type="number"
value={editingCharacter.defaultMaxHp}
onChange={(e) => setEditingCharacter({ ...editingCharacter, defaultMaxHp: e.target.value })}
2026-04-25 20:25:34 -04:00
className="w-20 px-2 py-1 bg-stone-700 border border-stone-600 rounded-md text-white"
title="Default Max HP"
/>
<input
type="number"
value={editingCharacter.defaultInitMod}
onChange={(e) => setEditingCharacter({ ...editingCharacter, defaultInitMod: e.target.value })}
2026-04-25 20:25:34 -04:00
className="w-20 px-2 py-1 bg-stone-700 border border-stone-600 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)}
2026-04-25 20:25:34 -04:00
className="p-1 text-stone-400 hover:text-stone-200"
>
<XCircle size={18} />
</button>
</form>
) : (
<>
2026-04-25 20:25:34 -04:00
<span className="text-stone-100">
{character.name}{' '}
2026-04-25 20:25:34 -04:00
<span className="text-xs text-stone-400">
(HP: {character.defaultMaxHp || 'N/A'}, Init Mod: {formatInitMod(character.defaultInitMod)})
</span>
</span>
<div className="flex space-x-2">
<button
onClick={() => setEditingCharacter({
id: character.id,
name: character.name,
defaultMaxHp: character.defaultMaxHp || DEFAULT_MAX_HP,
defaultInitMod: character.defaultInitMod || DEFAULT_INIT_MOD
})}
2026-04-25 20:25:34 -04:00
className="p-1 rounded transition-colors text-yellow-400 hover:text-yellow-300 bg-stone-700 hover:bg-stone-600"
aria-label="Edit character"
>
<Edit3 size={18} />
</button>
<button
onClick={() => requestDeleteCharacter(character.id, character.name)}
2026-04-25 20:25:34 -04:00
className="p-1 rounded transition-colors text-red-400 hover:text-red-300 bg-stone-700 hover:bg-stone-600"
aria-label="Delete character"
>
<Trash2 size={18} />
</button>
</div>
</>
)}
</li>
))}
</ul>
</>
)}
2025-05-25 21:19:22 -04:00
</div>
<ConfirmationModal
isOpen={showDeleteConfirm}
onClose={() => setShowDeleteConfirm(false)}
onConfirm={confirmDeleteCharacter}
title="Delete Character?"
message={`Are you sure you want to remove "${itemToDelete?.name}" from this campaign?`}
/>
</>
2025-05-25 21:19:22 -04:00
);
}
// ============================================================================
// PARTICIPANT MANAGER COMPONENT
// ============================================================================
2025-05-25 21:19:22 -04:00
function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
2025-05-25 21:19:22 -04:00
const [participantName, setParticipantName] = useState('');
const [participantType, setParticipantType] = useState('monster');
const [selectedCharacterId, setSelectedCharacterId] = useState('');
const [monsterInitMod, setMonsterInitMod] = useState(MONSTER_DEFAULT_INIT_MOD);
const [maxHp, setMaxHp] = useState(DEFAULT_MAX_HP);
const [isNpc, setIsNpc] = useState(false);
const [editingParticipant, setEditingParticipant] = useState(null);
const [hpChangeValues, setHpChangeValues] = useState({});
2025-05-25 21:19:22 -04:00
const [draggedItemId, setDraggedItemId] = useState(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [itemToDelete, setItemToDelete] = useState(null);
const [lastRollDetails, setLastRollDetails] = useState(null);
2026-04-26 10:37:25 -04:00
const [openConditionsId, setOpenConditionsId] = useState(null);
2025-05-25 21:19:22 -04:00
const participants = encounter.participants || [];
2025-05-26 21:48:28 -04:00
useEffect(() => {
if (participantType === 'character' && selectedCharacterId) {
const selectedChar = campaignCharacters.find(c => c.id === selectedCharacterId);
if (selectedChar && selectedChar.defaultMaxHp) {
setMaxHp(selectedChar.defaultMaxHp);
} else {
setMaxHp(DEFAULT_MAX_HP);
}
setIsNpc(false);
} else if (participantType === 'monster') {
setMaxHp(DEFAULT_MAX_HP);
setMonsterInitMod(MONSTER_DEFAULT_INIT_MOD);
2025-05-27 10:51:29 -04:00
}
2025-05-26 21:48:28 -04:00
}, [selectedCharacterId, participantType, campaignCharacters]);
2025-05-25 21:19:22 -04:00
const handleAddParticipant = async () => {
if (!db) return;
if (participantType === 'monster' && !participantName.trim()) return;
if (participantType === 'character' && !selectedCharacterId) return;
2025-05-25 21:19:22 -04:00
let nameToAdd = participantName.trim();
2025-05-26 22:31:43 -04:00
const initiativeRoll = rollD20();
let modifier = 0;
let currentMaxHp = parseInt(maxHp, 10) || DEFAULT_MAX_HP;
2025-05-27 10:51:29 -04:00
let participantIsNpc = false;
2025-05-26 21:48:28 -04:00
2025-05-25 21:19:22 -04:00
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;
}
2025-05-25 21:19:22 -04:00
nameToAdd = character.name;
currentMaxHp = character.defaultMaxHp || currentMaxHp;
2025-05-26 22:31:43 -04:00
modifier = character.defaultInitMod || 0;
} else {
modifier = parseInt(monsterInitMod, 10) || 0;
participantIsNpc = isNpc;
2025-05-25 21:19:22 -04:00
}
const finalInitiative = initiativeRoll + modifier;
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,
deathSaves: 0, // Track failed death saves (0-3)
isDying: false, // For death animation on player display
2025-05-27 10:51:29 -04:00
};
try {
await storage.updateDoc(encounterPath, {
participants: [...participants, newParticipant]
});
logAction(`${nameToAdd} added to encounter (Initiative: ${finalInitiative})`, { encounterName: encounter.name }, {
encounterPath,
updates: { participants: [...participants] },
});
setLastRollDetails({
name: nameToAdd,
roll: initiativeRoll,
mod: modifier,
total: finalInitiative,
type: participantIsNpc ? 'NPC' : participantType
});
setTimeout(() => setLastRollDetails(null), ROLL_DISPLAY_DURATION);
// Reset form
setParticipantName('');
setMaxHp(DEFAULT_MAX_HP);
setSelectedCharacterId('');
setMonsterInitMod(MONSTER_DEFAULT_INIT_MOD);
setIsNpc(false);
} catch (err) {
console.error("Error adding participant:", err);
alert("Failed to add participant. Please try again.");
}
2025-05-25 21:19:22 -04:00
};
2025-05-26 21:48:28 -04:00
const handleAddAllCampaignCharacters = async () => {
if (!db || !campaignCharacters || campaignCharacters.length === 0) return;
const existingCharacterIds = participants
.filter(p => p.type === 'character' && p.originalCharacterId)
.map(p => p.originalCharacterId);
2025-05-26 21:48:28 -04:00
const newParticipants = campaignCharacters
.filter(char => !existingCharacterIds.includes(char.id))
.map(char => {
const initiativeRoll = rollD20();
const modifier = char.defaultInitMod || 0;
const finalInitiative = initiativeRoll + modifier;
return {
id: generateId(),
name: char.name,
type: 'character',
originalCharacterId: char.id,
initiative: finalInitiative,
maxHp: char.defaultMaxHp || DEFAULT_MAX_HP,
currentHp: char.defaultMaxHp || DEFAULT_MAX_HP,
conditions: [],
isActive: true,
isNpc: false,
deathSaves: 0,
isDying: false,
};
});
if (newParticipants.length === 0) {
alert("All campaign characters are already in this encounter.");
return;
}
try {
await storage.updateDoc(encounterPath, {
participants: [...participants, ...newParticipants]
});
console.log(`Added ${newParticipants.length} characters to the encounter.`);
} catch (err) {
console.error("Error adding all campaign characters:", err);
alert("Failed to add all characters. Please try again.");
}
2025-05-26 21:48:28 -04:00
};
2025-05-25 21:19:22 -04:00
const handleUpdateParticipant = async (updatedData) => {
2025-05-25 22:21:45 -04:00
if (!db || !editingParticipant) return;
const updatedParticipants = participants.map(p =>
p.id === editingParticipant.id ? { ...p, ...updatedData } : p
);
try {
await storage.updateDoc(encounterPath, { participants: updatedParticipants });
setEditingParticipant(null);
} catch (err) {
console.error("Error updating participant:", err);
alert("Failed to update participant. Please try again.");
}
};
const requestDeleteParticipant = (participantId, participantName) => {
setItemToDelete({ id: participantId, name: participantName });
setShowDeleteConfirm(true);
2025-05-25 21:19:22 -04:00
};
const confirmDeleteParticipant = async () => {
if (!db || !itemToDelete) return;
const updatedParticipants = participants.filter(p => p.id !== itemToDelete.id);
const deleteUndoData = {
encounterPath,
updates: {
participants: [...participants],
...(encounter.isStarted ? {
currentTurnParticipantId: encounter.currentTurnParticipantId,
turnOrderIds: [...(encounter.turnOrderIds || [])],
} : {}),
},
};
try {
await storage.updateDoc(encounterPath, {
participants: updatedParticipants,
...computeTurnOrderAfterRemoval(encounter, itemToDelete.id, updatedParticipants)
});
logAction(`${itemToDelete.name} removed from encounter`, { encounterName: encounter.name }, deleteUndoData);
} catch (err) {
console.error("Error deleting participant:", err);
alert("Failed to delete participant. Please try again.");
}
setShowDeleteConfirm(false);
setItemToDelete(null);
2025-05-25 21:19:22 -04:00
};
2025-05-25 21:19:22 -04:00
const toggleParticipantActive = async (participantId) => {
2025-05-25 22:21:45 -04:00
if (!db) return;
const participant = participants.find(p => p.id === participantId);
if (!participant) return;
const newIsActive = !participant.isActive;
const updatedParticipants = participants.map(p =>
p.id === participantId ? { ...p, isActive: newIsActive } : p
);
const turnUpdates = newIsActive
? computeTurnOrderAfterAddition(encounter, participantId)
: computeTurnOrderAfterRemoval(encounter, participantId, updatedParticipants);
try {
await storage.updateDoc(encounterPath, { participants: updatedParticipants, ...turnUpdates });
logAction(`${participant.name} ${newIsActive ? 'reactivated' : 'deactivated'}`, { encounterName: encounter.name }, {
encounterPath,
updates: {
participants: [...participants],
...(encounter.isStarted ? {
currentTurnParticipantId: encounter.currentTurnParticipantId,
turnOrderIds: [...(encounter.turnOrderIds || [])],
} : {}),
},
});
} catch (err) {
console.error("Error toggling active state:", err);
}
2025-05-25 21:19:22 -04:00
};
2025-05-25 21:19:22 -04:00
const applyHpChange = async (participantId, changeType) => {
2025-05-25 22:21:45 -04:00
if (!db) return;
2025-05-25 21:19:22 -04:00
const amountStr = hpChangeValues[participantId];
if (!amountStr || amountStr.trim() === '') return;
2025-05-25 21:19:22 -04:00
const amount = parseInt(amountStr, 10);
if (isNaN(amount) || amount === 0) {
setHpChangeValues(prev => ({ ...prev, [participantId]: '' }));
return;
}
const participant = participants.find(p => p.id === participantId);
if (!participant) return;
let newHp = participant.currentHp;
if (changeType === 'damage') {
newHp = Math.max(0, participant.currentHp - amount);
} else if (changeType === 'heal') {
newHp = Math.min(participant.maxHp, participant.currentHp + amount);
}
// Determine if participant died or was resurrected
const wasDead = participant.currentHp === 0;
const isDead = newHp === 0;
const wasResurrected = wasDead && newHp > 0;
const updatedParticipants = participants.map(p => {
if (p.id === participantId) {
const updates = { ...p, currentHp: newHp };
// Handle death - deactivate and start death saves
if (isDead && !wasDead) {
updates.isActive = false;
updates.deathSaves = p.deathSaves || 0;
updates.isDying = false;
}
// Handle resurrection - reactivate and reset death saves
if (wasResurrected) {
updates.isActive = true;
updates.deathSaves = 0;
updates.isDying = false;
}
return updates;
}
return p;
});
const turnUpdates = (isDead && !wasDead)
? computeTurnOrderAfterRemoval(encounter, participantId, updatedParticipants)
: wasResurrected
? computeTurnOrderAfterAddition(encounter, participantId)
: {};
const hpUndoData = {
encounterPath,
updates: {
participants: [...participants],
...((isDead && !wasDead) || wasResurrected ? {
currentTurnParticipantId: encounter.currentTurnParticipantId,
turnOrderIds: [...(encounter.turnOrderIds || [])],
} : {}),
},
};
try {
await storage.updateDoc(encounterPath, { participants: updatedParticipants, ...turnUpdates });
setHpChangeValues(prev => ({ ...prev, [participantId]: '' }));
2026-05-16 10:25:17 -04:00
const hpLine = `${participant.currentHp}${newHp} HP`;
const deathSuffix = (isDead && !wasDead) ? (participant.type === 'character' ? ' — Unconscious' : ' — Defeated') : '';
const resurSuffix = wasResurrected ? ' — Revived' : '';
if (changeType === 'damage') {
logAction(`${participant.name} took ${amount} damage (${hpLine})${deathSuffix}`, { encounterName: encounter.name }, hpUndoData);
2026-05-16 10:25:17 -04:00
} else {
logAction(`${participant.name} healed for ${amount} (${hpLine})${resurSuffix}`, { encounterName: encounter.name }, hpUndoData);
2026-05-16 10:25:17 -04:00
}
} catch (err) {
console.error("Error applying HP change:", err);
}
};
const handleDeathSaveChange = async (participantId, saveNumber) => {
if (!db) return;
const participant = participants.find(p => p.id === participantId);
if (!participant) return;
const currentSaves = participant.deathSaves || 0;
const newSaves = currentSaves === saveNumber ? saveNumber - 1 : saveNumber;
// If clicking the third death save, mark as dying (for player view animation)
if (newSaves === 3) {
const updatedParticipants = participants.map(p =>
p.id === participantId ? { ...p, deathSaves: newSaves, isDying: true } : p
);
try {
await storage.updateDoc(encounterPath, { participants: updatedParticipants });
// Wait for animation to complete on player display (2 seconds) then remove participant
setTimeout(async () => {
const finalParticipants = participants.filter(p => p.id !== participantId);
try {
await storage.updateDoc(encounterPath, { participants: finalParticipants });
} catch (err) {
console.error("Error removing dead participant:", err);
}
}, 2000);
} catch (err) {
console.error("Error marking participant as dying:", err);
}
} else {
// Normal death save update
const updatedParticipants = participants.map(p =>
p.id === participantId ? { ...p, deathSaves: newSaves } : p
);
try {
await storage.updateDoc(encounterPath, { participants: updatedParticipants });
} catch (err) {
console.error("Error updating death saves:", err);
}
}
};
2026-04-26 10:37:25 -04:00
const toggleCondition = async (participantId, conditionId) => {
if (!db) return;
2026-05-16 10:25:17 -04:00
const participant = participants.find(p => p.id === participantId);
if (!participant) return;
const wasActive = (participant.conditions || []).includes(conditionId);
2026-04-26 10:37:25 -04:00
const updatedParticipants = participants.map(p => {
if (p.id !== participantId) return p;
const current = p.conditions || [];
2026-05-16 10:25:17 -04:00
const next = wasActive
2026-04-26 10:37:25 -04:00
? current.filter(c => c !== conditionId)
: [...current, conditionId];
return { ...p, conditions: next };
});
try {
await storage.updateDoc(encounterPath, { participants: updatedParticipants });
2026-05-16 10:25:17 -04:00
const cond = CONDITIONS.find(c => c.id === conditionId);
const condLabel = cond ? `${cond.label} ${cond.emoji}` : conditionId;
logAction(`${participant.name} ${wasActive ? 'lost' : 'gained'} ${condLabel}`, { encounterName: encounter.name }, {
encounterPath,
updates: { participants: [...participants] },
});
2026-04-26 10:37:25 -04:00
} catch (err) {
console.error("Error updating conditions:", err);
}
};
const handleDragStart = (e, id) => {
setDraggedItemId(id);
e.dataTransfer.effectAllowed = 'move';
2025-05-25 21:19:22 -04:00
};
const handleDragOver = (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
};
2025-05-25 21:19:22 -04:00
const handleDrop = async (e, targetId) => {
e.preventDefault();
if (!db || draggedItemId === null || draggedItemId === targetId) {
setDraggedItemId(null);
return;
}
const currentParticipants = [...participants];
const draggedIndex = currentParticipants.findIndex(p => p.id === draggedItemId);
const targetIndex = currentParticipants.findIndex(p => p.id === targetId);
if (draggedIndex === -1 || targetIndex === -1) {
console.error("Dragged or target item not found.");
setDraggedItemId(null);
return;
}
const draggedItem = currentParticipants[draggedIndex];
const targetItem = currentParticipants[targetIndex];
if (draggedItem.initiative !== targetItem.initiative) {
console.log("Drag-drop only allowed for participants with same initiative.");
setDraggedItemId(null);
return;
}
const [removedItem] = currentParticipants.splice(draggedIndex, 1);
currentParticipants.splice(targetIndex, 0, removedItem);
try {
await storage.updateDoc(encounterPath, { participants: currentParticipants });
} catch (err) {
console.error("Error reordering participants:", err);
}
setDraggedItemId(null);
2025-05-25 21:19:22 -04:00
};
const sortedParticipants = sortParticipantsByInitiative(participants, participants);
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);
2025-05-25 21:19:22 -04:00
return (
<>
2026-04-25 20:25:34 -04:00
<div className="p-3 bg-stone-900 rounded-md mt-4">
<div className="flex justify-between items-center mb-3">
2026-04-25 18:37:55 -04:00
<h4 className="text-lg font-medium text-amber-200 font-cinzel tracking-wide">Add Participants</h4>
<button
onClick={handleAddAllCampaignCharacters}
2026-04-25 20:25:34 -04:00
className="px-3 py-1.5 text-xs font-medium text-white bg-violet-700 hover:bg-violet-800 rounded-md transition-colors flex items-center"
disabled={!campaignCharacters || campaignCharacters.length === 0 || (encounter.isStarted && !encounter.isPaused)}
>
<Users2 size={16} className="mr-1.5" />
<Dices size={16} className="mr-1.5" /> Add All (Roll Init)
</button>
2025-05-25 21:19:22 -04:00
</div>
2025-05-27 11:02:03 -04:00
{/* Warning when combat is active */}
{encounter.isStarted && !encounter.isPaused && (
<div className="mb-4 p-3 bg-yellow-900 bg-opacity-30 border border-yellow-600 rounded-md flex items-start">
<AlertTriangle size={20} className="text-yellow-400 mr-3 flex-shrink-0 mt-0.5" />
<div>
<p className="text-yellow-300 font-semibold text-sm">Combat is Running</p>
<p className="text-yellow-200 text-xs mt-1">
2026-04-25 20:25:34 -04:00
Pause combat to add or remove participants. The turn order will be recalculated when combat is resumed.
</p>
2025-05-27 11:02:03 -04:00
</div>
</div>
2025-05-26 22:31:43 -04:00
)}
<form
onSubmit={(e) => {
e.preventDefault();
handleAddParticipant();
}}
2026-04-25 20:25:34 -04:00
className="grid grid-cols-1 md:grid-cols-6 gap-x-4 gap-y-2 mb-4 p-3 bg-stone-800 rounded items-end"
>
<div className="md:col-span-2">
2026-04-25 20:25:34 -04:00
<label className="block text-sm font-medium text-stone-300">Type</label>
<select
value={participantType}
onChange={(e) => {
setParticipantType(e.target.value);
setSelectedCharacterId('');
setIsNpc(false);
}}
2026-04-25 20:25:34 -04:00
className="mt-1 w-full px-3 py-2 bg-stone-700 border-stone-600 rounded text-white"
>
<option value="monster">Monster</option>
<option value="character">Character</option>
</select>
</div>
{participantType === 'monster' ? (
<>
<div className="md:col-span-4">
2026-04-25 20:25:34 -04:00
<label htmlFor="monsterName" className="block text-sm font-medium text-stone-300">
Monster Name
</label>
<input
type="text"
id="monsterName"
value={participantName}
onChange={(e) => setParticipantName(e.target.value)}
placeholder="e.g., Dire Wolf"
2026-04-25 20:25:34 -04:00
className="mt-1 w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
/>
</div>
<div className="md:col-span-2">
2026-04-25 20:25:34 -04:00
<label htmlFor="monsterInitMod" className="block text-sm font-medium text-stone-300">
Init Mod
</label>
<input
type="number"
id="monsterInitMod"
value={monsterInitMod}
onChange={(e) => setMonsterInitMod(e.target.value)}
2026-04-25 20:25:34 -04:00
className="mt-1 w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
/>
</div>
<div className="md:col-span-2">
2026-04-25 20:25:34 -04:00
<label htmlFor="monsterMaxHp" className="block text-sm font-medium text-stone-300">
Max HP
</label>
<input
type="number"
id="monsterMaxHp"
value={maxHp}
onChange={(e) => setMaxHp(e.target.value)}
2026-04-25 20:25:34 -04:00
className="mt-1 w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 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)}
2026-04-25 20:25:34 -04:00
className="h-4 w-4 text-violet-600 border-stone-400 rounded focus:ring-violet-500"
/>
2026-04-25 20:25:34 -04:00
<label htmlFor="isNpc" className="ml-2 block text-sm text-stone-300">
Is NPC?
</label>
</div>
</>
) : (
<>
<div className="md:col-span-4">
2026-04-25 20:25:34 -04:00
<label className="block text-sm font-medium text-stone-300">Select Character</label>
<select
value={selectedCharacterId}
onChange={(e) => setSelectedCharacterId(e.target.value)}
2026-04-25 20:25:34 -04:00
className="mt-1 w-full px-3 py-2 bg-stone-700 border-stone-600 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: {formatInitMod(c.defaultInitMod)})
</option>
))}
2025-05-27 11:02:03 -04:00
</select>
2025-05-25 21:19:22 -04:00
</div>
<div className="md:col-span-2">
2026-04-25 20:25:34 -04:00
<label className="block text-sm font-medium text-stone-300">Max HP (Encounter)</label>
<input
type="number"
value={maxHp}
onChange={(e) => setMaxHp(e.target.value)}
2026-04-25 20:25:34 -04:00
className="mt-1 w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
/>
2025-05-25 21:19:22 -04:00
</div>
</>
)}
2025-05-25 21:19:22 -04:00
<div className="md:col-span-6 flex justify-end mt-2">
<button
type="submit"
disabled={encounter.isStarted && !encounter.isPaused}
2026-04-25 20:25:34 -04:00
className={`px-4 py-2 text-sm font-medium text-white rounded-md transition-colors flex items-center ${encounter.isStarted && !encounter.isPaused ? 'bg-stone-600 cursor-not-allowed opacity-50' : 'bg-red-700 hover:bg-red-800'}`}
title={encounter.isStarted && !encounter.isPaused ? 'Pause combat to add participants' : 'Add participant and roll initiative'}
>
<Dices size={18} className="mr-1.5" /> Add to Encounter (Roll Init)
</button>
</div>
</form>
2025-05-27 10:51:29 -04:00
{lastRollDetails && (
<p className="text-sm text-green-400 mt-2 mb-2 text-center">
2026-04-25 20:25:34 -04:00
{lastRollDetails.name} ({lastRollDetails.type === 'character' ? 'Character' : lastRollDetails.type === 'monster' ? 'Monster' : lastRollDetails.type})
: Rolled d20 ({lastRollDetails.roll}) {formatInitMod(lastRollDetails.mod)} = {lastRollDetails.total} Initiative
</p>
2025-05-27 10:51:29 -04:00
)}
2026-04-25 20:25:34 -04:00
{participants.length === 0 && <p className="text-sm text-stone-400">No participants added yet.</p>}
<ul className="space-y-2">
{sortedParticipants.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';
2026-04-25 20:25:34 -04:00
let bgColor = p.type === 'character' ? 'bg-indigo-950' : (p.isNpc ? 'bg-stone-700' : 'bg-[#8e351c]');
if (isCurrentTurn && !encounter.isPaused) bgColor = 'bg-green-600';
const isDead = p.currentHp === 0;
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={() => setDraggedItemId(null)}
className={`p-3 rounded-md flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 transition-all duration-300 ${bgColor} ${isCurrentTurn && !encounter.isPaused ? 'ring-2 ring-green-300 shadow-lg' : ''} ${!p.isActive && !isDead ? 'opacity-50' : ''} ${isDraggable ? 'cursor-grab' : ''} ${draggedItemId === p.id ? 'opacity-50 ring-2 ring-yellow-400' : ''}`}
>
<div className="flex-1 flex items-start sm:items-center">
{isDraggable && (
<ChevronsUpDown
size={18}
2026-04-25 20:25:34 -04:00
className="mr-2 text-stone-400 flex-shrink-0"
title="Drag to reorder in tie"
/>
)}
<div className="flex-1">
<p className="font-semibold text-lg text-white">
{isDead && <span className="mr-2">☠️</span>}
{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>
)}
2026-04-25 20:25:34 -04:00
{isDead && <span className="ml-2 text-xs text-red-300 font-semibold">{p.type === 'character' ? '(Unconscious)' : '(Dead)'}</span>}
</p>
2026-04-25 20:25:34 -04:00
<p className={`text-sm ${isCurrentTurn && !encounter.isPaused ? 'text-green-100' : 'text-stone-200'}`}>
Init: {p.initiative} | HP: {p.currentHp}/{p.maxHp}
</p>
2026-04-25 20:25:34 -04:00
{/* Death Saves - only player characters make death saving throws */}
{isDead && encounter.isStarted && p.type === 'character' && (
<div className="mt-2 flex items-center space-x-2">
<span className="text-xs text-red-300 font-medium">Death Saves:</span>
{[1, 2, 3].map(saveNum => (
<button
key={saveNum}
onClick={() => handleDeathSaveChange(p.id, saveNum)}
2026-04-25 20:25:34 -04:00
className={`w-6 h-6 rounded border-2 transition-all ${(p.deathSaves || 0) >= saveNum ? 'bg-red-600 border-red-500' : 'bg-stone-800 border-stone-600 hover:border-red-400'} ${saveNum === 3 && (p.deathSaves || 0) === 3 ? 'animate-pulse' : ''}`}
title={`Death save ${saveNum}`}
>
{(p.deathSaves || 0) >= saveNum && <span className="text-white text-sm"></span>}
</button>
))}
</div>
)}
2026-04-26 10:37:25 -04:00
{/* Active condition badges */}
{(p.conditions || []).length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{(p.conditions || []).map(cId => {
const cond = CONDITIONS.find(c => c.id === cId);
return cond ? (
<span
key={cId}
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-full bg-purple-900 border border-purple-600 text-xs text-purple-200 cursor-pointer hover:bg-purple-800"
title={`Remove ${cond.label}`}
onClick={() => toggleCondition(p.id, cId)}
>
{cond.emoji} {cond.label}
</span>
) : null;
})}
</div>
)}
{/* Expandable conditions picker */}
{openConditionsId === p.id && (
<div className="mt-2 p-2 bg-stone-900 rounded-md border border-stone-600">
<p className="text-xs text-stone-400 mb-1 font-medium">Toggle Conditions</p>
<div className="flex flex-wrap gap-1">
{CONDITIONS.map(cond => {
const active = (p.conditions || []).includes(cond.id);
return (
<button
key={cond.id}
onClick={() => toggleCondition(p.id, cond.id)}
className={`px-2 py-1 rounded text-xs transition-colors ${active ? 'bg-purple-700 border border-purple-400 text-white' : 'bg-stone-700 border border-stone-500 text-stone-300 hover:bg-stone-600'}`}
title={cond.label}
>
{cond.emoji} {cond.label}
</button>
);
})}
</div>
</div>
)}
</div>
</div>
<div className="flex flex-wrap items-center space-x-2 mt-2 sm:mt-0">
{encounter.isStarted && (
2026-04-25 20:25:34 -04:00
<div className="flex items-center space-x-1 bg-stone-800 p-1 rounded-md">
<input
type="number"
placeholder="HP"
value={hpChangeValues[p.id] || ''}
onChange={(e) => setHpChangeValues(prev => ({ ...prev, [p.id]: e.target.value }))}
2026-04-25 20:25:34 -04:00
className="w-16 p-1 text-sm bg-stone-700 border border-stone-600 rounded-md text-white focus:ring-amber-600 focus:border-amber-600"
aria-label={`HP change for ${p.name}`}
/>
{!isDead && (
<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')}
2026-04-25 20:25:34 -04:00
className="p-1 bg-emerald-600 hover:bg-emerald-700 text-white rounded-md text-xs"
title={isDead ? "Heal / Revive" : "Heal"}
>
<HeartPulse size={16} />
</button>
</div>
)}
{!isDead && (
<button
onClick={() => toggleParticipantActive(p.id)}
2026-04-25 20:25:34 -04:00
className={`p-1 rounded transition-colors ${p.isActive ? 'text-yellow-400 hover:text-yellow-300' : 'text-stone-400 hover:text-stone-300'} bg-stone-700 hover:bg-stone-600`}
title={p.isActive ? "Mark Inactive" : "Mark Active"}
>
{p.isActive ? <UserCheck size={18} /> : <UserX size={18} />}
</button>
)}
2026-04-26 10:37:25 -04:00
<button
onClick={() => setOpenConditionsId(openConditionsId === p.id ? null : p.id)}
className={`p-1 rounded transition-colors bg-stone-700 hover:bg-stone-600 ${openConditionsId === p.id || (p.conditions || []).length > 0 ? 'text-purple-400 hover:text-purple-300' : 'text-stone-400 hover:text-stone-300'}`}
title="Conditions"
>
<span className="text-base leading-none"></span>
</button>
<button
onClick={() => setEditingParticipant(p)}
2026-04-25 20:25:34 -04:00
className="p-1 rounded transition-colors text-yellow-400 hover:text-yellow-300 bg-stone-700 hover:bg-stone-600"
title="Edit"
>
<Edit3 size={18} />
</button>
<button
onClick={() => requestDeleteParticipant(p.id, p.name)}
2026-04-25 20:25:34 -04:00
className="p-1 rounded transition-colors text-red-400 hover:text-red-300 bg-stone-700 hover:bg-stone-600"
title="Remove"
>
<Trash2 size={18} />
</button>
</div>
</li>
);
})}
</ul>
{editingParticipant && (
<EditParticipantModal
participant={editingParticipant}
onClose={() => setEditingParticipant(null)}
onSave={handleUpdateParticipant}
/>
)}
</div>
<ConfirmationModal
isOpen={showDeleteConfirm}
onClose={() => setShowDeleteConfirm(false)}
onConfirm={confirmDeleteParticipant}
title="Delete Participant?"
message={`Are you sure you want to remove "${itemToDelete?.name}" from this encounter?`}
/>
</>
2025-05-25 21:19:22 -04:00
);
}
// ============================================================================
// INITIATIVE CONTROLS COMPONENT
// ============================================================================
function InitiativeControls({ campaignId, encounter, encounterPath }) {
const [showEndConfirm, setShowEndConfirm] = useState(false);
const { data: activeDisplayData } = useFirestoreDocument(getPath.activeDisplay());
const hidePlayerHp = activeDisplayData?.hidePlayerHp ?? true;
const handleToggleHidePlayerHp = async () => {
if (!db) return;
try {
await storage.setDoc(getPath.activeDisplay(), { hidePlayerHp: !hidePlayerHp }, { merge: true });
} catch (err) {
console.error("Error toggling hidePlayerHp:", err);
}
};
2025-05-25 21:19:22 -04:00
const handleStartEncounter = async () => {
if (!db || !encounter.participants || encounter.participants.length === 0) {
alert("Add participants first.");
return;
}
const activeParticipants = encounter.participants.filter(p => p.isActive);
if (activeParticipants.length === 0) {
alert("No active participants.");
return;
}
const sortedParticipants = sortParticipantsByInitiative(activeParticipants, encounter.participants);
2025-05-25 21:19:22 -04:00
try {
await storage.updateDoc(encounterPath, {
isStarted: true,
isPaused: false,
round: 1,
currentTurnParticipantId: sortedParticipants[0].id,
turnOrderIds: sortedParticipants.map(p => p.id)
});
await storage.setDoc(getPath.activeDisplay(), {
activeCampaignId: campaignId,
activeEncounterId: encounter.id
}, { merge: true });
logAction(`Combat started: "${encounter.name}" — ${sortedParticipants[0].name}'s turn (Round 1)`, { encounterName: encounter.name }, {
encounterPath,
updates: {
isStarted: encounter.isStarted ?? false,
isPaused: encounter.isPaused ?? false,
round: encounter.round ?? 0,
currentTurnParticipantId: encounter.currentTurnParticipantId ?? null,
turnOrderIds: [...(encounter.turnOrderIds || [])],
},
});
2025-05-25 21:19:22 -04:00
console.log("Encounter started and set as active display.");
} catch (err) {
console.error("Error starting encounter:", err);
alert("Failed to start encounter. Please try again.");
}
2025-05-25 21:19:22 -04:00
};
2025-05-26 21:34:37 -04:00
const handleTogglePause = async () => {
if (!db || !encounter || !encounter.isStarted) return;
2025-05-26 21:34:37 -04:00
const newPausedState = !encounter.isPaused;
let newTurnOrderIds = encounter.turnOrderIds;
if (!newPausedState && encounter.isPaused) {
const activeParticipants = encounter.participants.filter(p => p.isActive);
const sortedParticipants = sortParticipantsByInitiative(activeParticipants, encounter.participants);
newTurnOrderIds = sortedParticipants.map(p => p.id);
}
try {
await storage.updateDoc(encounterPath, {
isPaused: newPausedState,
turnOrderIds: newTurnOrderIds
});
logAction(`Combat ${newPausedState ? 'paused' : 'resumed'}: "${encounter.name}"`, { encounterName: encounter.name }, {
encounterPath,
updates: {
isPaused: encounter.isPaused ?? false,
turnOrderIds: [...(encounter.turnOrderIds || [])],
},
});
} catch (err) {
console.error("Error toggling pause state:", err);
2025-05-26 21:34:37 -04:00
}
};
2025-05-25 21:19:22 -04:00
const handleNextTurn = async () => {
if (!db || !encounter.isStarted || encounter.isPaused) return;
if (!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 storage.updateDoc(encounterPath, {
isStarted: false,
isPaused: false,
currentTurnParticipantId: null,
round: encounter.round
});
return;
}
let currentIndex = activePsInOrder.findIndex(p => p.id === encounter.currentTurnParticipantId);
2025-05-25 21:19:22 -04:00
let nextRound = encounter.round;
// Current participant was removed; find the next one after their old position in turnOrderIds
if (currentIndex === -1) {
const rawPos = (encounter.turnOrderIds || []).indexOf(encounter.currentTurnParticipantId);
const candidateIds = [...(encounter.turnOrderIds || []).slice(rawPos + 1), ...(encounter.turnOrderIds || []).slice(0, rawPos)];
const nextP = candidateIds.map(id => activePsInOrder.find(p => p.id === id)).find(Boolean);
currentIndex = nextP ? activePsInOrder.findIndex(p => p.id === nextP.id) - 1 : -1;
}
let nextIndex = (currentIndex + 1) % activePsInOrder.length;
let newTurnOrderIds = encounter.turnOrderIds;
if (nextIndex === 0 && currentIndex !== -1) {
nextRound += 1;
// Rebuild turn order by initiative at the start of each new round so that participants
// activated mid-round (appended to the end) slot into proper initiative position next round.
const activePs = encounter.participants.filter(p => p.isActive);
const sorted = sortParticipantsByInitiative(activePs, encounter.participants);
newTurnOrderIds = sorted.map(p => p.id);
}
// When wrapping to a new round the next participant is first in the rebuilt order
const nextParticipant = (nextIndex === 0 && currentIndex !== -1)
? encounter.participants.find(p => p.id === newTurnOrderIds[0])
: activePsInOrder[nextIndex];
if (!nextParticipant) return;
try {
await storage.updateDoc(encounterPath, {
currentTurnParticipantId: nextParticipant.id,
round: nextRound,
turnOrderIds: newTurnOrderIds,
});
logAction(`${nextParticipant.name}'s turn (Round ${nextRound})`, { encounterName: encounter.name }, {
encounterPath,
updates: {
currentTurnParticipantId: encounter.currentTurnParticipantId,
round: encounter.round,
turnOrderIds: [...encounter.turnOrderIds],
},
});
} catch (err) {
console.error("Error advancing turn:", err);
}
2025-05-25 21:19:22 -04:00
};
const confirmEndEncounter = async () => {
2025-05-25 22:21:45 -04:00
if (!db) return;
2025-05-25 21:19:22 -04:00
try {
await storage.updateDoc(encounterPath, {
isStarted: false,
isPaused: false,
currentTurnParticipantId: null,
round: 0,
turnOrderIds: []
});
await storage.setDoc(getPath.activeDisplay(), {
activeCampaignId: null,
activeEncounterId: null
}, { merge: true });
logAction(`Combat ended: "${encounter.name}"`, { encounterName: encounter.name }, {
encounterPath,
updates: {
isStarted: encounter.isStarted ?? false,
isPaused: encounter.isPaused ?? false,
round: encounter.round ?? 0,
currentTurnParticipantId: encounter.currentTurnParticipantId ?? null,
turnOrderIds: [...(encounter.turnOrderIds || [])],
},
});
2025-05-26 07:59:05 -04:00
console.log("Encounter ended and deactivated from Player Display.");
} catch (err) {
console.error("Error ending encounter:", err);
}
setShowEndConfirm(false);
2025-05-25 21:19:22 -04:00
};
2025-05-25 21:19:22 -04:00
if (!encounter || !encounter.participants) return null;
2025-05-25 21:19:22 -04:00
return (
<>
2026-04-25 20:25:34 -04:00
<div className="lg:sticky lg:top-4 p-4 bg-stone-900 rounded-md shadow-lg">
2026-04-25 18:37:55 -04:00
<h4 className="text-lg font-medium text-amber-200 mb-4 text-center font-cinzel tracking-wide">Combat Controls</h4>
<div className="flex flex-col gap-3">
{!encounter.isStarted ? (
<button
onClick={handleStartEncounter}
2026-04-25 20:25:34 -04:00
className="w-full px-4 py-3 text-sm font-medium text-white bg-red-700 hover:bg-red-800 rounded-md transition-colors flex items-center justify-center"
disabled={!encounter.participants || encounter.participants.filter(p => p.isActive).length === 0}
>
<PlayIcon size={18} className="mr-2" /> Start Combat
2025-05-26 21:34:37 -04:00
</button>
) : (
<>
<button
onClick={handleTogglePause}
2026-04-25 20:25:34 -04:00
className={`w-full px-4 py-3 text-sm font-medium text-white rounded-md transition-colors flex items-center justify-center ${encounter.isPaused ? 'bg-red-700 hover:bg-red-800' : 'bg-amber-600 hover:bg-amber-700'}`}
title={encounter.isPaused
? 'Resume combat from the current turn. The initiative order will be recalculated to include any participants added while paused.'
: 'Pause combat to freeze the turn order. While paused, you can add or remove participants, adjust HP, and edit initiative values. The turn order will be recalculated when you resume.'}
>
{encounter.isPaused ? <PlayIcon size={18} className="mr-2" /> : <PauseIcon size={18} className="mr-2" />}
2026-04-25 20:25:34 -04:00
{encounter.isPaused ? 'Resume Combat' : 'Pause Combat'}
</button>
<button
onClick={handleNextTurn}
2026-04-25 20:25:34 -04:00
className="w-full px-4 py-3 text-sm font-medium text-white bg-purple-700 hover:bg-purple-800 rounded-md transition-colors flex items-center justify-center"
disabled={!encounter.currentTurnParticipantId || encounter.isPaused}
>
<SkipForwardIcon size={18} className="mr-2" /> Next Turn
</button>
<button
onClick={() => setShowEndConfirm(true)}
className="w-full px-4 py-3 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-md transition-colors flex items-center justify-center"
>
<StopCircleIcon size={18} className="mr-2" /> End Combat
</button>
{/* Round Counter */}
2026-04-25 20:25:34 -04:00
<div className="mt-2 pt-3 border-t border-stone-700">
2026-04-25 18:37:55 -04:00
<p className="text-center text-lg font-semibold text-amber-300 font-cinzel">Round: {encounter.round}</p>
{encounter.isPaused && (
<p className="text-center text-sm text-yellow-400 font-semibold mt-1">(Paused)</p>
)}
</div>
</>
)}
</div>
{/* Display Settings */}
<div className="mt-3 pt-3 border-t border-stone-700">
<h5 className="text-xs font-semibold text-stone-400 uppercase tracking-wider mb-2">Player Display</h5>
<label className="flex items-center justify-between cursor-pointer gap-2">
<span className="text-sm text-stone-300">Hide player HP</span>
<button
role="switch"
aria-checked={hidePlayerHp}
onClick={handleToggleHidePlayerHp}
className={`relative inline-flex h-5 w-9 flex-shrink-0 rounded-full border-2 border-transparent transition-colors focus:outline-none ${hidePlayerHp ? 'bg-amber-600' : 'bg-stone-600'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${hidePlayerHp ? 'translate-x-4' : 'translate-x-0'}`} />
</button>
</label>
</div>
</div>
<ConfirmationModal
isOpen={showEndConfirm}
onClose={() => setShowEndConfirm(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."
/>
</>
);
}
// ============================================================================
// ENCOUNTER MANAGER COMPONENT
// ============================================================================
function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharacters }) {
const { data: encountersData, isLoading: isLoadingEncounters } = useFirestoreCollection(
campaignId ? getPath.encounters(campaignId) : null
);
const { data: activeDisplayInfo } = useFirestoreDocument(getPath.activeDisplay());
const [encounters, setEncounters] = useState([]);
const [selectedEncounterId, setSelectedEncounterId] = useState(null);
const [showCreateModal, setShowCreateModal] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = 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 (
activeDisplayInfo &&
activeDisplayInfo.activeCampaignId === campaignId &&
encounters.some(e => e.id === activeDisplayInfo.activeEncounterId)
) {
setSelectedEncounterId(activeDisplayInfo.activeEncounterId);
}
}
} else if (encounters && encounters.length === 0) {
setSelectedEncounterId(null);
}
}, [campaignId, initialActiveEncounterId, activeDisplayInfo, encounters]);
const handleCreateEncounter = async (name) => {
if (!db || !name.trim() || !campaignId) return;
const newEncounterId = generateId();
try {
await storage.setDoc(`${getPath.encounters(campaignId)}/${newEncounterId}`, {
name: name.trim(),
createdAt: new Date().toISOString(),
participants: [],
round: 0,
currentTurnParticipantId: null,
isStarted: false,
isPaused: false
});
setShowCreateModal(false);
setSelectedEncounterId(newEncounterId);
} catch (err) {
console.error("Error creating encounter:", err);
alert("Failed to create encounter. Please try again.");
}
};
const requestDeleteEncounter = (encounterId, encounterName) => {
setItemToDelete({ id: encounterId, name: encounterName });
setShowDeleteConfirm(true);
};
const confirmDeleteEncounter = async () => {
if (!db || !itemToDelete) return;
const encounterId = itemToDelete.id;
try {
await storage.deleteDoc(getPath.encounter(campaignId, encounterId));
if (selectedEncounterId === encounterId) {
setSelectedEncounterId(null);
}
if (activeDisplayInfo && activeDisplayInfo.activeEncounterId === encounterId) {
await storage.updateDoc(getPath.activeDisplay(), {
activeCampaignId: null,
activeEncounterId: null
});
}
} catch (err) {
console.error("Error deleting encounter:", err);
alert("Failed to delete encounter. Please try again.");
}
setShowDeleteConfirm(false);
setItemToDelete(null);
};
const handleTogglePlayerDisplay = async (encounterId) => {
if (!db) return;
try {
const currentActiveCampaign = activeDisplayInfo?.activeCampaignId;
const currentActiveEncounter = activeDisplayInfo?.activeEncounterId;
if (currentActiveCampaign === campaignId && currentActiveEncounter === encounterId) {
await storage.setDoc(getPath.activeDisplay(), {
activeCampaignId: null,
activeEncounterId: null,
}, { merge: true });
} else {
await storage.setDoc(getPath.activeDisplay(), {
activeCampaignId: campaignId,
activeEncounterId: encounterId,
}, { merge: true });
}
} catch (err) {
console.error("Error toggling Player Display:", err);
}
};
const selectedEncounter = encounters?.find(e => e.id === selectedEncounterId);
if (isLoadingEncounters && campaignId) {
2026-04-25 20:25:34 -04:00
return <p className="text-center text-stone-300 mt-4">Loading encounters...</p>;
}
return (
<>
2026-04-25 20:25:34 -04:00
<div className="mt-6 p-4 bg-stone-900 rounded-lg shadow">
<div className="flex justify-between items-center mb-3">
2026-04-25 18:37:55 -04:00
<h3 className="text-xl font-semibold text-amber-300 font-cinzel tracking-wide flex items-center">
<Swords size={24} className="mr-2" /> Encounters
</h3>
<button
onClick={() => setShowCreateModal(true)}
2026-04-25 20:25:34 -04:00
className="px-4 py-2 text-sm font-medium text-white bg-amber-700 hover:bg-amber-800 rounded-md transition-colors flex items-center"
>
<PlusCircle size={18} className="mr-1" /> Create Encounter
</button>
</div>
{(!encounters || encounters.length === 0) && (
2026-04-25 20:25:34 -04:00
<p className="text-sm text-stone-400">No encounters yet.</p>
)}
<div className="space-y-3">
{encounters?.map(encounter => {
const isLive = activeDisplayInfo &&
activeDisplayInfo.activeCampaignId === campaignId &&
activeDisplayInfo.activeEncounterId === encounter.id;
return (
<div
key={encounter.id}
2026-04-25 20:25:34 -04:00
className={`p-3 rounded-md shadow transition-all ${selectedEncounterId === encounter.id ? 'bg-amber-900 ring-2 ring-amber-500' : 'bg-stone-800 hover:bg-stone-700'} ${isLive ? '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>
2026-04-25 20:25:34 -04:00
<p className="text-xs text-stone-300">
Participants: {encounter.participants?.length || 0}
</p>
{isLive && (
<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={() => handleTogglePlayerDisplay(encounter.id)}
2026-04-25 20:25:34 -04:00
className={`p-1 rounded transition-colors ${isLive ? 'bg-red-500 hover:bg-red-600 text-white' : 'text-amber-400 hover:text-amber-300 bg-stone-700 hover:bg-stone-600'}`}
title={isLive ? "Deactivate for Player Display" : "Activate for Player Display"}
>
{isLive ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
<button
onClick={(e) => {
e.stopPropagation();
requestDeleteEncounter(encounter.id, encounter.name);
}}
2026-04-25 20:25:34 -04:00
className="p-1 rounded transition-colors text-red-400 hover:text-red-300 bg-stone-700 hover:bg-stone-600"
title="Delete Encounter"
>
<Trash2 size={18} />
</button>
</div>
</div>
</div>
);
})}
</div>
{showCreateModal && (
<Modal onClose={() => setShowCreateModal(false)} title="Create New Encounter">
<CreateEncounterForm
onCreate={handleCreateEncounter}
onCancel={() => setShowCreateModal(false)}
/>
</Modal>
)}
{selectedEncounter && (
2026-04-25 20:25:34 -04:00
<div className="mt-6 p-4 bg-stone-900 rounded-lg shadow-inner">
2026-04-25 18:37:55 -04:00
<h3 className="text-xl font-semibold text-amber-300 mb-3 font-cinzel tracking-wide">
Managing Encounter: {selectedEncounter.name}
</h3>
<div className="flex flex-col lg:flex-row gap-4">
{/* Combat Controls - Left Side (Sticky on large screens) */}
<div className="lg:w-64 flex-shrink-0">
<InitiativeControls
campaignId={campaignId}
encounter={selectedEncounter}
encounterPath={getPath.encounter(campaignId, selectedEncounter.id)}
/>
</div>
{/* Participant Manager - Right Side */}
<div className="flex-grow">
<ParticipantManager
encounter={selectedEncounter}
encounterPath={getPath.encounter(campaignId, selectedEncounter.id)}
campaignCharacters={campaignCharacters}
/>
</div>
</div>
</div>
2025-05-25 21:19:22 -04:00
)}
</div>
<ConfirmationModal
isOpen={showDeleteConfirm}
onClose={() => setShowDeleteConfirm(false)}
onConfirm={confirmDeleteEncounter}
title="Delete Encounter?"
message={`Are you sure you want to delete the encounter "${itemToDelete?.name}"? This action cannot be undone.`}
/>
</>
2025-05-25 21:19:22 -04:00
);
}
// ============================================================================
// ADMIN VIEW COMPONENT
// ============================================================================
function AdminView({ userId }) {
const { data: campaignsData, isLoading: isLoadingCampaigns, error: campaignsError } = useFirestoreCollection(
getPath.campaigns()
);
const { data: initialActiveInfo } = useFirestoreDocument(getPath.activeDisplay());
const [campaignsWithDetails, setCampaignsWithDetails] = useState([]);
const [selectedCampaignId, setSelectedCampaignId] = useState(null);
const [showCreateModal, setShowCreateModal] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = 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 encounters = await storage.getCollection(getPath.encounters(campaign.id));
encounterCount = encounters.length;
} catch (err) {
console.error(`Failed to fetch encounters for campaign ${campaign.id}:`, err);
}
return { ...campaign, characters, encounterCount };
})
);
setCampaignsWithDetails(detailedCampaigns);
};
fetchDetails();
} else if (campaignsData) {
setCampaignsWithDetails(
campaignsData.map(c => ({ ...c, characters: c.players || [], encounterCount: 0 }))
);
}
}, [campaignsData]);
useEffect(() => {
if (
initialActiveInfo &&
initialActiveInfo.activeCampaignId &&
campaignsWithDetails.length > 0 &&
!selectedCampaignId
) {
const campaignExists = campaignsWithDetails.some(c => c.id === initialActiveInfo.activeCampaignId);
if (campaignExists) {
setSelectedCampaignId(initialActiveInfo.activeCampaignId);
}
}
}, [initialActiveInfo, campaignsWithDetails, selectedCampaignId]);
const handleCreateCampaign = async (name, backgroundUrl) => {
if (!db || !name.trim()) return;
const newCampaignId = generateId();
try {
await storage.setDoc(getPath.campaign(newCampaignId), {
name: name.trim(),
playerDisplayBackgroundUrl: backgroundUrl.trim() || '',
ownerId: userId,
createdAt: new Date().toISOString(),
players: [],
});
setShowCreateModal(false);
setSelectedCampaignId(newCampaignId);
} catch (err) {
console.error("Error creating campaign:", err);
alert("Failed to create campaign. Please try again.");
}
};
const requestDeleteCampaign = (campaignId, campaignName) => {
setItemToDelete({ id: campaignId, name: campaignName });
setShowDeleteConfirm(true);
};
const confirmDeleteCampaign = async () => {
if (!db || !itemToDelete) return;
const campaignId = itemToDelete.id;
try {
const encountersPath = getPath.encounters(campaignId);
const encounters = await storage.getCollection(encountersPath);
const deleteOps = encounters.map(e => {
const id = e.id || e.path?.split('/').pop();
return { type: 'delete', path: `${encountersPath}/${id}` };
});
if (deleteOps.length > 0) await storage.batchWrite(deleteOps);
await storage.deleteDoc(getPath.campaign(campaignId));
if (selectedCampaignId === campaignId) {
setSelectedCampaignId(null);
}
const activeDisplay = await storage.getDoc(getPath.activeDisplay());
if (activeDisplay && activeDisplay.activeCampaignId === campaignId) {
await storage.updateDoc(getPath.activeDisplay(), {
activeCampaignId: null,
activeEncounterId: null
});
}
} catch (err) {
console.error("Error deleting campaign:", err);
alert("Failed to delete campaign. Please try again.");
}
setShowDeleteConfirm(false);
setItemToDelete(null);
};
const selectedCampaign = campaignsWithDetails.find(c => c.id === selectedCampaignId);
if (isLoadingCampaigns) {
2026-04-25 20:25:34 -04:00
return <p className="text-center text-stone-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">
2026-04-25 18:37:55 -04:00
<h2 className="text-2xl font-semibold text-amber-300 font-cinzel tracking-wide">Campaigns</h2>
<button
onClick={() => setShowCreateModal(true)}
2026-04-25 20:25:34 -04:00
className="bg-red-700 hover:bg-red-800 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 && (
2026-04-25 20:25:34 -04:00
<p className="text-stone-400">No campaigns yet. Create one to get started!</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})` }
: {};
2026-04-25 20:25:34 -04:00
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-amber-500' : ''} ${!campaign.playerDisplayBackgroundUrl ? 'bg-stone-800 hover:bg-stone-700' : '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>
2026-04-25 20:25:34 -04:00
<div className="text-xs text-stone-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>
{showCreateModal && (
<Modal onClose={() => setShowCreateModal(false)} title="Create New Campaign">
<CreateCampaignForm
onCreate={handleCreateCampaign}
onCancel={() => setShowCreateModal(false)}
/>
</Modal>
)}
{selectedCampaign && (
2026-04-25 20:25:34 -04:00
<div className="mt-6 p-6 bg-stone-900 rounded-lg shadow-xl">
2026-04-25 18:37:55 -04:00
<h2 className="text-2xl font-semibold text-amber-300 mb-4 font-cinzel tracking-wide">
Managing: {selectedCampaign.name}
</h2>
<CharacterManager
campaignId={selectedCampaignId}
campaignCharacters={selectedCampaign.characters || []}
/>
2026-04-25 20:25:34 -04:00
<hr className="my-6 border-stone-700" />
<EncounterManager
campaignId={selectedCampaignId}
initialActiveEncounterId={
initialActiveInfo && initialActiveInfo.activeCampaignId === selectedCampaignId
? initialActiveInfo.activeEncounterId
: null
}
campaignCharacters={selectedCampaign.characters || []}
/>
</div>
)}
</div>
<ConfirmationModal
isOpen={showDeleteConfirm}
onClose={() => setShowDeleteConfirm(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.`}
/>
</>
);
}
// ============================================================================
// DISPLAY VIEW COMPONENT (Player View)
// ============================================================================
function DisplayView() {
const { data: activeDisplayData, isLoading: isLoadingActiveDisplay, error: activeDisplayError } = useFirestoreDocument(
getPath.activeDisplay()
);
2025-05-25 21:19:22 -04:00
const [activeEncounterData, setActiveEncounterData] = useState(null);
const [isLoadingEncounter, setIsLoadingEncounter] = useState(true);
2025-05-26 08:33:39 -04:00
const [encounterError, setEncounterError] = useState(null);
2025-05-25 23:28:36 -04:00
const [campaignBackgroundUrl, setCampaignBackgroundUrl] = useState('');
const [isPlayerDisplayActive, setIsPlayerDisplayActive] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [wakeLockEnabled, setWakeLockEnabled] = useState(false);
const wakeLockRef = useRef(null);
const currentParticipantRef = useRef(null);
useEffect(() => {
const onFsChange = () => setIsFullscreen(!!document.fullscreenElement);
document.addEventListener('fullscreenchange', onFsChange);
return () => document.removeEventListener('fullscreenchange', onFsChange);
}, []);
const toggleFullscreen = () => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen();
} else {
document.exitFullscreen();
}
};
useEffect(() => {
if (!wakeLockEnabled) {
wakeLockRef.current?.release();
wakeLockRef.current = null;
return;
}
const acquire = async () => {
try {
wakeLockRef.current = await navigator.wakeLock.request('screen');
} catch (e) {
console.error('Wake lock failed:', e);
}
};
acquire();
// Re-acquire after tab becomes visible again (browser auto-releases on hide)
const onVisChange = () => { if (document.visibilityState === 'visible') acquire(); };
document.addEventListener('visibilitychange', onVisChange);
return () => {
document.removeEventListener('visibilitychange', onVisChange);
wakeLockRef.current?.release();
wakeLockRef.current = null;
};
}, [wakeLockEnabled]);
2025-05-25 21:19:22 -04:00
useEffect(() => {
if (!db) {
setEncounterError("Firestore not available.");
setIsLoadingEncounter(false);
setIsPlayerDisplayActive(false);
return;
}
2025-05-25 21:19:22 -04:00
let unsubscribeEncounter;
2025-05-25 23:28:36 -04:00
let unsubscribeCampaign;
2025-05-26 08:33:39 -04:00
if (activeDisplayData) {
const { activeCampaignId, activeEncounterId } = activeDisplayData;
if (activeCampaignId && activeEncounterId) {
setIsPlayerDisplayActive(true);
setIsLoadingEncounter(true);
setEncounterError(null);
const campaignDocRef = doc(db, getPath.campaign(activeCampaignId));
unsubscribeCampaign = onSnapshot(
campaignDocRef,
(campSnap) => {
if (campSnap.exists()) {
setCampaignBackgroundUrl(campSnap.data().playerDisplayBackgroundUrl || '');
} else {
setCampaignBackgroundUrl('');
}
},
(err) => console.error("Error fetching campaign background:", err)
);
const encounterPath = getPath.encounter(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]);
// Auto-scroll current participant into view
useEffect(() => {
if (currentParticipantRef.current && activeEncounterData?.isStarted && !activeEncounterData?.isPaused) {
currentParticipantRef.current.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'nearest'
});
}
}, [activeEncounterData?.currentTurnParticipantId, activeEncounterData?.isStarted, activeEncounterData?.isPaused]);
if (isLoadingActiveDisplay || (isPlayerDisplayActive && isLoadingEncounter)) {
2026-04-25 20:25:34 -04:00
return <div className="text-center py-10 text-2xl text-stone-300">Loading Player Display...</div>;
}
if (activeDisplayError || (isPlayerDisplayActive && encounterError)) {
return <div className="text-center py-10 text-2xl text-red-400">{activeDisplayError || encounterError}</div>;
}
2025-05-26 07:50:24 -04:00
if (!isPlayerDisplayActive || !activeEncounterData) {
return (
2026-04-25 20:25:34 -04:00
<div className="min-h-screen bg-black text-stone-400 flex flex-col items-center justify-center p-4 text-center">
<EyeOff size={64} className="mb-4 text-stone-500" />
2026-04-25 18:37:55 -04:00
<h2 className="text-3xl font-semibold font-cinzel tracking-wide">Game Session Paused</h2>
2025-05-26 07:50:24 -04:00
<p className="text-xl mt-2">The Dungeon Master has not activated an encounter for display.</p>
</div>
);
2025-05-26 07:50:24 -04:00
}
const { name, participants, round, currentTurnParticipantId, isStarted, isPaused } = activeEncounterData;
const hidePlayerHp = activeDisplayData?.hidePlayerHp ?? true;
let participantsToRender = [];
if (participants) {
// Hide inactive monsters (pre-staged/summoned reserves) from the player view
const visibleParticipants = participants.filter(p => p.isActive || p.type !== 'monster');
participantsToRender = sortParticipantsByInitiative(visibleParticipants, visibleParticipants);
2025-05-25 21:19:22 -04:00
}
const displayStyles = campaignBackgroundUrl
? {
backgroundImage: `url(${campaignBackgroundUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center center',
backgroundRepeat: 'no-repeat',
minHeight: '100vh'
}
: { minHeight: '100vh' };
2025-05-25 21:19:22 -04:00
return (
<div
2026-04-25 20:25:34 -04:00
className={`p-4 md:p-8 rounded-xl shadow-2xl ${!campaignBackgroundUrl ? 'bg-stone-950' : ''}`}
style={displayStyles}
>
<div className="fixed top-3 right-3 z-50 flex gap-2">
<button
onClick={() => setWakeLockEnabled(v => !v)}
title={wakeLockEnabled ? 'Allow sleep' : 'Prevent sleep'}
className={`p-2 rounded-lg transition-all ${wakeLockEnabled ? 'bg-amber-600 hover:bg-amber-700 text-white' : 'bg-stone-800 bg-opacity-80 hover:bg-opacity-100 text-stone-300 hover:text-white'}`}
>
{wakeLockEnabled ? <Coffee size={20} /> : <Moon size={20} />}
</button>
<button
onClick={toggleFullscreen}
title={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
className="bg-stone-800 bg-opacity-80 hover:bg-opacity-100 text-stone-300 hover:text-white p-2 rounded-lg transition-all"
>
{isFullscreen ? <Minimize2 size={20} /> : <Maximize2 size={20} />}
</button>
</div>
2026-04-25 20:25:34 -04:00
<div className={campaignBackgroundUrl ? 'bg-stone-950 bg-opacity-75 p-4 md:p-6 rounded-lg' : ''}>
2026-04-25 18:37:55 -04:00
<h2 className="text-4xl md:text-5xl font-bold text-center text-amber-400 mb-2 font-cinzel tracking-wide">{name}</h2>
2026-04-25 18:37:55 -04:00
{isStarted && <p className="text-2xl text-center text-amber-300 mb-1 font-cinzel">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 && (
2026-04-25 20:25:34 -04:00
<p className="text-2xl text-center text-stone-400 mb-6">Awaiting Start</p>
)}
{!isStarted && (!participants || participants.length === 0) && (
2026-04-25 20:25:34 -04:00
<p className="text-2xl text-stone-500 mb-6">No participants.</p>
)}
{participantsToRender.length === 0 && isStarted && (
2026-04-25 20:25:34 -04:00
<p className="text-xl text-stone-400">No active participants.</p>
)}
<div className="space-y-4 max-w-3xl mx-auto">
2025-05-27 10:51:29 -04:00
{participantsToRender.map(p => {
const isDead = p.currentHp === 0;
const isDying = p.isDying || false;
let participantBgColor = p.type === 'monster'
2026-04-25 20:25:34 -04:00
? (p.isNpc ? 'bg-stone-800' : 'bg-[#8e351c]')
: 'bg-indigo-950';
const isCurrentTurn = p.id === currentTurnParticipantId && isStarted && !isPaused;
if (isCurrentTurn) {
2025-05-27 10:51:29 -04:00
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}
ref={isCurrentTurn ? currentParticipantRef : null}
className={`p-4 md:p-6 rounded-lg shadow-lg transition-all ${participantBgColor} ${isDead ? 'opacity-40 grayscale' : (!p.isActive ? 'opacity-40 grayscale' : '')} ${isDying ? 'animate-death-dissolve' : ''}`}
>
<div className="flex justify-between items-center mb-2">
<h3
2026-04-25 20:25:34 -04:00
className={`text-2xl md:text-3xl font-bold font-cinzel ${isCurrentTurn ? 'text-white' : (p.type === 'character' ? 'text-amber-100' : (p.isNpc ? 'text-stone-100' : 'text-white'))}`}
>
{isDead && <span className="mr-2">☠️</span>}
{p.name}
{isCurrentTurn && (
<span className="text-yellow-300 animate-pulse ml-2">(Current)</span>
)}
2026-04-25 20:25:34 -04:00
{isDead && <span className="text-red-300 text-lg ml-2">{p.type === 'character' ? '(Unconscious)' : '(Dead)'}</span>}
</h3>
<span
2026-04-25 20:25:34 -04:00
className={`text-xl md:text-2xl font-semibold ${isCurrentTurn ? 'text-green-200' : 'text-stone-200'}`}
>
Init: {p.initiative}
</span>
2025-05-25 23:28:36 -04:00
</div>
{!(hidePlayerHp && p.type === 'character') && (
<div className="flex justify-between items-center">
<div className="w-full bg-stone-700 rounded-full h-6 md:h-8 relative overflow-hidden border-2 border-stone-600">
<div
className={`h-full rounded-full transition-all ${isDead ? 'bg-red-900' : (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 && (
2026-04-26 10:37:25 -04:00
<div className="flex flex-wrap gap-1 mt-2">
{p.conditions.map(cId => {
const cond = CONDITIONS.find(c => c.id === cId);
return cond ? (
<span
key={cId}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-purple-900 border border-purple-500 text-xs md:text-sm text-purple-200 font-medium"
>
{cond.emoji} {cond.label}
</span>
) : null;
})}
</div>
)}
{!p.isActive && !isDead && (
2026-04-25 20:25:34 -04:00
<p className="text-center text-lg font-semibold text-stone-300 mt-2">(Inactive)</p>
)}
2025-05-25 23:28:36 -04:00
</div>
);
2025-05-27 10:51:29 -04:00
})}
2025-05-25 23:28:36 -04:00
</div>
2025-05-25 21:19:22 -04:00
</div>
</div>
);
2025-05-25 21:19:22 -04:00
}
2026-05-16 10:25:17 -04:00
// ============================================================================
// LOGS VIEW COMPONENT
// ============================================================================
function LogsView() {
const { data: logs, isLoading } = useFirestoreCollection(getPath.logs(), LOG_QUERY);
const [showClearConfirm, setShowClearConfirm] = useState(false);
const [undoingId, setUndoingId] = useState(null);
2026-05-16 10:25:17 -04:00
const handleClearLogs = async () => {
try {
const logs = await storage.getCollection(getPath.logs());
if (logs.length > 0) {
const ops = logs.map(l => {
const id = l.id || l.path?.split('/').pop();
return { type: 'delete', path: `${getPath.logs()}/${id}` };
});
await storage.batchWrite(ops);
2026-05-16 10:25:17 -04:00
}
} catch (err) {
console.error('Error clearing logs:', err);
}
setShowClearConfirm(false);
};
const handleUndo = async (entry) => {
if (!db || !entry.undo) return;
setUndoingId(entry.id);
try {
await storage.updateDoc(entry.undo.encounterPath, entry.undo.updates);
await storage.updateDoc(`${getPath.logs()}/${entry.id}`, { undone: true });
} catch (err) {
console.error('Error undoing action:', err);
alert('Failed to roll back. The encounter may have changed or no longer exists.');
}
setUndoingId(null);
};
2026-05-16 10:25:17 -04:00
return (
<div className="min-h-screen bg-stone-950 text-stone-100 font-garamond">
<header className="bg-stone-950 p-4 shadow-lg border-b border-amber-900">
<div className="container mx-auto flex justify-between items-center">
<h1 className="text-3xl font-bold text-amber-400 font-cinzel tracking-wide">Combat Log</h1>
<div className="flex gap-2">
<a
href="/"
className="px-4 py-2 rounded-md text-sm font-medium bg-stone-700 hover:bg-stone-600 text-white transition-colors"
>
Back to Tracker
</a>
<button
onClick={() => setShowClearConfirm(true)}
disabled={isLoading || logs.length === 0}
className="px-4 py-2 rounded-md text-sm font-medium bg-red-800 hover:bg-red-700 text-white transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
Clear Log
</button>
</div>
</div>
</header>
<main className="container mx-auto p-4 md:p-8">
{isLoading ? (
<LoadingSpinner message="Loading logs..." />
) : logs.length === 0 ? (
<p className="text-stone-400 text-center mt-12 text-lg">No log entries yet.</p>
) : (
<>
<p className="text-stone-500 text-sm mb-4">{logs.length} entries newest first</p>
<div className="space-y-1 max-w-5xl">
{logs.map(entry => (
<div
key={entry.id}
className={`flex gap-3 items-center p-3 rounded-md border text-sm transition-opacity ${
entry.undone
? 'bg-stone-900/50 border-stone-800/50 opacity-50'
: 'bg-stone-900 border-stone-800'
}`}
>
2026-05-16 10:25:17 -04:00
<span className="text-stone-500 whitespace-nowrap font-mono shrink-0">
{new Date(entry.timestamp).toLocaleString()}
</span>
{entry.encounterName && (
<span className="text-amber-600 whitespace-nowrap shrink-0">[{entry.encounterName}]</span>
)}
<span className={`flex-1 ${entry.undone ? 'line-through text-stone-500' : 'text-stone-100'}`}>
{entry.message}
</span>
{entry.undone ? (
<span className="shrink-0 text-xs text-stone-600 italic">rolled back</span>
) : entry.undo ? (
<button
onClick={() => handleUndo(entry)}
disabled={undoingId === entry.id}
className="shrink-0 px-2 py-0.5 text-xs rounded bg-stone-700 hover:bg-amber-800 text-stone-300 hover:text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Roll back this action"
>
{undoingId === entry.id ? '…' : '↩ Undo'}
</button>
) : null}
2026-05-16 10:25:17 -04:00
</div>
))}
</div>
</>
)}
</main>
<ConfirmationModal
isOpen={showClearConfirm}
onClose={() => setShowClearConfirm(false)}
onConfirm={handleClearLogs}
title="Clear All Logs?"
message="This will permanently delete all log entries and cannot be undone."
/>
</div>
);
}
// ============================================================================
// 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);
2026-05-16 10:25:17 -04:00
const [isLogsMode, setIsLogsMode] = useState(false);
2025-05-25 21:19:22 -04:00
useEffect(() => {
const queryParams = new URLSearchParams(window.location.search);
2026-04-25 18:37:55 -04:00
if (queryParams.get('playerView') === 'true' || window.location.pathname === '/display') {
setIsPlayerViewOnlyMode(true);
}
2026-05-16 10:25:17 -04:00
if (window.location.pathname === '/logs') {
setIsLogsMode(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.");
// Auth failed and onAuthStateChanged won't fire with a user, so unblock the UI here
setIsAuthReady(true);
setIsLoading(false);
}
};
const unsubscribe = onAuthStateChanged(auth, (user) => {
setUserId(user ? user.uid : null);
// Only mark auth ready once we have an actual authenticated user.
// onAuthStateChanged fires with null before signInAnonymously completes,
// which would cause Firestore queries to run unauthenticated.
if (user) {
setIsAuthReady(true);
setIsLoading(false);
}
});
initAuth();
return () => unsubscribe();
}, []);
if (!isFirebaseInitialized || !db || !auth) {
return (
<ErrorDisplay
critical
message="Firebase is not properly configured or initialized. Please check your .env.local file and ensure all REACT_APP_FIREBASE_... variables are correctly set."
/>
);
}
if (isLoading || !isAuthReady) {
return <LoadingSpinner message="Loading Initiative Tracker..." />;
}
if (error) {
return <ErrorDisplay message={error} />;
}
const openPlayerWindow = () => {
2026-04-25 18:37:55 -04:00
const playerViewUrl = window.location.origin + '/display';
window.open(playerViewUrl, '_blank', 'noopener,noreferrer,width=1024,height=768');
};
if (isPlayerViewOnlyMode) {
return (
2026-04-25 20:25:34 -04:00
<div className="min-h-screen bg-stone-950 text-stone-100 font-garamond">
{isAuthReady && <DisplayView />}
{!isAuthReady && !error && <p>Authenticating for Player Display...</p>}
</div>
);
}
2026-05-16 10:25:17 -04:00
if (isLogsMode) {
return isAuthReady ? <LogsView /> : <LoadingSpinner message="Authenticating..." />;
}
2025-05-25 21:19:22 -04:00
return (
2026-04-25 20:25:34 -04:00
<div className="min-h-screen bg-stone-950 text-stone-100 font-garamond">
<header className="bg-stone-950 p-4 shadow-lg border-b border-amber-900">
<div className="container mx-auto flex justify-between items-center">
2026-04-25 18:37:55 -04:00
<h1 className="text-3xl font-bold text-amber-400 font-cinzel tracking-wide">TTRPG Initiative Tracker</h1>
2026-05-16 10:25:17 -04:00
<div className="flex gap-2">
<a
href="/logs"
target="_blank"
rel="noopener noreferrer"
className="px-4 py-2 rounded-md text-sm font-medium transition-colors bg-stone-700 hover:bg-stone-600 text-white flex items-center"
>
<ScrollText size={16} className="mr-2" /> View Logs
</a>
<button
onClick={openPlayerWindow}
className="px-4 py-2 rounded-md text-sm font-medium transition-colors bg-amber-700 hover:bg-amber-800 text-white flex items-center"
>
<ExternalLink size={16} className="mr-2" /> Open Player Window
</button>
</div>
2025-05-25 21:19:22 -04:00
</div>
</header>
<main className="container mx-auto p-4 md:p-8">
{isAuthReady && userId && <AdminView userId={userId} />}
{!isAuthReady && !error && <p>Authenticating...</p>}
</main>
2026-04-25 20:25:34 -04:00
<footer className="bg-stone-950 p-4 text-center text-sm text-stone-400 mt-8">
TTRPG Initiative Tracker {APP_VERSION}
</footer>
2025-05-25 21:19:22 -04:00
</div>
);
}
2025-05-26 08:53:26 -04:00
export default App;