Changed view for more than 7 combatants
This commit is contained in:
parent
d5b93ac66a
commit
f530d4303d
114
src/App.js
114
src/App.js
@ -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;
|
||||
|
Loading…
x
Reference in New Issue
Block a user