Add combat action log at /logs
Instruments 9 handlers (combat start/end/pause/resume, next turn, participant add/remove/toggle, HP changes, conditions) to write timestamped entries to a Firestore logs collection. New LogsView at /logs shows entries newest-first with encounter context, and includes a Clear Log button. Adds a View Logs link in the header. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+137
-4
@@ -1,12 +1,12 @@
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { initializeApp } from 'firebase/app';
|
||||
import { getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken } from 'firebase/auth';
|
||||
import { getFirestore, doc, setDoc, getDoc, getDocs, collection, onSnapshot, updateDoc, deleteDoc, query, writeBatch } from 'firebase/firestore';
|
||||
import { getFirestore, doc, setDoc, getDoc, getDocs, addDoc, collection, onSnapshot, updateDoc, deleteDoc, query, orderBy, limit, writeBatch } from 'firebase/firestore';
|
||||
import {
|
||||
PlusCircle, Users, Swords, Trash2, Eye, Edit3, Save, XCircle, ChevronsUpDown,
|
||||
UserCheck, UserX, HeartCrack, HeartPulse, Zap, EyeOff, ExternalLink, AlertTriangle,
|
||||
Play as PlayIcon, Pause as PauseIcon, SkipForward as SkipForwardIcon,
|
||||
StopCircle as StopCircleIcon, Users2, Dices, ChevronUp, ChevronDown
|
||||
StopCircle as StopCircleIcon, Users2, Dices, ChevronUp, ChevronDown, ScrollText
|
||||
} from 'lucide-react';
|
||||
|
||||
// Custom CSS for death animation (player view only)
|
||||
@@ -126,7 +126,8 @@ const getPath = {
|
||||
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`
|
||||
activeDisplay: () => `${PUBLIC_DATA_PATH}/activeDisplay/status`,
|
||||
logs: () => `${PUBLIC_DATA_PATH}/logs`
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
@@ -152,6 +153,17 @@ const sortParticipantsByInitiative = (participants, originalOrder) => {
|
||||
});
|
||||
};
|
||||
|
||||
const LOG_QUERY = [orderBy('timestamp', 'desc'), limit(500)];
|
||||
|
||||
const logAction = async (message, context = {}) => {
|
||||
if (!db) return;
|
||||
try {
|
||||
await addDoc(collection(db, getPath.logs()), { timestamp: Date.now(), message, ...context });
|
||||
} catch (err) {
|
||||
console.error('Error writing log:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// Returns turnOrderIds/currentTurnParticipantId updates when a participant leaves active combat.
|
||||
const computeTurnOrderAfterRemoval = (encounter, removedId, updatedParticipants) => {
|
||||
if (!encounter.isStarted) return {};
|
||||
@@ -884,6 +896,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
|
||||
await updateDoc(doc(db, encounterPath), {
|
||||
participants: [...participants, newParticipant]
|
||||
});
|
||||
logAction(`${nameToAdd} added to encounter (Initiative: ${finalInitiative})`, { encounterName: encounter.name });
|
||||
|
||||
setLastRollDetails({
|
||||
name: nameToAdd,
|
||||
@@ -983,6 +996,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
|
||||
participants: updatedParticipants,
|
||||
...computeTurnOrderAfterRemoval(encounter, itemToDelete.id, updatedParticipants)
|
||||
});
|
||||
logAction(`${itemToDelete.name} removed from encounter`, { encounterName: encounter.name });
|
||||
} catch (err) {
|
||||
console.error("Error deleting participant:", err);
|
||||
alert("Failed to delete participant. Please try again.");
|
||||
@@ -1009,6 +1023,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
|
||||
|
||||
try {
|
||||
await updateDoc(doc(db, encounterPath), { participants: updatedParticipants, ...turnUpdates });
|
||||
logAction(`${participant.name} ${newIsActive ? 'reactivated' : 'deactivated'}`, { encounterName: encounter.name });
|
||||
} catch (err) {
|
||||
console.error("Error toggling active state:", err);
|
||||
}
|
||||
@@ -1073,6 +1088,14 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
|
||||
try {
|
||||
await updateDoc(doc(db, encounterPath), { participants: updatedParticipants, ...turnUpdates });
|
||||
setHpChangeValues(prev => ({ ...prev, [participantId]: '' }));
|
||||
const hpLine = `${participant.currentHp} → ${newHp} HP`;
|
||||
const deathSuffix = (isDead && !wasDead) ? (participant.type === 'character' ? ' — Unconscious' : ' — Defeated') : '';
|
||||
const resurSuffix = wasResurrected ? ' — Revived' : '';
|
||||
if (changeType === 'damage') {
|
||||
logAction(`${participant.name} took ${amount} damage (${hpLine})${deathSuffix}`, { encounterName: encounter.name });
|
||||
} else {
|
||||
logAction(`${participant.name} healed for ${amount} (${hpLine})${resurSuffix}`, { encounterName: encounter.name });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error applying HP change:", err);
|
||||
}
|
||||
@@ -1124,16 +1147,22 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
|
||||
|
||||
const toggleCondition = async (participantId, conditionId) => {
|
||||
if (!db) return;
|
||||
const participant = participants.find(p => p.id === participantId);
|
||||
if (!participant) return;
|
||||
const wasActive = (participant.conditions || []).includes(conditionId);
|
||||
const updatedParticipants = participants.map(p => {
|
||||
if (p.id !== participantId) return p;
|
||||
const current = p.conditions || [];
|
||||
const next = current.includes(conditionId)
|
||||
const next = wasActive
|
||||
? current.filter(c => c !== conditionId)
|
||||
: [...current, conditionId];
|
||||
return { ...p, conditions: next };
|
||||
});
|
||||
try {
|
||||
await updateDoc(doc(db, encounterPath), { participants: updatedParticipants });
|
||||
const cond = CONDITIONS.find(c => c.id === conditionId);
|
||||
const condLabel = cond ? `${cond.label} ${cond.emoji}` : conditionId;
|
||||
logAction(`${participant.name} ${wasActive ? 'lost' : 'gained'} ${condLabel}`, { encounterName: encounter.name });
|
||||
} catch (err) {
|
||||
console.error("Error updating conditions:", err);
|
||||
}
|
||||
@@ -1586,6 +1615,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
|
||||
activeEncounterId: encounter.id
|
||||
}, { merge: true });
|
||||
|
||||
logAction(`Combat started: "${encounter.name}" — ${sortedParticipants[0].name}'s turn (Round 1)`, { encounterName: encounter.name });
|
||||
console.log("Encounter started and set as active display.");
|
||||
} catch (err) {
|
||||
console.error("Error starting encounter:", err);
|
||||
@@ -1610,6 +1640,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
|
||||
isPaused: newPausedState,
|
||||
turnOrderIds: newTurnOrderIds
|
||||
});
|
||||
logAction(`Combat ${newPausedState ? 'paused' : 'resumed'}: "${encounter.name}"`, { encounterName: encounter.name });
|
||||
} catch (err) {
|
||||
console.error("Error toggling pause state:", err);
|
||||
}
|
||||
@@ -1656,6 +1687,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
|
||||
currentTurnParticipantId: activePsInOrder[nextIndex].id,
|
||||
round: nextRound
|
||||
});
|
||||
logAction(`${activePsInOrder[nextIndex].name}'s turn (Round ${nextRound})`, { encounterName: encounter.name });
|
||||
} catch (err) {
|
||||
console.error("Error advancing turn:", err);
|
||||
}
|
||||
@@ -1678,6 +1710,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
|
||||
activeEncounterId: null
|
||||
}, { merge: true });
|
||||
|
||||
logAction(`Combat ended: "${encounter.name}"`, { encounterName: encounter.name });
|
||||
console.log("Encounter ended and deactivated from Player Display.");
|
||||
} catch (err) {
|
||||
console.error("Error ending encounter:", err);
|
||||
@@ -2504,6 +2537,88 @@ function DisplayView() {
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// LOGS VIEW COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
function LogsView() {
|
||||
const { data: logs, isLoading } = useFirestoreCollection(getPath.logs(), LOG_QUERY);
|
||||
const [showClearConfirm, setShowClearConfirm] = useState(false);
|
||||
|
||||
const handleClearLogs = async () => {
|
||||
if (!db) return;
|
||||
try {
|
||||
const snapshot = await getDocs(collection(db, getPath.logs()));
|
||||
if (!snapshot.empty) {
|
||||
const batch = writeBatch(db);
|
||||
snapshot.docs.forEach(d => batch.delete(d.ref));
|
||||
await batch.commit();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error clearing logs:', err);
|
||||
}
|
||||
setShowClearConfirm(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-stone-950 text-stone-100 font-garamond">
|
||||
<header className="bg-stone-950 p-4 shadow-lg border-b border-amber-900">
|
||||
<div className="container mx-auto flex justify-between items-center">
|
||||
<h1 className="text-3xl font-bold text-amber-400 font-cinzel tracking-wide">Combat Log</h1>
|
||||
<div className="flex gap-2">
|
||||
<a
|
||||
href="/"
|
||||
className="px-4 py-2 rounded-md text-sm font-medium bg-stone-700 hover:bg-stone-600 text-white transition-colors"
|
||||
>
|
||||
← Back to Tracker
|
||||
</a>
|
||||
<button
|
||||
onClick={() => setShowClearConfirm(true)}
|
||||
disabled={isLoading || logs.length === 0}
|
||||
className="px-4 py-2 rounded-md text-sm font-medium bg-red-800 hover:bg-red-700 text-white transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
Clear Log
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="container mx-auto p-4 md:p-8">
|
||||
{isLoading ? (
|
||||
<LoadingSpinner message="Loading logs..." />
|
||||
) : logs.length === 0 ? (
|
||||
<p className="text-stone-400 text-center mt-12 text-lg">No log entries yet.</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-stone-500 text-sm mb-4">{logs.length} entries — newest first</p>
|
||||
<div className="space-y-1 max-w-5xl">
|
||||
{logs.map(entry => (
|
||||
<div key={entry.id} className="flex gap-3 p-3 bg-stone-900 rounded-md border border-stone-800 text-sm">
|
||||
<span className="text-stone-500 whitespace-nowrap font-mono shrink-0">
|
||||
{new Date(entry.timestamp).toLocaleString()}
|
||||
</span>
|
||||
{entry.encounterName && (
|
||||
<span className="text-amber-600 whitespace-nowrap shrink-0">[{entry.encounterName}]</span>
|
||||
)}
|
||||
<span className="text-stone-100">{entry.message}</span>
|
||||
</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
|
||||
// ============================================================================
|
||||
@@ -2514,12 +2629,16 @@ function App() {
|
||||
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("Firebase Auth not initialized. Check your Firebase configuration.");
|
||||
@@ -2584,11 +2703,24 @@ function App() {
|
||||
);
|
||||
}
|
||||
|
||||
if (isLogsMode) {
|
||||
return isAuthReady ? <LogsView /> : <LoadingSpinner message="Authenticating..." />;
|
||||
}
|
||||
|
||||
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">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"
|
||||
@@ -2596,6 +2728,7 @@ function App() {
|
||||
<ExternalLink size={16} className="mr-2" /> Open Player Window
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="container mx-auto p-4 md:p-8">
|
||||
|
||||
Reference in New Issue
Block a user