Files
ttrpg-initiative-tracker/src/App.js
T
david raistrick 9159755846 Scope player card transitions: ring/shadow fast, fade/transform slow
transition-all duration-1000 on PlayerParticipantCard wrapper slowed current-
turn ring/border animation. Turn pointer visibly laggy at 400ms turn intervals.

Fix: scoped transition properties. opacity/transform/filter/border-color = 1s
for fade/death animations. box-shadow = 300ms for current-turn ring highlight,
so turn pointer snaps between participants while fades stay smooth.
2026-07-06 22:48:10 -04:00

3440 lines
144 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useRef, useMemo, createContext, useContext, useCallback } from 'react';
import * as shared from '@ttrpg/shared';
import { initializeApp, getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken, getFirestore, where, orderBy, limit, offset, getStorage, getStorageMode } from './storage';
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, Clock, ChevronRight, X,
Undo2, Redo2, Crosshair
} from 'lucide-react';
// ----- UI feedback: toast (transient) + info modal (persistent) -----
const UIFeedbackContext = createContext(null);
const useUIFeedback = () => useContext(UIFeedbackContext);
function ToastStack({ toasts, onDismiss }) {
if (!toasts.length) return null;
return (
<div className="fixed bottom-4 right-4 z-[60] flex flex-col gap-2 max-w-sm">
{toasts.map(t => (
<div key={t.id} className="flex items-start gap-2 bg-stone-800 border border-red-600 text-stone-100 px-3 py-2 rounded-md shadow-lg">
<AlertTriangle size={16} className="text-red-400 flex-shrink-0 mt-0.5" />
<span className="text-sm flex-grow">{t.message}</span>
<button onClick={() => onDismiss(t.id)} className="text-stone-400 hover:text-stone-200 flex-shrink-0" aria-label="Dismiss">
<X size={14} />
</button>
</div>
))}
</div>
);
}
function InfoModal({ message, onClose }) {
if (!message) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center p-4 z-50">
<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">Notice</h2>
</div>
<p className="text-stone-300 mb-6">{message}</p>
<div className="flex justify-end">
<button onClick={onClose} className="px-4 py-2 text-sm font-medium text-stone-900 bg-amber-400 hover:bg-amber-300 rounded-md transition-colors">
OK
</button>
</div>
</div>
</div>
);
}
function UIFeedbackProvider({ children }) {
const [toasts, setToasts] = useState([]);
const [info, setInfo] = useState(null);
const nextId = useRef(1);
const showToast = useCallback((message) => {
const id = nextId.current++;
setToasts(prev => [...prev, { id, message }]);
setTimeout(() => setToasts(prev => prev.filter(t => t.id !== id)), 6000);
}, []);
const showInfo = useCallback((message) => {
setInfo(message);
}, []);
const dismissToast = useCallback((id) => {
setToasts(prev => prev.filter(t => t.id !== id));
}, []);
return (
<UIFeedbackContext.Provider value={{ showToast, showInfo }}>
{children}
<ToastStack toasts={toasts} onDismiss={dismissToast} />
<InfoModal message={info} onClose={() => setInfo(null)} />
</UIFeedbackContext.Provider>
);
}
// 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
// ============================================================================
const APP_VERSION = 'v0.4';
const {
DEFAULT_MAX_HP, DEFAULT_INIT_MOD, MONSTER_DEFAULT_INIT_MOD,
generateId, rollD20, formatInitMod,
startEncounter, nextTurn, togglePause, endEncounter,
addParticipant, addParticipants, updateParticipant, removeParticipant,
toggleParticipantActive: combatToggleActive,
applyHpChange: combatApplyHpChange,
deathSave: combatDeathSave,
stabilizeParticipant,
reviveParticipant,
toggleCondition: combatToggleCondition,
reorderParticipants,
} = shared;
const ROLL_DISPLAY_DURATION = 5000;
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: '🔨' },
{ 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: '🎯' },
];
// ============================================================================
// FIREBASE CONFIGURATION
// ============================================================================
const firebaseConfig = {
apiKey: process.env.REACT_APP_FIREBASE_API_KEY,
authDomain: process.env.REACT_APP_FIREBASE_AUTH_DOMAIN,
projectId: process.env.REACT_APP_FIREBASE_PROJECT_ID,
storageBucket: process.env.REACT_APP_FIREBASE_STORAGE_BUCKET,
messagingSenderId: process.env.REACT_APP_FIREBASE_MESSAGING_SENDER_ID,
appId: process.env.REACT_APP_FIREBASE_APP_ID
};
const APP_ID = process.env.REACT_APP_TRACKER_APP_ID || 'ttrpg-initiative-tracker-default';
const PUBLIC_DATA_PATH = `artifacts/${APP_ID}/public/data`;
let app;
let db;
let auth;
let storage;
const STORAGE_MODE = getStorageMode();
// Initialize storage backend. firebase mode = real SDK init.
// ws/memory mode = mock auth, no firebase.
const initializeStorage = () => {
if (STORAGE_MODE === 'firebase') {
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;
}
try {
app = initializeApp(firebaseConfig);
db = getFirestore(app);
auth = getAuth(app);
storage = getStorage();
return true;
} catch (error) {
console.error("Error initializing Firebase:", error);
return false;
}
}
// ws / memory mode: stub auth so App's anon-sign-in path works.
// db stays a truthy sentinel object so legacy `if (!db) return` guards pass;
// all real reads/writes route through `storage.*`, never the SDK `db`.
const FAKE_USER = { uid: 'local-user', isAnonymous: true };
auth = {
currentUser: FAKE_USER,
};
db = { __localStub: true };
storage = getStorage();
return true;
};
const isInitialized = initializeStorage();
// ============================================================================
// FIRESTORE PATH HELPERS
// ============================================================================
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}`,
activeDisplay: () => `${PUBLIC_DATA_PATH}/activeDisplay/status`,
logs: () => `${PUBLIC_DATA_PATH}/logs`
};
// ============================================================================
// UTILITY FUNCTIONS
// ============================================================================
// generateId, rollD20, formatInitMod, sortParticipantsByInitiative,
// computeTurnOrderAfterRemoval/Addition: imported from @ttrpg/shared (1-list model).
// Display limit: recent logs only. Download/copy fetch full set on demand.
// Keep small — 500 = ~900KB per snapshot = slow load.
const LOG_PAGE_SIZE = 100;
const LOG_ORDER = orderBy('ts', 'desc');
// Combat actions now live in shared/turn.js and WRITE THEIR OWN LOGS.
// Every mutating func is async, takes ctx {storage, encPath, logPath},
// persists encounter patch + log entry itself. No separate logEvent call.
//
// buildCtx: standard context for combat action calls. Lazy getStorage()
// so handlers work even if module-level `storage` assigned late (test/auth).
const buildCtx = (encounterPath) => ({
storage: getStorage(),
encPath: encounterPath,
logPath: getPath.logs(),
});
// Read undo payload: new lean `undo` (id-based) or legacy (`undo_payload`/`undo`).
// Lean events need expandUndo() at click time (needs current enc doc).
// expandUndo lives in shared/turn.js (re-exported via @ttrpg/shared).
const { expandUndo } = shared;
const getUndo = (entry) => entry.undo_payload || entry.undo || null;
// ============================================================================
// CUSTOM HOOKS
// ============================================================================
function useFirestoreDocument(docPath) {
const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
if (!docPath) {
setData(null);
setIsLoading(false);
setError("Document path not provided.");
return;
}
setIsLoading(true);
setError(null);
const storage = getStorage();
const unsubscribe = storage.subscribeDoc(docPath, (doc) => {
setData(doc);
setIsLoading(false);
}, (err) => {
console.error(`Error fetching document ${docPath}:`, err);
setError(err.message || "Failed to fetch document.");
setIsLoading(false);
setData(null);
});
return () => { if (typeof unsubscribe === 'function') unsubscribe(); };
}, [docPath]);
return { data, isLoading, error };
}
function useFirestoreCollection(collectionPath, queryConstraints = []) {
const [data, setData] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const queryString = useMemo(() => JSON.stringify(queryConstraints), [queryConstraints]);
useEffect(() => {
if (!collectionPath) {
setData([]);
setIsLoading(false);
setError("Collection path not provided.");
return;
}
setIsLoading(true);
setError(null);
const storage = getStorage();
const unsubscribe = storage.subscribeCollection(collectionPath, (items) => {
setData(items);
setIsLoading(false);
}, queryConstraints, (err) => {
console.error(`[useFirestoreCollection] ERR ${collectionPath}:`, err);
setError(err.message || "Failed to fetch collection.");
setIsLoading(false);
setData([]);
});
return () => {
if (typeof unsubscribe === 'function') unsubscribe();
};
// queryString, not array ref
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [collectionPath, queryString]);
return { data, isLoading, error };
}
// ============================================================================
// REUSABLE COMPONENTS
// ============================================================================
function Modal({ onClose, title, children }) {
useEffect(() => {
const handleEsc = (event) => {
if (event.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleEsc);
return () => window.removeEventListener('keydown', handleEsc);
}, [onClose]);
return (
<div className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center p-4 z-50">
<div className="bg-stone-900 p-6 rounded-lg shadow-xl w-full max-w-md">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold text-amber-300 font-cinzel tracking-wide">{title}</h2>
<button onClick={onClose} className="text-stone-400 hover:text-stone-200">
<XCircle size={24} />
</button>
</div>
{children}
</div>
</div>
);
}
function ConfirmationModal({ isOpen, onClose, onConfirm, title, message }) {
const [pending, setPending] = useState(false);
const [error, setError] = useState(null);
// Reset transient state each time modal opens.
useEffect(() => {
if (isOpen) { setPending(false); setError(null); }
}, [isOpen]);
if (!isOpen) return null;
const handleConfirm = async () => {
if (pending) return; // double-click guard
setPending(true);
setError(null);
try {
await onConfirm();
onClose();
} catch (err) {
console.error('ConfirmationModal action failed:', err);
setError(err?.message || 'Action failed. Try again.');
setPending(false);
}
};
const close = () => {
if (pending) return; // block close mid-action
onClose();
};
return (
<div className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center p-4 z-50">
<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>
<p className="text-stone-300 mb-6">{message || "Are you sure you want to proceed?"}</p>
{error && (
<p className="text-red-400 text-sm mb-4">{error}</p>
)}
<div className="flex justify-end space-x-3">
<button
onClick={close}
disabled={pending}
className="px-4 py-2 text-sm font-medium text-stone-300 bg-stone-700 hover:bg-stone-600 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Cancel
</button>
<button
onClick={handleConfirm}
disabled={pending}
className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{pending ? "Working..." : "Confirm"}
</button>
</div>
</div>
</div>
);
}
function LoadingSpinner({ message = "Loading..." }) {
return (
<div className="min-h-screen bg-stone-950 text-white flex flex-col items-center justify-center p-4">
<div className="animate-spin rounded-full h-16 w-16 border-t-4 border-amber-500 border-solid"></div>
<p className="mt-4 text-xl">{message}</p>
</div>
);
}
function ErrorDisplay({ message, critical = false }) {
return (
<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>
);
}
// ============================================================================
// FORM COMPONENTS
// ============================================================================
function CreateCampaignForm({ onCreate, onCancel }) {
const [name, setName] = useState('');
const [backgroundUrl, setBackgroundUrl] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
if (name.trim()) {
onCreate(name, backgroundUrl);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<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)}
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>
<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"
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 justify-end space-x-3">
<button
type="button"
onClick={onCancel}
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"
className="px-4 py-2 text-sm font-medium text-white bg-red-700 hover:bg-red-800 rounded-md transition-colors"
>
Create
</button>
</div>
</form>
);
}
function CreateEncounterForm({ onCreate, onCancel }) {
const [name, setName] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
if (name.trim()) {
onCreate(name);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<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)}
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}
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"
className="px-4 py-2 text-sm font-medium text-white bg-amber-700 hover:bg-amber-800 rounded-md transition-colors"
>
Create
</button>
</div>
</form>
);
}
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 [asNpc, setAsNpc] = useState(participant.type === 'npc');
const handleSubmit = (e) => {
e.preventDefault();
onSave({
name: name.trim(),
initiative: parseInt(initiative, 10),
currentHp: parseInt(currentHp, 10),
maxHp: parseInt(maxHp, 10),
type: participant.type === 'monster' || participant.type === 'npc' ? (asNpc ? 'npc' : 'monster') : participant.type,
});
};
return (
<Modal onClose={onClose} title={`Edit ${participant.name}`}>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-stone-300">Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="mt-1 block w-full px-3 py-2 bg-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>
<label htmlFor="edit-initiative" className="block text-sm font-medium text-stone-300">Initiative</label>
<input
type="number"
id="edit-initiative"
value={initiative}
onChange={(e) => setInitiative(e.target.value)}
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">
<label className="block text-sm font-medium text-stone-300">Current HP</label>
<input
type="number"
value={currentHp}
onChange={(e) => setCurrentHp(e.target.value)}
className="mt-1 block w-full px-3 py-2 bg-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">
<label className="block text-sm font-medium text-stone-300">Max HP</label>
<input
type="number"
value={maxHp}
onChange={(e) => setMaxHp(e.target.value)}
className="mt-1 block w-full px-3 py-2 bg-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' || participant.type === 'npc') && (
<div className="flex items-center">
<input
type="checkbox"
id="editIsNpc"
checked={asNpc}
onChange={(e) => setAsNpc(e.target.checked)}
className="h-4 w-4 text-violet-600 border-stone-400 rounded focus:ring-violet-500"
/>
<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}
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"
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>
);
}
// ============================================================================
// CHARACTER MANAGER COMPONENT
// ============================================================================
function CharacterManager({ campaignId, campaignCharacters }) {
const { showToast, showInfo } = useUIFeedback();
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) {
showInfo("Please enter a valid positive number for Default Max HP."); return;
}
if (isNaN(initMod)) {
showInfo("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);
showToast("Failed to add character. Please try again."); }
};
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) {
showInfo("Please enter a valid positive number for Default Max HP."); setEditingCharacter(null);
return;
}
if (isNaN(initMod)) {
showInfo("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);
showToast("Failed to update character. Please try again."); }
};
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);
try {
await storage.updateDoc(getPath.campaign(campaignId), { players: updatedCharacters });
} catch (err) {
console.error("Error deleting character:", err);
showToast("Failed to delete character. Please try again."); }
setShowDeleteConfirm(false);
setItemToDelete(null);
};
return (
<>
<div className="p-4 bg-stone-900 rounded-lg shadow">
<div className="flex justify-between items-center mb-3">
<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)}
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">
<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"
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">
<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)}
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">
<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)}
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"
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 && (
<p className="text-sm text-stone-400">No characters added yet.</p>
)}
<ul className="space-y-2">
{campaignCharacters.map(character => (
<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 })}
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 })}
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 })}
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)}
className="p-1 text-stone-400 hover:text-stone-200"
>
<XCircle size={18} />
</button>
</form>
) : (
<>
<span className="text-stone-100">
{character.name}{' '}
<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
})}
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)}
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>
</>
)}
</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?`}
/>
</>
);
}
// ============================================================================
// PARTICIPANT MANAGER COMPONENT
// ============================================================================
function ParticipantManager({ encounter, encounterPath, campaignCharacters, campaignId }) {
const { showToast, showInfo } = useUIFeedback();
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 [manualInitiative, setManualInitiative] = useState('');
const [asNpc, setAsNpc] = useState(false);
const [editingParticipant, setEditingParticipant] = useState(null);
const [hpChangeValues, setHpChangeValues] = useState({});
const [draggedItemId, setDraggedItemId] = useState(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [itemToDelete, setItemToDelete] = useState(null);
const [lastRollDetails, setLastRollDetails] = useState(null);
const [openConditionsId, setOpenConditionsId] = useState(null);
const [customConditionInput, setCustomConditionInput] = useState('');
// Per-campaign custom conditions (DM-added freeform strings).
const { data: campaignDoc } = useFirestoreDocument(campaignId ? getPath.campaign(campaignId) : null);
const customConditions = (campaignDoc && campaignDoc.customConditions) || [];
// Merged condition list: built-ins + custom. Custom = { id: label, label, emoji: '' }.
// id may contain spaces/emoji — toggleCondition stores raw string.
const allConditions = [
...CONDITIONS,
...customConditions.map(label => ({ id: label, label, emoji: '' })),
];
const participants = encounter.participants || [];
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);
}
setAsNpc(false);
} else if (participantType === 'monster') {
setMaxHp(DEFAULT_MAX_HP);
setMonsterInitMod(MONSTER_DEFAULT_INIT_MOD);
}
}, [selectedCharacterId, participantType, campaignCharacters]);
const handleAddParticipant = async () => {
if (!db) return;
if (participantType === 'monster' && !participantName.trim()) return;
if (participantType === 'character' && !selectedCharacterId) return;
let nameToAdd = participantName.trim();
const initiativeRoll = rollD20();
let modifier = 0;
let currentMaxHp = parseInt(maxHp, 10) || DEFAULT_MAX_HP;
let participantAsNpc = false;
const manualInit = manualInitiative !== '' && !isNaN(parseInt(manualInitiative, 10));
const finalInitiative = manualInit ? parseInt(manualInitiative, 10) : null;
if (participantType === 'character') {
const character = campaignCharacters.find(c => c.id === selectedCharacterId);
if (!character) {
return;
}
if (participants.some(p => p.type === 'character' && p.originalCharacterId === selectedCharacterId)) {
showInfo(`${character.name} is already in this encounter.`);
return;
}
nameToAdd = character.name;
currentMaxHp = character.defaultMaxHp || currentMaxHp;
modifier = character.defaultInitMod || 0;
} else {
modifier = parseInt(monsterInitMod, 10) || 0;
participantAsNpc = asNpc;
}
const computedInitiative = manualInit ? finalInitiative : (initiativeRoll + modifier);
const newParticipant = {
id: generateId(),
name: nameToAdd,
type: participantType === 'monster' && participantAsNpc ? 'npc' : participantType,
originalCharacterId: participantType === 'character' ? selectedCharacterId : null,
initiative: computedInitiative,
maxHp: currentMaxHp,
currentHp: currentMaxHp,
conditions: [],
isActive: true,
status: 'conscious',
deathSaveSuccesses: 0,
deathSaveFailures: 0,
};
try {
await addParticipant(encounter, newParticipant, buildCtx(encounterPath));
setLastRollDetails({
name: nameToAdd,
roll: manualInit ? null : initiativeRoll,
mod: manualInit ? null : modifier,
total: computedInitiative,
type: participantAsNpc ? 'NPC' : participantType,
manual: manualInit,
});
setTimeout(() => setLastRollDetails(null), ROLL_DISPLAY_DURATION);
setParticipantName('');
setMaxHp(DEFAULT_MAX_HP);
setSelectedCharacterId('');
setMonsterInitMod(MONSTER_DEFAULT_INIT_MOD);
setAsNpc(false);
setManualInitiative('');
} catch (err) {
showToast("Failed to add participant. Please try again."); }
};
const handleAddAllCampaignCharacters = async () => {
if (!db || !campaignCharacters || campaignCharacters.length === 0) return;
const existingCharacterIds = participants
.filter(p => p.type === 'character' && p.originalCharacterId)
.map(p => p.originalCharacterId);
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,
status: 'conscious',
deathSaveSuccesses: 0,
deathSaveFailures: 0,
};
});
if (newParticipants.length === 0) {
showInfo("All campaign characters are already in this encounter."); return;
}
try {
await addParticipants(encounter, newParticipants, buildCtx(encounterPath));
} catch (err) {
showToast("Failed to add all characters. Please try again."); }
};
const handleUpdateParticipant = async (updatedData) => {
if (!db || !editingParticipant) return;
try {
await updateParticipant(encounter, editingParticipant.id, updatedData, buildCtx(encounterPath));
setEditingParticipant(null);
} catch (err) {
showToast("Failed to update participant. Please try again."); }
};
// Inline initiative edit (FEAT-3): blur/Enter commits. Reslots participant
// into correct list position (stable sort by init desc, tie-break original
// index). Display + AdminView both reflect new order. Pre-combat only —
// field gated to !started||paused elsewhere.
const handleInlineInitiative = async (participantId, value) => {
if (!db) return;
const n = parseInt(value, 10);
if (isNaN(n)) return;
try {
await updateParticipant(encounter, participantId, { initiative: n }, buildCtx(encounterPath));
} catch (err) {
// fall through silently
}
};
const requestDeleteParticipant = (participantId, participantName) => {
setItemToDelete({ id: participantId, name: participantName });
setShowDeleteConfirm(true);
};
const confirmDeleteParticipant = async () => {
if (!db || !itemToDelete) return;
try {
await removeParticipant(encounter, itemToDelete.id, buildCtx(encounterPath));
} catch (err) {
showToast("Failed to delete participant. Please try again."); }
setShowDeleteConfirm(false);
setItemToDelete(null);
};
const toggleParticipantActive = async (participantId) => {
if (!db) return;
try {
await combatToggleActive(encounter, participantId, buildCtx(encounterPath));
} catch (err) {
// fall through silently
}
};
const applyHpChange = async (participantId, changeType) => {
if (!db) return;
const amountStr = hpChangeValues[participantId];
if (!amountStr || amountStr.trim() === '') return;
const amount = parseInt(amountStr, 10);
if (isNaN(amount) || amount === 0) {
setHpChangeValues(prev => ({ ...prev, [participantId]: '' }));
return;
}
try {
await combatApplyHpChange(encounter, participantId, changeType, amount, buildCtx(encounterPath));
setHpChangeValues(prev => ({ ...prev, [participantId]: '' }));
} catch (err) {
// fall through silently
}
};
const handleDeathSaveChange = async (participantId, outcome) => {
if (!db) return;
try {
await combatDeathSave(encounter, participantId, outcome, buildCtx(encounterPath));
} catch (err) {
showToast(err.message);
}
};
const handleNat20 = async (participantId) => handleDeathSaveChange(participantId, 'nat20');
const handleNat1 = async (participantId) => handleDeathSaveChange(participantId, 'nat1');
const handleStabilize = async (participantId) => {
if (!db) return;
try {
await stabilizeParticipant(encounter, participantId, buildCtx(encounterPath));
} catch (err) {
showToast(err.message);
}
};
const handleRevive = async (participantId) => {
if (!db) return;
try {
await reviveParticipant(encounter, participantId, buildCtx(encounterPath));
} catch (err) {
showToast(err.message);
}
};
const handleCritDamage = async (participantId) => {
if (!db) return;
try {
// Any damage at 0 HP + isCriticalHit = 2 death-save failures (5e crit-at-0).
await combatApplyHpChange(encounter, participantId, 'damage', 1, { isCriticalHit: true }, buildCtx(encounterPath));
} catch (err) {
showToast(err.message);
}
};
const toggleCondition = async (participantId, conditionId) => {
if (!db) return;
try {
await combatToggleCondition(encounter, participantId, conditionId, buildCtx(encounterPath));
} catch (err) {
// fall through silently
}
};
// Add freeform condition: persist to campaign palette + apply to current participant.
const addCustomCondition = async (label, participantId = null) => {
const trimmed = (label || '').trim();
if (!trimmed || !campaignId) return;
setCustomConditionInput('');
const exists = allConditions.some(c => c.label.toLowerCase() === trimmed.toLowerCase());
if (!exists) {
try {
await storage.updateDoc(getPath.campaign(campaignId), { customConditions: [...customConditions, trimmed] });
} catch (err) {}
}
if (participantId) {
const cur = (encounter.participants.find(p => p.id === participantId)?.conditions) || [];
if (!cur.includes(trimmed)) {
try {
await combatToggleCondition(encounter, participantId, trimmed, buildCtx(encounterPath));
} catch (err) {}
}
}
};
const handleDragStart = (e, id) => {
setDraggedItemId(id);
e.dataTransfer.effectAllowed = 'move';
};
const handleDragOver = (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
};
const handleDrop = async (e, targetId) => {
e.preventDefault();
if (!db || draggedItemId === null || draggedItemId === targetId) {
setDraggedItemId(null);
return;
}
try {
await reorderParticipants(encounter, draggedItemId, targetId, buildCtx(encounterPath));
} catch (err) {
// drag invalid (id not found) — ignore
}
setDraggedItemId(null);
};
// 1-list model: participants[] IS the display order. No re-sort.
const sortedParticipants = 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);
return (
<>
<div className="p-3 bg-stone-900 rounded-md mt-4">
<div className="flex justify-between items-center mb-3">
<h4 className="text-lg font-medium text-amber-200 font-cinzel tracking-wide">Add Participants</h4>
<button
onClick={handleAddAllCampaignCharacters}
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>
</div>
{/* 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">
Pause combat to add or remove participants. The turn order will be recalculated when combat is resumed.
</p>
</div>
</div>
)}
<form
onSubmit={(e) => {
e.preventDefault();
handleAddParticipant();
}}
className="grid grid-cols-1 md:grid-cols-6 gap-x-4 gap-y-2 mb-4 p-3 bg-stone-800 rounded items-end"
>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-stone-300">Type</label>
<select
value={participantType}
onChange={(e) => {
setParticipantType(e.target.value);
setSelectedCharacterId('');
setAsNpc(false);
}}
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">
<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"
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">
<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)}
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">
<label htmlFor="manualInitiative" className="block text-sm font-medium text-stone-300">
Initiative <span className="text-xs text-stone-400">(blank=roll)</span>
</label>
<input
type="number"
id="manualInitiative"
value={manualInitiative}
onChange={(e) => setManualInitiative(e.target.value)}
placeholder="auto"
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">
<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)}
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="asNpc"
checked={asNpc}
onChange={(e) => setAsNpc(e.target.checked)}
className="h-4 w-4 text-violet-600 border-stone-400 rounded focus:ring-violet-500"
/>
<label htmlFor="asNpc" className="ml-2 block text-sm text-stone-300">
Is NPC?
</label>
</div>
</>
) : (
<>
<div className="md:col-span-4">
<label className="block text-sm font-medium text-stone-300">Select Character</label>
<select
value={selectedCharacterId}
onChange={(e) => setSelectedCharacterId(e.target.value)}
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.filter(c => !participants.some(p => p.type === 'character' && p.originalCharacterId === c.id)).map(c => (
<option key={c.id} value={c.id}>
{c.name} (HP: {c.defaultMaxHp || 'N/A'}, Mod: {formatInitMod(c.defaultInitMod)})
</option>
))}
</select>
</div>
<div className="md:col-span-2">
<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)}
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">
<label htmlFor="charManualInitiative" className="block text-sm font-medium text-stone-300">
Initiative <span className="text-xs text-stone-400">(blank=roll)</span>
</label>
<input
type="number"
id="charManualInitiative"
value={manualInitiative}
onChange={(e) => setManualInitiative(e.target.value)}
placeholder="auto"
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-6 flex justify-end mt-2">
<button
type="submit"
disabled={encounter.isStarted && !encounter.isPaused}
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>
{lastRollDetails && (
<p className="text-sm text-green-400 mt-2 mb-2 text-center">
{lastRollDetails.manual
? `${lastRollDetails.name} (${lastRollDetails.type === 'character' ? 'Character' : lastRollDetails.type === 'monster' ? 'Monster' : lastRollDetails.type === 'npc' ? 'NPC' : lastRollDetails.type}): Set initiative ${lastRollDetails.total}`
: `${lastRollDetails.name} (${lastRollDetails.type === 'character' ? 'Character' : lastRollDetails.type === 'monster' ? 'Monster' : lastRollDetails.type === 'npc' ? 'NPC' : lastRollDetails.type}): Rolled d20 (${lastRollDetails.roll}) ${formatInitMod(lastRollDetails.mod)} = ${lastRollDetails.total} Initiative`
}
</p>
)}
{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 === 'npc' ? 'NPC' : (p.type === 'monster' ? 'Monster' : 'Character');
const hasDeathSaves = p.type === 'character' || p.type === 'npc';
let bgColor = p.type === 'character' ? 'bg-indigo-950' : 'bg-[#8e351c]';
if (isCurrentTurn && !encounter.isPaused) bgColor = 'bg-green-600';
const participantStatus = p.status || (p.currentHp === 0 ? (p.type === 'monster' ? 'dead' : 'dying') : 'conscious');
const isZeroHp = p.currentHp === 0;
const isDead = participantStatus === 'dead';
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 ? 'opacity-35 grayscale' : (isDead ? 'opacity-90 brightness-75 saturate-125' : '')} ${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}
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">
{isZeroHp && <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>
)}
{hasDeathSaves && participantStatus === 'dying' && <span className="ml-2 text-xs text-red-300 font-semibold animate-pulse">(Dying)</span>}
{hasDeathSaves && participantStatus === 'stable' && (p.conditions || []).includes('unconscious') && <span className="ml-2 text-xs text-emerald-300 font-semibold">(Unconscious)</span>}
{isDead && <span className="ml-2 px-2 py-0.5 rounded bg-red-950 border border-red-500 text-red-200 text-xs font-bold">DEAD</span>}
</p>
<div className={`text-sm ${isCurrentTurn && !encounter.isPaused ? 'text-green-100' : 'text-stone-200'} flex items-center gap-2`}>
<span className="inline-flex items-center gap-1">
<label htmlFor={`init-${p.id}`} className="sr-only">Initiative</label>
<input
type="number"
id={`init-${p.id}`}
defaultValue={p.initiative}
key={p.initiative}
min="0"
max="99"
disabled={encounter.isStarted && !encounter.isPaused}
onChange={(e) => { if (e.target.value.length > 2) e.target.value = e.target.value.slice(0, 2); }}
onFocus={(e) => e.target.select()}
onBlur={(e) => { if (e.target.value !== String(p.initiative)) handleInlineInitiative(p.id, e.target.value); }}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur(); }}
className="w-10 px-1 py-0.5 bg-stone-800 border border-stone-700 rounded-md shadow-sm text-white text-sm focus:outline-none focus:ring-1 focus:ring-amber-600 focus:border-amber-600 disabled:opacity-50 disabled:cursor-not-allowed"
aria-label={`Initiative for ${p.name}`}
/>
</span>
<span>HP: {p.currentHp}/{p.maxHp}</span>
</div>
{participantStatus === 'dead' && (
<button onClick={() => handleRevive(p.id)} className="mt-2 inline-flex items-center gap-1 self-start px-2.5 py-1 text-xs rounded bg-emerald-800 hover:bg-emerald-700 text-white" title="Revive to 0 HP, stable and unconscious">
<HeartPulse size={14} /> Revive
</button>
)}
{hasDeathSaves && participantStatus === 'stable' && (
<div className="mt-2 text-xs text-emerald-300/80 italic">Stable regains 1 HP after 1d4 hours</div>
)}
{/* Death saves - D&D 5e status state machine */}
{hasDeathSaves && encounter.isStarted && participantStatus !== 'conscious' && (
<div className="mt-2 flex flex-col space-y-1">
{participantStatus === 'dying' && (
<>
<div className="flex items-center space-x-2">
<span className="text-xs text-emerald-300 font-medium w-10">Saves:</span>
{[1, 2, 3].map(saveNum => (
<span
key={saveNum}
className={`w-6 h-6 rounded border-2 flex items-center justify-center ${(p.deathSaveSuccesses || 0) >= saveNum ? 'bg-emerald-600 border-emerald-500' : 'bg-stone-800 border-stone-600'}`}
title={`Success pip ${saveNum}`}
>
{(p.deathSaveSuccesses || 0) >= saveNum && <span className="text-white text-sm"></span>}
</span>
))}
</div>
<div className="flex items-center space-x-2">
<span className="text-xs text-red-300 font-medium w-10">Fails:</span>
{[1, 2, 3].map(failNum => (
<span
key={failNum}
className={`w-6 h-6 rounded border-2 flex items-center justify-center ${(p.deathSaveFailures || 0) >= failNum ? 'bg-red-600 border-red-500' : 'bg-stone-800 border-stone-600'}`}
title={`Fail pip ${failNum}`}
>
{(p.deathSaveFailures || 0) >= failNum && <span className="text-white text-sm"></span>}
</span>
))}
</div>
<div className="flex gap-2 mt-1">
<button onClick={() => handleDeathSaveChange(p.id, 'success')} className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded bg-emerald-700 hover:bg-emerald-600 text-white" title="Death save success">
Success
</button>
<button onClick={() => handleDeathSaveChange(p.id, 'fail')} className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded bg-red-700 hover:bg-red-600 text-white" title="Death save failure">
Fail
</button>
<button onClick={() => handleNat1(p.id)} className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded bg-red-800 hover:bg-red-700 text-white" title="Natural 1: +2 failures">
<AlertTriangle size={12} /> Nat1
</button>
<button onClick={() => handleNat20(p.id)} className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded bg-yellow-600 hover:bg-yellow-500 text-white" title="Natural 20: 1 HP + conscious">
<Zap size={12} /> Nat20
</button>
<button onClick={() => handleStabilize(p.id)} className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded bg-emerald-700 hover:bg-emerald-600 text-white" title="Stabilize at 0 HP">
<HeartPulse size={12} /> Stabilize
</button>
</div>
</>
)}
</div>
)}
{/* Active condition badges */}
{(p.conditions || []).length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{(p.conditions || []).map(cId => {
const cond = allConditions.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>
) : (
<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 ${cId}`}
onClick={() => toggleCondition(p.id, cId)}
>
{cId}
</span>
);
})}
</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">
{allConditions.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>
{/* Add custom condition (per-campaign, freeform string) */}
<div className="mt-2 flex gap-1">
<input
type="text"
value={customConditionInput}
onChange={(e) => setCustomConditionInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') { e.preventDefault(); addCustomCondition(customConditionInput, p.id); }
}}
placeholder="Add custom condition..."
className="flex-grow px-2 py-1 text-xs rounded bg-stone-800 border border-stone-600 text-stone-200 placeholder-stone-500 focus:outline-none focus:border-amber-500"
maxLength={40}
/>
<button
type="button"
onClick={() => addCustomCondition(customConditionInput, p.id)}
disabled={!customConditionInput.trim()}
className="px-2 py-1 rounded text-xs bg-amber-700 border border-amber-500 text-white hover:bg-amber-600 disabled:opacity-40 disabled:cursor-not-allowed"
>
Add
</button>
</div>
</div>
)}
</div>
</div>
<div className="flex flex-wrap items-center space-x-2 mt-2 sm:mt-0">
{hasDeathSaves && (participantStatus === 'dying' || participantStatus === 'stable') && encounter.isStarted && (
<button onClick={() => handleCritDamage(p.id)} className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded bg-red-900 hover:bg-red-800 text-red-100 border border-red-700" title="Critical hit at 0 HP: +2 death-save failures">
<Crosshair size={12} />Crit
</button>
)}
{encounter.isStarted && (
<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 }))}
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')}
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>
)}
<button
onClick={() => toggleParticipantActive(p.id)}
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>
<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)}
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)}
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?`}
/>
</>
);
}
// ============================================================================
// INITIATIVE CONTROLS COMPONENT
// ============================================================================
function InitiativeControls({ campaignId, encounter, encounterPath }) {
const { showToast, showInfo } = useUIFeedback();
const [showEndConfirm, setShowEndConfirm] = useState(false);
const { data: activeDisplayData } = useFirestoreDocument(getPath.activeDisplay());
const hidePlayerHp = activeDisplayData?.hidePlayerHp ?? true;
const hideNpcHp = activeDisplayData?.hideNpcHp ?? false;
// Undo/redo queried on click. No live logs subscription during combat.
const latestLogQuery = [where('encounterPath', '==', encounterPath), LOG_ORDER, limit(25)];
const findUndoTarget = async (redo = false) => {
const rows = (await storage.getCollection(getPath.logs(), latestLogQuery) || [])
.filter(l => l.undo || l.undo_payload);
if (!redo) return rows.find(l => !l.undone) || null;
// Redo stack = contiguous undone entries at top of log (newest first).
// If A3 then A2 undone, redo must replay A2 first, then A3.
const undoneBlock = [];
for (const row of rows) {
if (!row.undone) break;
undoneBlock.push(row);
}
return undoneBlock.length ? undoneBlock[undoneBlock.length - 1] : null;
};
const handleUndo = async () => {
if (!db) return;
try {
if (storage.undo) {
const undoTarget = await findUndoTarget(false);
if (!undoTarget) { showToast('No action to undo.'); return; }
const expanded = undoTarget.undo
? expandUndo(undoTarget, encounter) // lean: build patch from current enc
: getUndo(undoTarget); // legacy
if (!expanded) return;
await storage.undo({ logPath: `${getPath.logs()}/${undoTarget.id}`, undo: expanded });
}
} catch (err) {
console.error('Error undoing action:', err);
showToast('Failed to undo. The encounter may have changed or no longer exists.');
}
};
const handleRedo = async () => {
if (!db) return;
try {
if (storage.undo) {
const redoTarget = await findUndoTarget(true);
if (!redoTarget) { showToast('No action to redo.'); return; }
const expanded = redoTarget.undo
? expandUndo(redoTarget, encounter)
: getUndo(redoTarget);
if (!expanded) return;
await storage.undo({ logPath: `${getPath.logs()}/${redoTarget.id}`, undo: expanded, redo: true });
}
} catch (err) {
console.error('Error redoing action:', err);
showToast('Failed to redo.');
}
};
const handleToggleHidePlayerHp = async () => {
if (!db) return;
try {
await storage.setDoc(getPath.activeDisplay(), { hidePlayerHp: !hidePlayerHp }, { merge: true });
} catch (err) {
console.error("Error toggling hidePlayerHp:", err);
}
};
const handleToggleHideNpcHp = async () => {
if (!db) return;
try {
await storage.setDoc(getPath.activeDisplay(), { hideNpcHp: !hideNpcHp }, { merge: true });
} catch (err) {
console.error("Error toggling hideNpcHp:", err);
}
};
const handleStartEncounter = async () => {
if (!db || !encounter.participants || encounter.participants.length === 0) {
showInfo("Add participants first."); return;
}
try {
await startEncounter(encounter, buildCtx(encounterPath));
await storage.setDoc(getPath.activeDisplay(), { activeCampaignId: campaignId, activeEncounterId: encounter.id }, { merge: true });
} catch (err) {
showToast(err.message || "Failed to start encounter. Please try again."); }
};
const handleTogglePause = async () => {
if (!db || !encounter || !encounter.isStarted) return;
try {
await togglePause(encounter, buildCtx(encounterPath));
} catch (err) {
// fall through silently
}
};
const handleNextTurn = async () => {
if (!db || !encounter.isStarted || encounter.isPaused) return;
if (!encounter.currentTurnParticipantId || !encounter.turnOrderIds || encounter.turnOrderIds.length === 0) return;
try {
await nextTurn(encounter, buildCtx(encounterPath));
} catch (err) {
// nextTurn throws if no active participants — auto-end combat
if (err.message === 'Encounter not running.' || err.message === 'No active turn.') return;
showInfo("No active participants left."); await storage.updateDoc(encounterPath, {
isStarted: false,
isPaused: false,
currentTurnParticipantId: null,
round: encounter.round
});
}
};
const confirmEndEncounter = async () => {
if (!db) return;
try {
await endEncounter(encounter, buildCtx(encounterPath));
await storage.setDoc(getPath.activeDisplay(), { activeCampaignId: null, activeEncounterId: null }, { merge: true });
} catch (err) {
showToast("Failed to end encounter. Please try again."); }
setShowEndConfirm(false);
};
if (!encounter || !encounter.participants) return null;
return (
<>
<div className="lg:sticky lg:top-4 p-4 bg-stone-900 rounded-md shadow-lg">
<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}
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
</button>
) : (
<>
<button
onClick={handleTogglePause}
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" />}
{encounter.isPaused ? 'Resume Combat' : 'Pause Combat'}
</button>
<button
onClick={handleNextTurn}
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 */}
<div className="mt-2 pt-3 border-t border-stone-700">
<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>
{/* Undo / Redo — queried on click (no live logs subscription during combat) */}
<div className="mt-3 flex gap-2">
<button
onClick={handleUndo}
className="flex-1 px-3 py-2 text-xs font-medium text-white bg-amber-700 hover:bg-amber-600 rounded-md transition-colors flex items-center justify-center"
title="Undo latest action for this encounter"
>
<Undo2 size={14} className="mr-1" /> Undo
</button>
<button
onClick={handleRedo}
className="flex-1 px-3 py-2 text-xs font-medium text-white bg-sky-700 hover:bg-sky-600 rounded-md transition-colors flex items-center justify-center"
title="Redo latest undone action for this encounter"
>
<Redo2 size={14} className="mr-1" /> Redo
</button>
</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>
<label className="flex items-center justify-between cursor-pointer gap-2 mt-2">
<span className="text-sm text-stone-300">Hide NPC/monster HP</span>
<button
role="switch"
aria-checked={hideNpcHp}
onClick={handleToggleHideNpcHp}
className={`relative inline-flex h-5 w-9 flex-shrink-0 rounded-full border-2 border-transparent transition-colors focus:outline-none ${hideNpcHp ? 'bg-amber-600' : 'bg-stone-600'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${hideNpcHp ? '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 { showToast } = useUIFeedback();
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);
showToast("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 {
// cascade: delete logs for this encounter
const encPath = getPath.encounter(campaignId, encounterId);
const allLogs = await storage.getCollection(getPath.logs());
const encLogs = allLogs.filter(l =>
(l.encounterId && l.encounterId === encounterId) ||
(l.encounterPath && l.encounterPath.includes(encPath))
);
if (encLogs.length > 0) {
await storage.batchWrite(encLogs.map(l => ({
type: 'delete',
path: `${getPath.logs()}/${l.id || l.path?.split('/').pop()}`,
})));
}
await storage.deleteDoc(encPath);
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);
showToast("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,
});
}
} catch (err) {
console.error("Error toggling Player Display:", err);
}
};
const selectedEncounter = encounters?.find(e => e.id === selectedEncounterId);
if (isLoadingEncounters && campaignId) {
return <p className="text-center text-stone-300 mt-4">Loading encounters...</p>;
}
return (
<>
<div className="mt-6 p-4 bg-stone-900 rounded-lg shadow">
<div className="flex justify-between items-center mb-3">
<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)}
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) && (
<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}
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>
<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)}
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);
}}
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 && (
<div className="mt-6 p-4 bg-stone-900 rounded-lg shadow-inner">
<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}
campaignId={campaignId}
/>
</div>
</div>
</div>
)}
</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.`}
/>
</>
);
}
// ============================================================================
// ADMIN VIEW COMPONENT
// ============================================================================
function AdminView({ userId }) {
const { showToast } = useUIFeedback();
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);
const [campaignsCollapsed, setCampaignsCollapsed] = useState(() => {
try { return localStorage.getItem('ttrpg.campaignsCollapsed') === 'true'; }
catch { return false; }
});
useEffect(() => {
try { localStorage.setItem('ttrpg.campaignsCollapsed', String(campaignsCollapsed)); }
catch {}
}, [campaignsCollapsed]);
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 };
})
);
// newest first by createdAt (fallback to name for stable order).
detailedCampaigns.sort((a, b) => {
const at = a.createdAt || 0;
const bt = b.createdAt || 0;
if (at === bt) return (a.name || '').localeCompare(b.name || '');
return bt - at;
});
setCampaignsWithDetails(detailedCampaigns);
};
fetchDetails();
} else if (campaignsData) {
const sorted = [...campaignsData].sort((a, b) => {
const at = a.createdAt || 0;
const bt = b.createdAt || 0;
if (at === bt) return (a.name || '').localeCompare(b.name || '');
return bt - at;
});
setCampaignsWithDetails(
sorted.map(c => ({ ...c, characters: c.players || [], encounterCount: 0 }))
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [campaignsData]);
useEffect(() => {
if (
initialActiveInfo &&
initialActiveInfo.activeCampaignId &&
campaignsWithDetails.length > 0
) {
const campaignExists = campaignsWithDetails.some(c => c.id === initialActiveInfo.activeCampaignId);
if (campaignExists && selectedCampaignId !== initialActiveInfo.activeCampaignId) {
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);
showToast("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;
await deleteCampaignCascade(campaignId);
if (selectedCampaignId === campaignId) {
setSelectedCampaignId(null);
}
setShowDeleteConfirm(false);
setItemToDelete(null);
showToast('Campaign deleted.');
};
// Cascade delete: encounters + logs + display + campaign doc.
const deleteCampaignCascade = async (campaignId) => {
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}` };
});
const allLogs = await storage.getCollection(getPath.logs());
const encPaths = new Set(encounters.map(e => `${encountersPath}/${e.id || e.path?.split('/').pop()}`));
const encIds = new Set(encounters.map(e => e.id || e.path?.split('/').pop()));
const logOps = allLogs
.filter(l =>
(l.encounterId && encIds.has(l.encounterId)) ||
(l.encounterPath && [...encPaths].some(p => l.encounterPath.includes(p)))
)
.map(l => ({
type: 'delete',
path: `${getPath.logs()}/${l.id || l.path?.split('/').pop()}`,
}));
if (deleteOps.length > 0 || logOps.length > 0) {
await storage.batchWrite([...deleteOps, ...logOps]);
}
await storage.deleteDoc(getPath.campaign(campaignId));
const activeDisplay = await storage.getDoc(getPath.activeDisplay());
if (activeDisplay && activeDisplay.activeCampaignId === campaignId) {
await storage.updateDoc(getPath.activeDisplay(), { activeCampaignId: null, activeEncounterId: null });
}
};
// Dev-only: wipe all campaigns + encounters + logs. Irreversible.
// Uses deleteCollection (SQL bulk DELETE, no fetch). Parallel per campaign.
const [showDeleteAllConfirm, setShowDeleteAllConfirm] = useState(false);
const handleDeleteAllCampaigns = async () => {
if (!db) return;
try {
const all = await storage.getCollection(getPath.campaigns());
await Promise.all(all.map(async (c) => {
const cid = c.id || c.path?.split('/').pop();
// bulk-delete all encounters under this campaign (SQL DELETE, no fetch)
if (storage.deleteCollection) {
await storage.deleteCollection(getPath.encounters(cid));
}
await storage.deleteDoc(getPath.campaign(cid));
}));
// nuke all logs in one shot (orphans acceptable in dev; logs span campaigns)
if (storage.deleteCollection) {
await storage.deleteCollection(getPath.logs());
}
const activeDisplay = await storage.getDoc(getPath.activeDisplay());
if (activeDisplay && activeDisplay.activeCampaignId) {
await storage.updateDoc(getPath.activeDisplay(), { activeCampaignId: null, activeEncounterId: null });
}
setSelectedCampaignId(null);
setShowDeleteAllConfirm(false);
showToast(`Deleted ${all.length} campaign(s).`);
} catch (err) {
console.error('Error deleting all campaigns:', err);
showToast('Failed to delete all campaigns.');
}
};
const selectedCampaign = campaignsWithDetails.find(c => c.id === selectedCampaignId);
if (isLoadingCampaigns) {
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">
<button
onClick={() => setCampaignsCollapsed(c => !c)}
className="flex items-center gap-2 text-2xl font-semibold text-amber-300 font-cinzel tracking-wide hover:text-amber-200 transition-colors"
aria-expanded={!campaignsCollapsed}
aria-controls="campaigns-grid"
>
{campaignsCollapsed
? <ChevronRight size={24} />
: <ChevronDown size={24} />}
Campaigns
<span className="text-sm font-normal text-stone-400">({campaignsWithDetails.length})</span>
</button>
<button
onClick={() => setShowCreateModal(true)}
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>
{!campaignsCollapsed && (
<>
{campaignsWithDetails.length === 0 && !isLoadingCampaigns && (
<p className="text-stone-400">No campaigns yet. Create one to get started!</p>
)}
<div id="campaigns-grid" className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{campaignsWithDetails.map(campaign => {
const cardStyle = campaign.playerDisplayBackgroundUrl
? { backgroundImage: `url(${campaign.playerDisplayBackgroundUrl})` }
: {};
const cardClasses = `h-40 flex flex-col justify-between rounded-lg shadow-md cursor-pointer transition-all relative overflow-hidden bg-cover bg-center ${selectedCampaignId === campaign.id ? 'ring-4 ring-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>
<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>
{campaign.createdAt && (
<div className="text-xs text-stone-300 opacity-70 mt-1">
<Clock size={12} className="mr-1 inline-block" /> Created: {new Date(campaign.createdAt).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false })}
</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 && (
<div className="mt-6 p-6 bg-stone-900 rounded-lg shadow-xl">
<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 || []}
/>
<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.`}
/>
{process.env.NODE_ENV === 'development' && campaignsWithDetails.length > 0 && (
<div className="mt-4">
<button
onClick={() => setShowDeleteAllConfirm(true)}
className="px-3 py-1.5 text-xs rounded bg-red-900 hover:bg-red-800 text-red-100 border border-red-700"
title="DEV ONLY: Delete every campaign, encounter, and log. Irreversible."
>
<Trash2 size={12} className="inline mr-1" />DEV: Delete All Campaigns
</button>
</div>
)}
<ConfirmationModal
isOpen={showDeleteAllConfirm}
onClose={() => setShowDeleteAllConfirm(false)}
onConfirm={handleDeleteAllCampaigns}
title="Delete ALL Campaigns?"
message="DEV ONLY: This permanently deletes EVERY campaign, all encounters, and all logs. Cannot be undone. Are you absolutely sure?"
/>
</>
);
}
// ============================================================================
// DISPLAY VIEW COMPONENT (Player View)
// ============================================================================
// Player participant card: animate state transitions.
// mount/activate : animate in (opacity+scale+slide)
// deactivate : animate out then signal exit
// alive→dead : transition to death cue (dim+desaturate+skull pulse+red rim)
// conscious/dying/stable: full visible, no fade
function PlayerParticipantCard({ id, isActive, status, onExit, className, children, divRef }) {
// phase: 'hidden' (opacity-0, start state) -> 'visible' (opacity-100)
// disable: 'visible' -> 'exiting' (opacity-0, transition plays) -> onExit
const [phase, setPhase] = useState('hidden');
const isDead = status === 'dead';
useEffect(() => {
if (!isActive) {
// ensure current 'visible' frame painted, THEN flip to exiting so
// browser sees opacity change and plays the fade-out.
const raf = requestAnimationFrame(() => {
setPhase('exiting');
});
const t = setTimeout(() => onExit(id), 1000);
return () => { cancelAnimationFrame(raf); clearTimeout(t); };
}
// enable/reactivate: paint hidden frame first, THEN flip to visible
// so browser sees opacity change and plays the transition.
setPhase('hidden');
const raf = requestAnimationFrame(() => {
requestAnimationFrame(() => setPhase('visible'));
});
return () => cancelAnimationFrame(raf);
}, [isActive, id, onExit]);
// animate: opacity + scale + slide. Visible motion, not just fade.
const fadeClass = phase === 'visible'
? 'opacity-100 scale-100 translate-y-0'
: 'opacity-0 scale-75 translate-y-4';
// death transition: fires once when alive→dead. dim + desaturate + red rim.
const deathClass = isDead
? 'brightness-50 saturate-0 border-2 border-red-900/70 shadow-red-900/50 shadow-2xl'
: '';
return (
<div
ref={divRef}
className={`[transition:opacity_1s_ease-in-out,transform_1s_ease-in-out,filter_1s_ease-in-out,border-color_1s_ease-in-out,box-shadow_300ms_ease-in-out] ${fadeClass} ${deathClass} ${className}`}
>
{children}
{isDead && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<span className="text-6xl opacity-80 animate-pulse">☠️</span>
</div>
)}
</div>
);
}
function DisplayView() {
const { data: activeDisplayData, isLoading: isLoadingActiveDisplay, error: activeDisplayError } = useFirestoreDocument(
getPath.activeDisplay()
);
const [activeEncounterData, setActiveEncounterData] = useState(null);
const [isLoadingEncounter, setIsLoadingEncounter] = useState(true);
const [encounterError, setEncounterError] = useState(null);
const [campaignBackgroundUrl, setCampaignBackgroundUrl] = useState('');
const [isPlayerDisplayActive, setIsPlayerDisplayActive] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [wakeLockEnabled, setWakeLockEnabled] = useState(false);
const [displayParticipants, setDisplayParticipants] = useState([]);
const wakeLockRef = useRef(null);
const currentParticipantRef = useRef(null);
// Player display transition state. Active participants render normally.
// Active→inactive: keep prior card, mark __displayActive=false, animate out,
// then PlayerParticipantCard calls handleExit to remove from display list.
// Inactive→active: card reappears with __displayActive=true and fades in.
useEffect(() => {
const source = (activeEncounterData && activeEncounterData.participants) || [];
setDisplayParticipants(prev => {
const prevById = new Map(prev.map(p => [p.id, p]));
const out = [];
for (const p of source) {
const prevP = prevById.get(p.id);
if (p.isActive !== false) {
out.push({ ...p, __displayActive: true });
} else if (prevP && prevP.__displayActive !== false) {
out.push({ ...p, __displayActive: false });
} else if (prevP && prevP.__displayActive === false) {
out.push(prevP);
}
}
return out;
});
}, [activeEncounterData]);
const handleExit = useCallback((id) => {
setDisplayParticipants(prev => prev.filter(p => p.id !== id));
}, []);
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]);
useEffect(() => {
if (!db) {
setEncounterError("Firestore not available.");
setIsLoadingEncounter(false);
setIsPlayerDisplayActive(false);
return;
}
let unsubscribeEncounter;
let unsubscribeCampaign;
if (activeDisplayData) {
const { activeCampaignId, activeEncounterId } = activeDisplayData;
if (activeCampaignId && activeEncounterId) {
setIsPlayerDisplayActive(true);
setIsLoadingEncounter(true);
setEncounterError(null);
unsubscribeCampaign = storage.subscribeDoc(
getPath.campaign(activeCampaignId),
(camp) => {
setCampaignBackgroundUrl((camp && camp.playerDisplayBackgroundUrl) || '');
},
(err) => console.error("Error fetching campaign background:", err)
);
unsubscribeEncounter = storage.subscribeDoc(
getPath.encounter(activeCampaignId, activeEncounterId),
(enc) => {
if (enc) {
setActiveEncounterData({ id: activeEncounterId, ...enc });
} 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)) {
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>;
}
if (!isPlayerDisplayActive || !activeEncounterData) {
return (
<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" />
<h2 className="text-3xl font-semibold font-cinzel tracking-wide">Game Session Paused</h2>
<p className="text-xl mt-2">The Dungeon Master has not activated an encounter for display.</p>
</div>
);
}
const { name, participants, round, currentTurnParticipantId, isStarted, isPaused } = activeEncounterData;
const hidePlayerHp = activeDisplayData?.hidePlayerHp ?? true;
const hideNpcHp = activeDisplayData?.hideNpcHp ?? false;
// 1-list model: displayParticipants IS the display order (participants[] order
// plus temporary exiting cards). Do NOT re-sort by initiative.
const participantsToRender = displayParticipants;
const displayStyles = campaignBackgroundUrl
? {
backgroundImage: `url(${campaignBackgroundUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center center',
backgroundRepeat: 'no-repeat',
minHeight: '100vh'
}
: { minHeight: '100vh' };
return (
<div
className={`p-4 md:p-8 rounded-xl shadow-2xl ${!campaignBackgroundUrl ? 'bg-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>
<div className={campaignBackgroundUrl ? 'bg-stone-950 bg-opacity-75 p-4 md:p-6 rounded-lg' : ''}>
<h2 className="text-4xl md:text-5xl font-bold text-center text-amber-400 mb-2 font-cinzel tracking-wide">{name}</h2>
{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 && (
<p className="text-2xl text-center text-stone-400 mb-6">Awaiting Start</p>
)}
{!isStarted && (!participants || participants.length === 0) && (
<p className="text-2xl text-stone-500 mb-6">No participants.</p>
)}
{participantsToRender.length === 0 && isStarted && (
<p className="text-xl text-stone-400">No active participants.</p>
)}
<div className="space-y-4 max-w-3xl mx-auto">
{participantsToRender.map(p => {
const status = p.status || (p.currentHp === 0 ? 'dying' : 'conscious');
const isZeroHp = p.currentHp === 0;
let participantBgColor = p.type === 'monster' || p.type === 'npc'
? 'bg-[#8e351c]'
: 'bg-indigo-950';
const isCurrentTurn = p.id === currentTurnParticipantId && isStarted && !isPaused;
if (isCurrentTurn) {
participantBgColor = 'bg-green-700 ring-4 ring-green-400 scale-105';
} else if (isPaused && p.id === currentTurnParticipantId) {
participantBgColor += ' ring-2 ring-yellow-400';
}
return (
<PlayerParticipantCard
key={p.id}
id={p.id}
isActive={p.isActive !== false}
status={status}
onExit={handleExit}
divRef={isCurrentTurn ? currentParticipantRef : null}
className={`relative p-4 md:p-6 rounded-lg shadow-lg ${participantBgColor}`}
>
<div className="flex justify-between items-center mb-2">
<h3
className={`text-2xl md:text-3xl font-bold font-cinzel ${isCurrentTurn ? 'text-white' : (p.type === 'character' ? 'text-amber-100' : 'text-white')}`}
>
{isZeroHp && <span className="mr-2">☠️</span>}
{p.name}
{isCurrentTurn && (
<span className="text-yellow-300 animate-pulse ml-2">(Current)</span>
)}
{status === 'dying' && <span className="text-red-300 text-lg ml-2">(Dying)</span>}
{status === 'stable' && (p.conditions || []).includes('unconscious') && <span className="text-emerald-300 text-lg ml-2">(Unconscious)</span>}
{status === 'dead' && <span className="text-red-300 text-lg ml-2">(Dead)</span>}
</h3>
<span
className={`text-xl md:text-2xl font-semibold ${isCurrentTurn ? 'text-green-200' : 'text-stone-200'}`}
>
Init: {p.initiative}
</span>
</div>
{!(hidePlayerHp && p.type === 'character') && !(hideNpcHp && 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 ${isZeroHp ? '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 && (
<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>
) : (
<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"
>
{cId}
</span>
);
})}
</div>
)}
{!p.isActive && !isZeroHp && (
<p className="text-center text-lg font-semibold text-stone-300 mt-2">(Inactive)</p>
)}
</PlayerParticipantCard>
);
})}
</div>
</div>
</div>
);
}
// ============================================================================
// LOGS VIEW COMPONENT
// ============================================================================
function LogsView() {
const { showToast } = useUIFeedback();
const [page, setPage] = useState(0);
const pageQuery = useMemo(() => [LOG_ORDER, limit(LOG_PAGE_SIZE), offset(page * LOG_PAGE_SIZE)], [page]);
const { data: logs, isLoading } = useFirestoreCollection(getPath.logs(), pageQuery);
const { data: campaignsData } = useFirestoreCollection(getPath.campaigns());
const [showClearConfirm, setShowClearConfirm] = useState(false);
const [undoingId, setUndoingId] = useState(null);
const [dlMenu, setDlMenu] = useState(false);
const [cpMenu, setCpMenu] = useState(false);
// Full typed-log set for download/copy dropdown lists. Page query (LOG_QUERY,
// 100/page) only sees current page, so the encounter list was truncated.
// Lazy-fetch on first menu open; reused after. Server getCollection with no
// constraint returns all docs, so this is the full set (not page-limited).
// Ref guard (not state): StrictMode double-mount sets state then unmounts
// before fetch resolves; state-based loading flag dead-locks the remount.
const [allLogs, setAllLogs] = useState(null);
const allLogsFetched = useRef(false);
useEffect(() => {
if (!dlMenu && !cpMenu) return;
if (allLogs || allLogsFetched.current) return;
allLogsFetched.current = true;
let cancelled = false;
getStorage().getCollection(getPath.logs())
.then(docs => { if (!cancelled) setAllLogs(docs.filter(l => l.type)); })
.catch(err => { console.error('Error loading all logs:', err); allLogsFetched.current = false; })
.finally(() => { if (cancelled) allLogsFetched.current = false; });
return () => { cancelled = true; };
}, [dlMenu, cpMenu, allLogs]);
// Total log count (separate cheap server count query, not full fetch).
const [totalCount, setTotalCount] = useState(null);
useEffect(() => {
let cancelled = false;
getStorage().countCollection(getPath.logs())
.then(n => { if (!cancelled) setTotalCount(n); })
.catch(() => { if (!cancelled) setTotalCount(null); });
return () => { cancelled = true; };
}, [logs]);
const shown = (logs || []).length;
const rangeStart = totalCount ? page * LOG_PAGE_SIZE + 1 : 0;
const rangeEnd = totalCount ? Math.min((page + 1) * LOG_PAGE_SIZE, totalCount) : 0;
const hasPrev = page > 0;
const hasNext = totalCount === null ? shown === LOG_PAGE_SIZE : rangeEnd < totalCount;
// Parse campaign id out of encounterPath (.../campaigns/{cid}/encounters/{eid}).
const cidFromPath = (p) => {
if (!p) return null;
const m = String(p).match(/campaigns\/([^/]+)\/encounters/);
return m ? m[1] : null;
};
const campaignNameById = new Map(
(campaignsData || []).map(c => [c.id, c.name || '(unnamed campaign)'])
);
// Group typed logs by encounter. Each item shows campaign · encounter ·
// last-entry date · entry count. Sorted newest first by last-entry ts.
// Legacy logs (no type) excluded — kept for human scroll only.
const encounterGroups = (() => {
const src = allLogs ?? (logs || []);
const map = new Map(); // key: encounterId || encounterPath || 'none'
for (const l of src) {
if (!l.type) continue;
const key = l.encounterId || l.encounterPath || 'none';
const cid = cidFromPath(l.encounterPath);
const campName = cid ? (campaignNameById.get(cid) || '(unknown campaign)') : '(unknown campaign)';
const g = map.get(key) || {
key,
encounter: l.encounterName || '(unnamed encounter)',
campaign: campName,
count: 0,
latest: 0,
};
g.count++;
g.latest = Math.max(g.latest, l.ts || l.timestamp || 0);
map.set(key, g);
}
return [...map.values()].sort((a, b) => b.latest - a.latest);
})();
const typedCount = encounterGroups.reduce((n, g) => n + g.count, 0);
const fmtDate = (ts) => ts ? new Date(ts).toLocaleString() : '—';
// Filename-safe human date: 2026-07-04_15-30
const fileDate = (ts) => ts
? new Date(ts).toISOString().slice(0,16).replace('T', '_').replace(':', '-')
: new Date().toISOString().slice(0,16).replace('T', '_').replace(':', '-');
const dlFileName = (g) => g
? `ttrpg-logs-${(g.campaign + '_' + g.encounter).replace(/[^a-z0-9]+/gi, '_')}-${fileDate(g.latest)}.json`
: `ttrpg-logs-all-${fileDate(Date.now())}.json`;
const handleDownloadFor = async (encKey) => {
setDlMenu(false);
try {
const all = await storage.getCollection(getPath.logs());
const filtered = encKey
? all.filter(l => (l.encounterId || l.encounterPath || 'none') === encKey)
: all;
const json = shared.logEvent.serializeEvents(filtered);
if (json === '[]') {
showToast('No typed log entries for that selection.');
return;
}
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const grp = encKey ? encounterGroups.find(g => g.key === encKey) : null;
a.download = dlFileName(grp);
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (err) {
console.error('Error downloading logs:', err);
showToast('Failed to download logs.');
}
};
const handleCopyFor = async (encKey) => {
setCpMenu(false);
try {
const all = await storage.getCollection(getPath.logs());
const filtered = encKey
? all.filter(l => (l.encounterId || l.encounterPath || 'none') === encKey)
: all;
const json = shared.logEvent.serializeEvents(filtered);
if (json === '[]') {
showToast('No typed log entries for that selection.');
return;
}
await navigator.clipboard.writeText(json);
showToast('Logs copied to clipboard.');
} catch (err) {
console.error('Error copying logs:', err);
showToast('Failed to copy logs.');
}
};
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);
}
} catch (err) {
console.error('Error clearing logs:', err);
}
setShowClearConfirm(false);
};
const handleUndo = async (entry) => {
if (!db) return;
setUndoingId(entry.id);
try {
if (storage.undo) {
const expanded = entry.undo
? expandUndo(entry, await storage.getDoc(entry.encounterPath))
: getUndo(entry);
if (!expanded) { setUndoingId(null); return; }
await storage.undo({ logPath: `${getPath.logs()}/${entry.id}`, undo: expanded });
}
} catch (err) {
console.error('Error undoing action:', err);
showToast("Failed to roll back. The encounter may have changed or no longer exists."); }
setUndoingId(null);
};
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>
<div className="relative">
<button
onClick={() => { setDlMenu(v => !v); setCpMenu(false); }}
disabled={isLoading || typedCount === 0}
className="px-4 py-2 rounded-md text-sm font-medium bg-stone-700 hover:bg-stone-600 text-white transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
Download
</button>
{dlMenu && (
<div className="absolute right-0 mt-1 w-80 bg-stone-800 border border-stone-700 rounded-md shadow-xl z-20 py-1 max-h-96 overflow-y-auto">
<button
onClick={() => handleDownloadFor(null)}
className="block w-full text-left px-4 py-2 text-sm text-stone-100 hover:bg-stone-700"
>
All encounters ({typedCount})
</button>
{encounterGroups.length > 0 && (
<div className="border-t border-stone-700 my-1" />
)}
{encounterGroups.map(g => (
<button
key={g.key}
onClick={() => handleDownloadFor(g.key)}
className="block w-full text-left px-3 py-2 text-sm text-stone-100 hover:bg-stone-700"
>
<div className="font-medium truncate">{g.encounter}</div>
<div className="text-xs text-stone-400 truncate">{g.campaign}</div>
<div className="text-xs text-stone-500">{fmtDate(g.latest)} · {g.count} entries</div>
</button>
))}
</div>
)}
</div>
<div className="relative">
<button
onClick={() => { setCpMenu(v => !v); setDlMenu(false); }}
disabled={isLoading || typedCount === 0}
className="px-4 py-2 rounded-md text-sm font-medium bg-stone-700 hover:bg-stone-600 text-white transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
Copy
</button>
{cpMenu && (
<div className="absolute right-0 mt-1 w-80 bg-stone-800 border border-stone-700 rounded-md shadow-xl z-20 py-1 max-h-96 overflow-y-auto">
<button
onClick={() => handleCopyFor(null)}
className="block w-full text-left px-4 py-2 text-sm text-stone-100 hover:bg-stone-700"
>
All encounters ({typedCount})
</button>
{encounterGroups.length > 0 && (
<div className="border-t border-stone-700 my-1" />
)}
{encounterGroups.map(g => (
<button
key={g.key}
onClick={() => handleCopyFor(g.key)}
className="block w-full text-left px-3 py-2 text-sm text-stone-100 hover:bg-stone-700"
>
<div className="font-medium truncate">{g.encounter}</div>
<div className="text-xs text-stone-400 truncate">{g.campaign}</div>
<div className="text-xs text-stone-500">{fmtDate(g.latest)} · {g.count} entries</div>
</button>
))}
</div>
)}
</div>
<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>
) : (
<>
<div className="flex items-center justify-between mb-4">
<p className="text-stone-500 text-sm">
{totalCount === null
? `${shown} entries — newest first`
: `Showing ${rangeStart}${rangeEnd} of ${totalCount}`}
</p>
<div className="flex items-center gap-2">
<button
onClick={() => setPage(p => Math.max(0, p - 1))}
disabled={!hasPrev}
className="px-3 py-1 rounded text-sm bg-stone-700 hover:bg-stone-600 text-white transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
Prev
</button>
<span className="text-stone-500 text-sm">Page {page + 1}</span>
<button
onClick={() => setPage(p => p + 1)}
disabled={!hasNext}
className="px-3 py-1 rounded text-sm bg-stone-700 hover:bg-stone-600 text-white transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
Next
</button>
</div>
</div>
<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'
}`}
>
<span className="text-stone-500 whitespace-nowrap font-mono shrink-0">
{fmtDate(entry.ts || entry.timestamp)}
</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>
) : getUndo(entry) ? (
<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}
</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);
const [isLogsMode, setIsLogsMode] = useState(false);
useEffect(() => {
const queryParams = new URLSearchParams(window.location.search);
if (queryParams.get('playerView') === 'true' || window.location.pathname === '/display') {
setIsPlayerViewOnlyMode(true);
}
if (window.location.pathname === '/logs') {
setIsLogsMode(true);
}
if (!auth) {
setError("Auth not initialized.");
setIsLoading(false);
setIsAuthReady(false);
return;
}
// ws/memory mode: stub auth, no SDK sign-in. Unblock UI immediately.
if (STORAGE_MODE !== 'firebase') {
setUserId(auth.currentUser?.uid || 'local-user');
setIsAuthReady(true);
setIsLoading(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 (!isInitialized || !auth) {
return (
<ErrorDisplay
critical
message={`${STORAGE_MODE === 'firebase' ? 'Firebase' : 'Storage'} is not properly configured. Check your .env.local file and ensure all REACT_APP_* variables are correctly set.`}
/>
);
}
if (isLoading || !isAuthReady) {
return <LoadingSpinner message="Loading Initiative Tracker..." />;
}
if (error) {
return <ErrorDisplay message={error} />;
}
const openPlayerWindow = () => {
const playerViewUrl = window.location.origin + '/display';
window.open(playerViewUrl, '_blank', 'noopener,noreferrer,width=1024,height=768');
};
if (isPlayerViewOnlyMode) {
return (
<UIFeedbackProvider>
<div className="min-h-screen bg-stone-950 text-stone-100 font-garamond">
{isAuthReady && <DisplayView />}
{!isAuthReady && !error && <p>Authenticating for Player Display...</p>}
</div>
</UIFeedbackProvider>
);
}
if (isLogsMode) {
return (
<UIFeedbackProvider>
{isAuthReady ? <LogsView /> : <LoadingSpinner message="Authenticating..." />}
</UIFeedbackProvider>
);
}
return (
<UIFeedbackProvider>
<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">TTRPG Initiative Tracker</h1>
<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>
</div>
</header>
<main className="container mx-auto p-4 md:p-8">
{isAuthReady && userId && <AdminView userId={userId} />}
{!isAuthReady && !error && <p>Authenticating...</p>}
</main>
<footer className="bg-stone-950 p-4 text-center text-sm text-stone-400 mt-8">
TTRPG Initiative Tracker {APP_VERSION}
</footer>
</div>
</UIFeedbackProvider>
);
}
export default App;