Character list items draggable (ChevronsUpDown handle). Drop reorders players array on campaign doc (single updateDoc). Array order = display order.
3652 lines
155 KiB
JavaScript
3652 lines
155 KiB
JavaScript
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, 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,
|
||
markDead,
|
||
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 [ruleset, setRuleset] = useState('5e');
|
||
|
||
const handleSubmit = (e) => {
|
||
e.preventDefault();
|
||
if (name.trim()) {
|
||
onCreate(name, backgroundUrl, ruleset);
|
||
}
|
||
};
|
||
|
||
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>
|
||
<label className="block text-sm font-medium text-stone-300 mb-1">Ruleset</label>
|
||
<div className="flex gap-4">
|
||
<label className="flex items-center gap-2 text-sm text-stone-200">
|
||
<input type="radio" value="5e" checked={ruleset === '5e'} onChange={() => setRuleset('5e')} />
|
||
D&D 5e (death saves)
|
||
</label>
|
||
<label className="flex items-center gap-2 text-sm text-stone-200">
|
||
<input type="radio" value="generic" checked={ruleset === 'generic'} onChange={() => setRuleset('generic')} />
|
||
Generic (no death saves, negative HP)
|
||
</label>
|
||
</div>
|
||
</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, defaultRuleset = '5e' }) {
|
||
const [name, setName] = useState('');
|
||
const [ruleset, setRuleset] = useState(defaultRuleset);
|
||
|
||
const handleSubmit = (e) => {
|
||
e.preventDefault();
|
||
if (name.trim()) {
|
||
onCreate(name, ruleset);
|
||
}
|
||
};
|
||
|
||
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>
|
||
<label className="block text-sm font-medium text-stone-300 mb-1">Ruleset</label>
|
||
<div className="flex gap-4">
|
||
<label className="flex items-center gap-2 text-sm text-stone-200">
|
||
<input type="radio" value="5e" checked={ruleset === '5e'} onChange={() => setRuleset('5e')} />
|
||
D&D 5e (death saves)
|
||
</label>
|
||
<label className="flex items-center gap-2 text-sm text-stone-200">
|
||
<input type="radio" value="generic" checked={ruleset === 'generic'} onChange={() => setRuleset('generic')} />
|
||
Generic (no death saves, negative HP)
|
||
</label>
|
||
</div>
|
||
</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 [draggedCharId, setDraggedCharId] = useState(null);
|
||
|
||
const handleCharDragStart = (e, id) => { setDraggedCharId(id); e.dataTransfer.effectAllowed = 'move'; };
|
||
const handleCharDragOver = (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; };
|
||
const handleCharDrop = async (e, targetId) => {
|
||
e.preventDefault();
|
||
if (!campaignId || !draggedCharId || draggedCharId === targetId) { setDraggedCharId(null); return; }
|
||
const reordered = [...campaignCharacters];
|
||
const fromIdx = reordered.findIndex(c => c.id === draggedCharId);
|
||
const toIdx = reordered.findIndex(c => c.id === targetId);
|
||
if (fromIdx === -1 || toIdx === -1) { setDraggedCharId(null); return; }
|
||
const [moved] = reordered.splice(fromIdx, 1);
|
||
reordered.splice(toIdx, 0, moved);
|
||
try { await storage.updateDoc(getPath.campaign(campaignId), { players: reordered }); } catch (err) {}
|
||
setDraggedCharId(null);
|
||
};
|
||
const [isOpen, setIsOpen] = useState(() => {
|
||
try { return localStorage.getItem('ttrpg.charactersCollapsed') !== 'true'; }
|
||
catch { return true; }
|
||
});
|
||
useEffect(() => {
|
||
try { localStorage.setItem('ttrpg.charactersCollapsed', String(!isOpen)); }
|
||
catch {}
|
||
}, [isOpen]);
|
||
|
||
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">
|
||
<button
|
||
onClick={() => setIsOpen(!isOpen)}
|
||
className="flex items-center text-xl font-semibold text-amber-300 font-cinzel tracking-wide hover:text-amber-200 transition-colors"
|
||
aria-expanded={isOpen}
|
||
>
|
||
{isOpen ? <ChevronDown size={20} className="mr-1" /> : <ChevronRight size={20} className="mr-1" />}
|
||
<Users size={24} className="mr-2" /> Campaign Characters
|
||
<span className="text-sm font-normal text-stone-400 ml-1">({campaignCharacters.length})</span>
|
||
</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}
|
||
draggable
|
||
onDragStart={(e) => handleCharDragStart(e, character.id)}
|
||
onDragOver={handleCharDragOver}
|
||
onDrop={(e) => handleCharDrop(e, character.id)}
|
||
onDragEnd={() => setDraggedCharId(null)}
|
||
className={`flex justify-between items-center p-3 bg-stone-800 rounded-md cursor-grab ${draggedCharId === character.id ? 'opacity-50 ring-2 ring-yellow-400' : ''}`}
|
||
>
|
||
<div className="flex items-center flex-grow">
|
||
<ChevronsUpDown size={16} className="text-stone-400 flex-shrink-0 mr-2" title="Drag to reorder" />
|
||
{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>
|
||
</>
|
||
)}
|
||
</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 handleMarkDead = async (participantId) => {
|
||
if (!db) return;
|
||
try {
|
||
await markDead(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 isGeneric = (encounter.ruleset || '5e') === 'generic';
|
||
const hasDeathSaves = !isGeneric && (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 || (isGeneric
|
||
? (p.currentHp <= 0 ? 'down' : 'conscious')
|
||
: (p.currentHp === 0 ? (p.type === 'monster' ? 'dead' : 'dying') : 'conscious'));
|
||
const isZeroHp = p.currentHp <= 0;
|
||
const isDead = participantStatus === 'dead';
|
||
const isDown = participantStatus === 'down';
|
||
|
||
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>}
|
||
{isDown && <span className="ml-2 text-xs text-red-300 font-semibold animate-pulse">(Down)</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 (clear dead status)">
|
||
<HeartPulse size={14} /> Revive
|
||
</button>
|
||
)}
|
||
|
||
{isGeneric && !isDead && (
|
||
<button onClick={() => handleMarkDead(p.id)} className="mt-2 inline-flex items-center gap-1 self-start px-2.5 py-1 text-xs rounded bg-red-900 hover:bg-red-800 text-white" title="Mark dead">
|
||
<HeartCrack size={14} /> Mark Dead
|
||
</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, encounterStartedRef, encounterActiveRef }) {
|
||
const { showToast } = useUIFeedback();
|
||
const { data: encountersData, isLoading: isLoadingEncounters } = useFirestoreCollection(
|
||
campaignId ? getPath.encounters(campaignId) : null
|
||
);
|
||
const { data: activeDisplayInfo } = useFirestoreDocument(getPath.activeDisplay());
|
||
const { data: campaignDoc } = useFirestoreDocument(campaignId ? getPath.campaign(campaignId) : null);
|
||
|
||
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 [draggedEncounterId, setDraggedEncounterId] = useState(null);
|
||
|
||
const selectedEncounterIdRef = useRef(selectedEncounterId);
|
||
|
||
useEffect(() => {
|
||
if (encountersData) setEncounters(encountersData);
|
||
}, [encountersData]);
|
||
|
||
// Sort by order field (fallback createdAt). Drag reorder writes order.
|
||
const sortedEncounters = [...(encounters || [])].sort((a, b) => {
|
||
const ao = a.order ?? null, bo = b.order ?? null;
|
||
if (ao !== null && bo !== null) return ao - bo;
|
||
if (ao !== null) return -1;
|
||
if (bo !== null) return 1;
|
||
return (a.createdAt || '').localeCompare(b.createdAt || '');
|
||
});
|
||
|
||
const handleEncounterDragStart = (e, id) => {
|
||
setDraggedEncounterId(id);
|
||
e.dataTransfer.effectAllowed = 'move';
|
||
};
|
||
const handleEncounterDragOver = (e) => {
|
||
e.preventDefault();
|
||
e.dataTransfer.dropEffect = 'move';
|
||
};
|
||
const handleEncounterDrop = async (e, targetId) => {
|
||
e.preventDefault();
|
||
if (!db || !draggedEncounterId || draggedEncounterId === targetId) {
|
||
setDraggedEncounterId(null);
|
||
return;
|
||
}
|
||
const reordered = [...sortedEncounters];
|
||
const fromIdx = reordered.findIndex(x => x.id === draggedEncounterId);
|
||
const toIdx = reordered.findIndex(x => x.id === targetId);
|
||
if (fromIdx === -1 || toIdx === -1) { setDraggedEncounterId(null); return; }
|
||
const [moved] = reordered.splice(fromIdx, 1);
|
||
reordered.splice(toIdx, 0, moved);
|
||
// assign order = index, batch update
|
||
const ops = reordered.map((enc, i) => ({
|
||
type: 'update',
|
||
path: `${getPath.encounters(campaignId)}/${enc.id}`,
|
||
data: { order: i },
|
||
}));
|
||
try { await storage.batchWrite(ops); } catch (err) {}
|
||
setDraggedEncounterId(null);
|
||
};
|
||
|
||
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, ruleset = '5e') => {
|
||
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,
|
||
ruleset: ruleset || '5e',
|
||
order: (encounters || []).reduce((max, e) => Math.max(max, e.order ?? -1), -1) + 1,
|
||
});
|
||
|
||
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);
|
||
|
||
useEffect(() => {
|
||
if (encounterStartedRef) {
|
||
encounterStartedRef.current = !!(selectedEncounter && selectedEncounter.isStarted && !selectedEncounter.isPaused);
|
||
}
|
||
if (encounterActiveRef) {
|
||
encounterActiveRef.current = !!(selectedEncounter && selectedEncounter.isStarted);
|
||
}
|
||
}, [selectedEncounter, encounterStartedRef, encounterActiveRef]);
|
||
|
||
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">
|
||
{sortedEncounters.map(encounter => {
|
||
const isLive = activeDisplayInfo &&
|
||
activeDisplayInfo.activeCampaignId === campaignId &&
|
||
activeDisplayInfo.activeEncounterId === encounter.id;
|
||
|
||
return (
|
||
<div
|
||
key={encounter.id}
|
||
draggable
|
||
onDragStart={(e) => handleEncounterDragStart(e, encounter.id)}
|
||
onDragOver={handleEncounterDragOver}
|
||
onDrop={(e) => handleEncounterDrop(e, encounter.id)}
|
||
onDragEnd={() => setDraggedEncounterId(null)}
|
||
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' : ''} ${draggedEncounterId === encounter.id ? 'opacity-50 ring-2 ring-yellow-400' : ''} cursor-grab`}
|
||
>
|
||
<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} <span className={`ml-1 px-1.5 py-0.5 rounded text-xs font-bold tracking-wide ${encounter.ruleset === 'generic' ? 'bg-purple-900/80 text-purple-200 border border-purple-500' : 'bg-amber-900/80 text-amber-200 border border-amber-500'}`}>{encounter.ruleset === 'generic' ? 'GEN' : '5e'}</span></h4>
|
||
<p className="text-xs text-stone-300">
|
||
{encounter.createdAt && `${new Date(encounter.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false })} · `}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">
|
||
<ChevronsUpDown
|
||
size={18}
|
||
className="text-stone-400 flex-shrink-0 cursor-grab"
|
||
title="Drag to reorder"
|
||
/>
|
||
<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
|
||
key={campaignId}
|
||
onCreate={handleCreateEncounter}
|
||
onCancel={() => setShowCreateModal(false)}
|
||
defaultRuleset={campaignDoc?.ruleset || '5e'}
|
||
/>
|
||
</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 [draggedCampaignId, setDraggedCampaignId] = useState(null);
|
||
const [selectedCampaignId, setSelectedCampaignId] = useState(null);
|
||
const encounterStartedRef = useRef(false);
|
||
const encounterActiveRef = useRef(false);
|
||
const manualSelectRef = useRef(false);
|
||
const prevDisplayCampaignRef = useRef(null);
|
||
|
||
// External display change (replay/other DM) = clear manual override, allow follow.
|
||
useEffect(() => {
|
||
const cur = initialActiveInfo?.activeCampaignId || null;
|
||
if (cur !== prevDisplayCampaignRef.current) {
|
||
manualSelectRef.current = false;
|
||
prevDisplayCampaignRef.current = cur;
|
||
}
|
||
}, [initialActiveInfo]);
|
||
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 };
|
||
})
|
||
);
|
||
|
||
// sort by order field (fallback createdAt)
|
||
detailedCampaigns.sort((a, b) => {
|
||
const ao = a.order ?? null, bo = b.order ?? null;
|
||
if (ao !== null && bo !== null) return ao - bo;
|
||
if (ao !== null) return -1;
|
||
if (bo !== null) return 1;
|
||
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 ao = a.order ?? null, bo = b.order ?? null;
|
||
if (ao !== null && bo !== null) return ao - bo;
|
||
if (ao !== null) return -1;
|
||
if (bo !== null) return 1;
|
||
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(() => {
|
||
// Skip follow only if user manually selected AND display hasn't changed.
|
||
// (external display change clears manualSelectRef via prevDisplay effect)
|
||
if (manualSelectRef.current && selectedCampaignId !== initialActiveInfo?.activeCampaignId) return;
|
||
if (
|
||
initialActiveInfo &&
|
||
initialActiveInfo.activeCampaignId &&
|
||
campaignsWithDetails.length > 0 &&
|
||
!encounterStartedRef.current &&
|
||
!encounterActiveRef.current
|
||
) {
|
||
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, ruleset = '5e') => {
|
||
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: [],
|
||
ruleset: ruleset || '5e',
|
||
order: (campaignsWithDetails || []).reduce((max, c) => Math.max(max, c.order ?? -1), -1) + 1,
|
||
});
|
||
|
||
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-grab 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'} ${draggedCampaignId === campaign.id ? 'opacity-50 ring-2 ring-yellow-400' : ''}`;
|
||
|
||
return (
|
||
<div
|
||
key={campaign.id}
|
||
draggable
|
||
onDragStart={(e) => { setDraggedCampaignId(campaign.id); e.dataTransfer.effectAllowed = 'move'; }}
|
||
onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }}
|
||
onDrop={async (e) => {
|
||
e.preventDefault();
|
||
if (!db || !draggedCampaignId || draggedCampaignId === campaign.id) { setDraggedCampaignId(null); return; }
|
||
const reordered = [...campaignsWithDetails];
|
||
const fromIdx = reordered.findIndex(c => c.id === draggedCampaignId);
|
||
const toIdx = reordered.findIndex(c => c.id === campaign.id);
|
||
if (fromIdx === -1 || toIdx === -1) { setDraggedCampaignId(null); return; }
|
||
const [moved] = reordered.splice(fromIdx, 1);
|
||
reordered.splice(toIdx, 0, moved);
|
||
const ops = reordered.map((c, i) => ({ type: 'update', path: getPath.campaign(c.id), data: { order: i } }));
|
||
try { await storage.batchWrite(ops); } catch (err) {}
|
||
setDraggedCampaignId(null);
|
||
}}
|
||
onDragEnd={() => setDraggedCampaignId(null)}
|
||
onClick={() => {
|
||
if (encounterStartedRef.current) {
|
||
showToast('End or pause active encounter before switching campaigns.');
|
||
return;
|
||
}
|
||
manualSelectRef.current = true;
|
||
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>
|
||
<div className="flex items-center gap-1">
|
||
<ChevronsUpDown size={14} className="text-white/60 flex-shrink-0" title="Drag to reorder" />
|
||
<h3 className="text-xl font-semibold text-white">{campaign.name}</h3>
|
||
</div>
|
||
<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>
|
||
<span
|
||
className={`absolute bottom-2 right-2 z-20 px-2 py-0.5 rounded text-xs font-bold tracking-wide ${campaign.ruleset === 'generic' ? 'bg-purple-900/80 text-purple-200 border border-purple-500' : 'bg-amber-900/80 text-amber-200 border border-amber-500'}`}
|
||
title={campaign.ruleset === 'generic' ? 'Generic ruleset' : 'D&D 5e ruleset'}
|
||
>
|
||
{campaign.ruleset === 'generic' ? 'GEN' : '5e'}
|
||
</span>
|
||
</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 || []}
|
||
encounterStartedRef={encounterStartedRef}
|
||
encounterActiveRef={encounterActiveRef}
|
||
/>
|
||
</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 isGeneric = (activeEncounterData.ruleset || '5e') === 'generic';
|
||
const status = p.status || (isGeneric ? (p.currentHp <= 0 ? 'down' : 'conscious') : (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 === 'down' && <span className="text-red-300 text-lg ml-2 animate-pulse">(Down)</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;
|