Changed view for more than 7 combatants

This commit is contained in:
Robert Johnson 2025-05-26 08:53:26 -04:00
parent d5b93ac66a
commit f530d4303d

View File

@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef } from 'react';
import { initializeApp } from 'firebase/app';
import { getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken } from 'firebase/auth';
import { getFirestore, doc, setDoc, addDoc, getDoc, getDocs, collection, onSnapshot, updateDoc, deleteDoc, query, writeBatch } from 'firebase/firestore';
import { PlusCircle, Users, Swords, Shield, Trash2, Eye, Edit3, Save, XCircle, ChevronsUpDown, UserCheck, UserX, HeartCrack, HeartPulse, Zap, Image as ImageIcon, EyeOff, ExternalLink } from 'lucide-react'; // Removed unused icons
import { PlusCircle, Users, Swords, Shield, Trash2, Eye, Edit3, Save, XCircle, ChevronsUpDown, UserCheck, UserX, HeartCrack, HeartPulse, Zap, Image as ImageIcon, EyeOff, ExternalLink } from 'lucide-react';
// --- Firebase Configuration ---
const firebaseConfig = {
@ -103,7 +103,6 @@ function useFirestoreCollection(collectionPath, queryConstraints = []) {
setIsLoading(true);
setError(null);
// Ensure queryConstraints is an array before spreading
const constraints = Array.isArray(queryConstraints) ? queryConstraints : [];
const q = query(collection(db, collectionPath), ...constraints);
@ -119,8 +118,6 @@ function useFirestoreCollection(collectionPath, queryConstraints = []) {
});
return () => unsubscribe();
// Using JSON.stringify for queryConstraints is a common way to handle array/object dependencies.
// For simple cases, it's fine. For complex queries, a more robust memoization or comparison might be needed.
}, [collectionPath, JSON.stringify(queryConstraints)]);
return { data, isLoading, error };
@ -237,7 +234,7 @@ function App() {
{!isAuthReady && !error && <p>Authenticating...</p>}
</main>
<footer className="bg-slate-900 p-4 text-center text-sm text-slate-400 mt-8">
TTRPG Initiative Tracker v0.1.19
TTRPG Initiative Tracker v0.1.20
</footer>
</div>
);
@ -459,7 +456,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
}, [selectedEncounterId]);
useEffect(() => {
if (!campaignId) { // If no campaign is selected, clear selection
if (!campaignId) {
setSelectedEncounterId(null);
return;
}
@ -473,7 +470,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
setSelectedEncounterId(activeDisplayInfo.activeEncounterId);
}
}
} else if (encounters && encounters.length === 0) { // No encounters in this campaign
} else if (encounters && encounters.length === 0) {
setSelectedEncounterId(null);
}
}, [campaignId, initialActiveEncounterId, activeDisplayInfo, encounters]);
@ -682,27 +679,25 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
// --- Drag and Drop Handlers ---
const handleDragStart = (e, id) => {
setDraggedItemId(id);
e.dataTransfer.effectAllowed = 'move'; // Indicates that the element can be moved
// e.dataTransfer.setData('text/plain', id); // Optional: useful for some browsers or inter-app drag
e.dataTransfer.effectAllowed = 'move';
};
const handleDragOver = (e) => {
e.preventDefault(); // This is necessary to allow a drop
e.dataTransfer.dropEffect = 'move'; // Visual feedback to the user
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
};
const handleDrop = async (e, targetId) => {
e.preventDefault(); // Prevent default browser behavior
e.preventDefault();
if (!db || draggedItemId === null || draggedItemId === targetId) {
setDraggedItemId(null); // Reset if no valid drag or dropping on itself
setDraggedItemId(null);
return;
}
const currentParticipants = [...participants]; // Create a mutable copy
const currentParticipants = [...participants];
const draggedItemIndex = currentParticipants.findIndex(p => p.id === draggedItemId);
const targetItemIndex = currentParticipants.findIndex(p => p.id === targetId);
// Ensure both items are found
if (draggedItemIndex === -1 || targetItemIndex === -1) {
console.error("Dragged or target item not found in participants list.");
setDraggedItemId(null);
@ -712,50 +707,37 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
const draggedItem = currentParticipants[draggedItemIndex];
const targetItem = currentParticipants[targetItemIndex];
// Crucial: Only allow reordering within the same initiative score for tie-breaking
if (draggedItem.initiative !== targetItem.initiative) {
console.log("Drag-and-drop for tie-breaking only allowed between participants with the same initiative score.");
setDraggedItemId(null);
return;
}
// Perform the reorder
const [removedItem] = currentParticipants.splice(draggedItemIndex, 1); // Remove dragged item
currentParticipants.splice(targetItemIndex, 0, removedItem); // Insert it at the target's position
const [removedItem] = currentParticipants.splice(draggedItemIndex, 1);
currentParticipants.splice(targetItemIndex, 0, removedItem);
try {
// Update Firestore with the new participants order
await updateDoc(doc(db, encounterPath), { participants: currentParticipants });
console.log("Participants reordered in Firestore for tie-breaking.");
} catch (err) {
console.error("Error updating participants after drag-drop:", err);
// Optionally, you might want to revert the local state if Firestore update fails,
// or display an error to the user. For now, we log the error.
}
setDraggedItemId(null); // Clear the dragged item ID
setDraggedItemId(null);
};
const handleDragEnd = () => {
// This event fires after a drag operation, regardless of whether it was successful or not.
setDraggedItemId(null); // Always clear the dragged item ID
setDraggedItemId(null);
};
// Sort participants for display. Primary sort by initiative (desc), secondary by existing order in array (for stable tie-breaking after D&D)
const sortedAdminParticipants = [...participants].sort((a, b) => {
if (a.initiative === b.initiative) {
// If initiatives are tied, maintain their current relative order from the `participants` array.
// This relies on `Array.prototype.sort` being stable, which it is in modern JS engines.
// To be absolutely sure or for older engines, one might compare original indices if stored.
// However, since drag-and-drop directly modifies the `participants` array order in Firestore,
// this simple stable sort approach should preserve the manually set tie-breaker order.
const indexA = participants.findIndex(p => p.id === a.id);
const indexB = participants.findIndex(p => p.id === b.id);
return indexA - indexB;
}
return b.initiative - a.initiative; // Higher initiative first
return b.initiative - a.initiative;
});
// Identify which initiative scores have ties to enable dragging only for them
const initiativeGroups = participants.reduce((acc, p) => {
acc[p.initiative] = (acc[p.initiative] || 0) + 1;
return acc;
@ -796,7 +778,6 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
<ul className="space-y-2">
{sortedAdminParticipants.map((p, index) => {
const isCurrentTurn = encounter.isStarted && p.id === encounter.currentTurnParticipantId;
// A participant is draggable if the encounter hasn't started AND their initiative score is part of a tie.
const isDraggable = !encounter.isStarted && tiedInitiatives.includes(Number(p.initiative));
return (
<li
@ -947,10 +928,11 @@ function DisplayView() {
const { data: activeDisplayData, isLoading: isLoadingActiveDisplay, error: activeDisplayError } = useFirestoreDocument(getActiveDisplayDocPath());
const [activeEncounterData, setActiveEncounterData] = useState(null);
const [isLoadingEncounter, setIsLoadingEncounter] = useState(true); // Separate loading for encounter
const [isLoadingEncounter, setIsLoadingEncounter] = useState(true);
const [encounterError, setEncounterError] = useState(null);
const [campaignBackgroundUrl, setCampaignBackgroundUrl] = useState('');
const [isPlayerDisplayActive, setIsPlayerDisplayActive] = useState(false);
const currentTurnRef = useRef(null); // Ref for the current turn participant's element
useEffect(() => {
if (!db) {
@ -999,7 +981,7 @@ function DisplayView() {
setIsPlayerDisplayActive(false);
setIsLoadingEncounter(false);
}
} else if (!isLoadingActiveDisplay) { // activeDisplayData is null and not loading
} else if (!isLoadingActiveDisplay) {
setActiveEncounterData(null);
setCampaignBackgroundUrl('');
setIsPlayerDisplayActive(false);
@ -1012,6 +994,16 @@ function DisplayView() {
};
}, [activeDisplayData, isLoadingActiveDisplay]);
// Scroll to current turn participant
useEffect(() => {
if (currentTurnRef.current) {
currentTurnRef.current.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
}
}, [activeEncounterData?.currentTurnParticipantId]); // Rerun when current turn changes
if (isLoadingActiveDisplay || (isPlayerDisplayActive && isLoadingEncounter)) {
return <div className="text-center py-10 text-2xl text-slate-300">Loading Player Display...</div>;
}
@ -1032,13 +1024,13 @@ function DisplayView() {
const { name, participants, round, currentTurnParticipantId, isStarted } = activeEncounterData;
let displayParticipants = [];
if (participants) {
let allOrderedActiveParticipants = [];
if (participants) {
if (isStarted && activeEncounterData.turnOrderIds?.length > 0 ) {
displayParticipants = activeEncounterData.turnOrderIds
allOrderedActiveParticipants = activeEncounterData.turnOrderIds
.map(id => participants.find(p => p.id === id)).filter(p => p && p.isActive);
} else {
displayParticipants = [...participants].filter(p => p.isActive)
allOrderedActiveParticipants = [...participants].filter(p => p.isActive)
.sort((a, b) => {
if (a.initiative === b.initiative) {
const indexA = participants.findIndex(p => p.id === a.id);
@ -1050,6 +1042,29 @@ function DisplayView() {
}
}
let participantsToRender = allOrderedActiveParticipants;
const FOCUSED_VIEW_THRESHOLD = 7;
const ITEMS_AROUND_CURRENT = 2; // Show 2 before and 2 after current = 5 total in focused view
let inFocusedView = false;
if (isStarted && allOrderedActiveParticipants.length >= FOCUSED_VIEW_THRESHOLD) {
inFocusedView = true;
const currentIndex = allOrderedActiveParticipants.findIndex(p => p.id === currentTurnParticipantId);
if (currentIndex !== -1) {
const focusedList = [];
for (let i = -ITEMS_AROUND_CURRENT; i <= ITEMS_AROUND_CURRENT; i++) {
const listLength = allOrderedActiveParticipants.length;
const actualIndex = (currentIndex + i + listLength) % listLength;
focusedList.push(allOrderedActiveParticipants[actualIndex]);
}
participantsToRender = focusedList;
} else {
// Fallback if current turn ID not found, show first few (should not happen ideally)
participantsToRender = allOrderedActiveParticipants.slice(0, ITEMS_AROUND_CURRENT * 2 + 1);
}
}
const displayStyles = campaignBackgroundUrl ? {
backgroundImage: `url(${campaignBackgroundUrl})`,
backgroundSize: 'cover',
@ -1066,13 +1081,20 @@ function DisplayView() {
>
<div className={campaignBackgroundUrl ? 'bg-slate-900 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">{name}</h2>
{isStarted && <p className="text-2xl text-center text-sky-300 mb-6">Round: {round}</p>}
{isStarted && <p className="text-2xl text-center text-sky-300 mb-2">Round: {round}</p>}
{inFocusedView && <p className="text-sm text-center text-slate-400 mb-4">(Focused View: {participantsToRender.length} of {allOrderedActiveParticipants.length} active)</p>}
{!isStarted && participants?.length > 0 && <p className="text-2xl text-center text-slate-400 mb-6">Awaiting Start</p>}
{!isStarted && (!participants || participants.length === 0) && <p className="text-2xl text-slate-500 mb-6">No participants.</p>}
{displayParticipants.length === 0 && isStarted && <p className="text-xl text-slate-400">No active participants.</p>}
<div className="space-y-4 max-w-3xl mx-auto">
{displayParticipants.map(p => (
<div key={p.id} className={`p-4 md:p-6 rounded-lg shadow-lg transition-all ${p.id === currentTurnParticipantId && isStarted ? 'bg-green-700 ring-4 ring-green-400 scale-105' : (p.type === 'character' ? 'bg-sky-700' : 'bg-red-700')} ${!p.isActive ? 'opacity-40 grayscale' : ''}`}>
{participantsToRender.length === 0 && isStarted && <p className="text-xl text-slate-400">No active participants.</p>}
{/* Container for participant list - make it scrollable if focused view is not enough */}
<div className="space-y-4 max-w-3xl mx-auto overflow-y-auto" style={{maxHeight: 'calc(100vh - 200px)' /* Adjust as needed */}}>
{participantsToRender.map(p => (
<div
key={p.id}
ref={p.id === currentTurnParticipantId ? currentTurnRef : null} // Add ref to current turn item
className={`p-4 md:p-6 rounded-lg shadow-lg transition-all ${p.id === currentTurnParticipantId && isStarted ? 'bg-green-700 ring-4 ring-green-400 scale-105' : (p.type === 'character' ? 'bg-sky-700' : 'bg-red-700')} ${!p.isActive ? 'opacity-40 grayscale' : ''}`}
>
<div className="flex justify-between items-center mb-2">
<h3 className={`text-2xl md:text-3xl font-bold ${p.id === currentTurnParticipantId && isStarted ? 'text-white' : (p.type === 'character' ? 'text-sky-100' : 'text-red-100')}`}>{p.name}{p.id === currentTurnParticipantId && isStarted && <span className="text-yellow-300 animate-pulse ml-2">(Current)</span>}</h3>
<span className={`text-xl md:text-2xl font-semibold ${p.id === currentTurnParticipantId && isStarted ? 'text-green-200' : 'text-slate-200'}`}>Init: {p.initiative}</span>
@ -1120,4 +1142,4 @@ const PlayIcon = ({ size = 24, className = '' }) => <svg xmlns="http://www.w3.or
const SkipForwardIcon = ({ size = 24, className = '' }) => <svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><polygon points="5 4 15 12 5 20 5 4"></polygon><line x1="19" y1="5" x2="19" y2="19"></line></svg>;
const StopCircleIcon = ({size=24, className=''}) => <svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><circle cx="12" cy="12" r="10"></circle><rect x="9" y="9" width="6" height="6"></rect></svg>;
export default App;
export default App;