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 React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||||
import { initializeApp } from 'firebase/app';
|
import { initializeApp } from 'firebase/app';
|
||||||
import { getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken } from 'firebase/auth';
|
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 {
|
import {
|
||||||
PlusCircle, Users, Swords, Trash2, Eye, Edit3, Save, XCircle, ChevronsUpDown,
|
PlusCircle, Users, Swords, Trash2, Eye, Edit3, Save, XCircle, ChevronsUpDown,
|
||||||
UserCheck, UserX, HeartCrack, HeartPulse, Zap, EyeOff, ExternalLink, AlertTriangle,
|
UserCheck, UserX, HeartCrack, HeartPulse, Zap, EyeOff, ExternalLink, AlertTriangle,
|
||||||
Play as PlayIcon, Pause as PauseIcon, SkipForward as SkipForwardIcon,
|
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';
|
} from 'lucide-react';
|
||||||
|
|
||||||
// Custom CSS for death animation (player view only)
|
// Custom CSS for death animation (player view only)
|
||||||
@@ -126,7 +126,8 @@ const getPath = {
|
|||||||
campaign: (id) => `${PUBLIC_DATA_PATH}/campaigns/${id}`,
|
campaign: (id) => `${PUBLIC_DATA_PATH}/campaigns/${id}`,
|
||||||
encounters: (campaignId) => `${PUBLIC_DATA_PATH}/campaigns/${campaignId}/encounters`,
|
encounters: (campaignId) => `${PUBLIC_DATA_PATH}/campaigns/${campaignId}/encounters`,
|
||||||
encounter: (campaignId, encounterId) => `${PUBLIC_DATA_PATH}/campaigns/${campaignId}/encounters/${encounterId}`,
|
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.
|
// Returns turnOrderIds/currentTurnParticipantId updates when a participant leaves active combat.
|
||||||
const computeTurnOrderAfterRemoval = (encounter, removedId, updatedParticipants) => {
|
const computeTurnOrderAfterRemoval = (encounter, removedId, updatedParticipants) => {
|
||||||
if (!encounter.isStarted) return {};
|
if (!encounter.isStarted) return {};
|
||||||
@@ -884,6 +896,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
|
|||||||
await updateDoc(doc(db, encounterPath), {
|
await updateDoc(doc(db, encounterPath), {
|
||||||
participants: [...participants, newParticipant]
|
participants: [...participants, newParticipant]
|
||||||
});
|
});
|
||||||
|
logAction(`${nameToAdd} added to encounter (Initiative: ${finalInitiative})`, { encounterName: encounter.name });
|
||||||
|
|
||||||
setLastRollDetails({
|
setLastRollDetails({
|
||||||
name: nameToAdd,
|
name: nameToAdd,
|
||||||
@@ -983,6 +996,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
|
|||||||
participants: updatedParticipants,
|
participants: updatedParticipants,
|
||||||
...computeTurnOrderAfterRemoval(encounter, itemToDelete.id, updatedParticipants)
|
...computeTurnOrderAfterRemoval(encounter, itemToDelete.id, updatedParticipants)
|
||||||
});
|
});
|
||||||
|
logAction(`${itemToDelete.name} removed from encounter`, { encounterName: encounter.name });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error deleting participant:", err);
|
console.error("Error deleting participant:", err);
|
||||||
alert("Failed to delete participant. Please try again.");
|
alert("Failed to delete participant. Please try again.");
|
||||||
@@ -1009,6 +1023,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await updateDoc(doc(db, encounterPath), { participants: updatedParticipants, ...turnUpdates });
|
await updateDoc(doc(db, encounterPath), { participants: updatedParticipants, ...turnUpdates });
|
||||||
|
logAction(`${participant.name} ${newIsActive ? 'reactivated' : 'deactivated'}`, { encounterName: encounter.name });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error toggling active state:", err);
|
console.error("Error toggling active state:", err);
|
||||||
}
|
}
|
||||||
@@ -1073,6 +1088,14 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
|
|||||||
try {
|
try {
|
||||||
await updateDoc(doc(db, encounterPath), { participants: updatedParticipants, ...turnUpdates });
|
await updateDoc(doc(db, encounterPath), { participants: updatedParticipants, ...turnUpdates });
|
||||||
setHpChangeValues(prev => ({ ...prev, [participantId]: '' }));
|
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) {
|
} catch (err) {
|
||||||
console.error("Error applying HP change:", err);
|
console.error("Error applying HP change:", err);
|
||||||
}
|
}
|
||||||
@@ -1124,16 +1147,22 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
|
|||||||
|
|
||||||
const toggleCondition = async (participantId, conditionId) => {
|
const toggleCondition = async (participantId, conditionId) => {
|
||||||
if (!db) return;
|
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 => {
|
const updatedParticipants = participants.map(p => {
|
||||||
if (p.id !== participantId) return p;
|
if (p.id !== participantId) return p;
|
||||||
const current = p.conditions || [];
|
const current = p.conditions || [];
|
||||||
const next = current.includes(conditionId)
|
const next = wasActive
|
||||||
? current.filter(c => c !== conditionId)
|
? current.filter(c => c !== conditionId)
|
||||||
: [...current, conditionId];
|
: [...current, conditionId];
|
||||||
return { ...p, conditions: next };
|
return { ...p, conditions: next };
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await updateDoc(doc(db, encounterPath), { participants: updatedParticipants });
|
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) {
|
} catch (err) {
|
||||||
console.error("Error updating conditions:", err);
|
console.error("Error updating conditions:", err);
|
||||||
}
|
}
|
||||||
@@ -1586,6 +1615,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
|
|||||||
activeEncounterId: encounter.id
|
activeEncounterId: encounter.id
|
||||||
}, { merge: true });
|
}, { 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.");
|
console.log("Encounter started and set as active display.");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error starting encounter:", err);
|
console.error("Error starting encounter:", err);
|
||||||
@@ -1610,6 +1640,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
|
|||||||
isPaused: newPausedState,
|
isPaused: newPausedState,
|
||||||
turnOrderIds: newTurnOrderIds
|
turnOrderIds: newTurnOrderIds
|
||||||
});
|
});
|
||||||
|
logAction(`Combat ${newPausedState ? 'paused' : 'resumed'}: "${encounter.name}"`, { encounterName: encounter.name });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error toggling pause state:", err);
|
console.error("Error toggling pause state:", err);
|
||||||
}
|
}
|
||||||
@@ -1656,6 +1687,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
|
|||||||
currentTurnParticipantId: activePsInOrder[nextIndex].id,
|
currentTurnParticipantId: activePsInOrder[nextIndex].id,
|
||||||
round: nextRound
|
round: nextRound
|
||||||
});
|
});
|
||||||
|
logAction(`${activePsInOrder[nextIndex].name}'s turn (Round ${nextRound})`, { encounterName: encounter.name });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error advancing turn:", err);
|
console.error("Error advancing turn:", err);
|
||||||
}
|
}
|
||||||
@@ -1678,6 +1710,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
|
|||||||
activeEncounterId: null
|
activeEncounterId: null
|
||||||
}, { merge: true });
|
}, { merge: true });
|
||||||
|
|
||||||
|
logAction(`Combat ended: "${encounter.name}"`, { encounterName: encounter.name });
|
||||||
console.log("Encounter ended and deactivated from Player Display.");
|
console.log("Encounter ended and deactivated from Player Display.");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error ending encounter:", 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
|
// MAIN APP COMPONENT
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -2514,12 +2629,16 @@ function App() {
|
|||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [isPlayerViewOnlyMode, setIsPlayerViewOnlyMode] = useState(false);
|
const [isPlayerViewOnlyMode, setIsPlayerViewOnlyMode] = useState(false);
|
||||||
|
const [isLogsMode, setIsLogsMode] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const queryParams = new URLSearchParams(window.location.search);
|
const queryParams = new URLSearchParams(window.location.search);
|
||||||
if (queryParams.get('playerView') === 'true' || window.location.pathname === '/display') {
|
if (queryParams.get('playerView') === 'true' || window.location.pathname === '/display') {
|
||||||
setIsPlayerViewOnlyMode(true);
|
setIsPlayerViewOnlyMode(true);
|
||||||
}
|
}
|
||||||
|
if (window.location.pathname === '/logs') {
|
||||||
|
setIsLogsMode(true);
|
||||||
|
}
|
||||||
|
|
||||||
if (!auth) {
|
if (!auth) {
|
||||||
setError("Firebase Auth not initialized. Check your Firebase configuration.");
|
setError("Firebase Auth not initialized. Check your Firebase configuration.");
|
||||||
@@ -2584,11 +2703,24 @@ function App() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isLogsMode) {
|
||||||
|
return isAuthReady ? <LogsView /> : <LoadingSpinner message="Authenticating..." />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-stone-950 text-stone-100 font-garamond">
|
<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">
|
<header className="bg-stone-950 p-4 shadow-lg border-b border-amber-900">
|
||||||
<div className="container mx-auto flex justify-between items-center">
|
<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>
|
<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
|
<button
|
||||||
onClick={openPlayerWindow}
|
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"
|
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
|
<ExternalLink size={16} className="mr-2" /> Open Player Window
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="container mx-auto p-4 md:p-8">
|
<main className="container mx-auto p-4 md:p-8">
|
||||||
|
|||||||
Reference in New Issue
Block a user