more slight changes.

This commit is contained in:
Robert Johnson 2025-05-25 23:28:36 -04:00
parent bfb0f20a25
commit 962c0bd911

View File

@ -1,8 +1,8 @@
import React, { useState, useEffect, useRef } from 'react'; // Added useRef
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'; // Removed where as it's not used
import { ArrowLeft, PlusCircle, Users, Swords, Shield, Trash2, Eye, Edit3, Save, XCircle, ChevronsUpDown, ChevronDown, ChevronUp, UserCheck, UserX, HeartCrack, HeartPulse, Zap, Share2, Copy as CopyIcon } from 'lucide-react';
import { getFirestore, doc, setDoc, addDoc, getDoc, getDocs, collection, onSnapshot, updateDoc, deleteDoc, query, writeBatch } from 'firebase/firestore';
import { ArrowLeft, PlusCircle, Users, Swords, Shield, Trash2, Eye, Edit3, Save, XCircle, ChevronsUpDown, ChevronDown, ChevronUp, UserCheck, UserX, HeartCrack, HeartPulse, Zap, Share2, Copy as CopyIcon, Image as ImageIcon, EyeOff } from 'lucide-react';
// --- Firebase Configuration ---
const firebaseConfig = {
@ -24,7 +24,6 @@ const missingKeys = requiredFirebaseConfigKeys.filter(key => !firebaseConfig[key
if (missingKeys.length > 0) {
console.error(`CRITICAL: Missing Firebase config values from environment variables: ${missingKeys.join(', ')}`);
console.error("Firebase cannot be initialized. Please ensure all REACT_APP_FIREBASE_... variables are set in your .env.local file and accessible during the build.");
// Fallback: Render a message or allow app to break if Firebase is critical
} else {
try {
app = initializeApp(firebaseConfig);
@ -32,11 +31,9 @@ if (missingKeys.length > 0) {
auth = getAuth(app);
} catch (error) {
console.error("Error initializing Firebase:", error);
// Handle initialization error, perhaps by setting db and auth to null or showing an error UI
}
}
// --- Firestore Paths ---
const APP_ID = process.env.REACT_APP_TRACKER_APP_ID || 'ttrpg-initiative-tracker-default';
const PUBLIC_DATA_PATH = `artifacts/${APP_ID}/public/data`;
@ -60,10 +57,10 @@ function App() {
const [directDisplayParams, setDirectDisplayParams] = useState(null);
useEffect(() => {
if (!auth) { // Check if Firebase auth was initialized
if (!auth) {
setError("Firebase Auth not initialized. Check your Firebase configuration.");
setIsLoading(false);
setIsAuthReady(false); // Explicitly set auth not ready
setIsAuthReady(false);
return;
}
const handleHashChange = () => {
@ -109,7 +106,7 @@ function App() {
};
}, []);
if (!db || !auth) { // If Firebase failed to init
if (!db || !auth) {
return (
<div className="min-h-screen bg-red-900 text-white flex flex-col items-center justify-center p-4">
<h1 className="text-3xl font-bold">Configuration Error</h1>
@ -132,48 +129,37 @@ function App() {
);
}
const goToAdminView = () => {
setViewMode('admin');
setDirectDisplayParams(null);
window.location.hash = '';
};
return (
<div className="min-h-screen bg-slate-800 text-slate-100 font-sans">
{!directDisplayParams && (
<header className="bg-slate-900 p-4 shadow-lg">
<div className="container mx-auto flex justify-between items-center">
<h1 className="text-3xl font-bold text-sky-400">TTRPG Initiative Tracker</h1>
<h1
className="text-3xl font-bold text-sky-400 cursor-pointer hover:text-sky-300 transition-colors"
onClick={goToAdminView}
title="Go to Admin View"
>
TTRPG Initiative Tracker
</h1>
<div className="flex items-center space-x-4">
{userId && <span className="text-xs text-slate-400">UID: {userId}</span>}
{/* Show Admin View button only if not in display mode */}
{viewMode !== 'display' && (
<button
onClick={() => { setViewMode('admin'); setDirectDisplayParams(null); window.location.hash = '';}}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${viewMode === 'admin' ? 'bg-sky-500 text-white' : 'bg-slate-700 hover:bg-slate-600'}`}
>
Admin View
</button>
)}
{/* Show Player Display button only if not in admin mode (or always if header is visible and it's the only option left) */}
{viewMode !== 'admin' && (
<button
onClick={() => { setViewMode('display'); setDirectDisplayParams(null); window.location.hash = '';}}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${viewMode === 'display' ? 'bg-teal-500 text-white' : 'bg-slate-700 hover:bg-slate-600'}`}
>
Player Display
</button>
)}
{/* Simpler toggle: always show both if header is visible and style the active one */}
{/* The above logic is slightly off for two buttons always present. Let's fix: */}
{/* Corrected Header Buttons for Toggling */}
<button
onClick={() => { setViewMode('admin'); setDirectDisplayParams(null); window.location.hash = '';}}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${viewMode === 'admin' ? 'bg-sky-500 text-white' : 'bg-slate-700 hover:bg-slate-600'}`}
// Hide if current view is display, but allow access from admin
style={viewMode === 'display' ? { display: 'none' } : {}}
>
Admin View
</button>
<button
onClick={() => { setViewMode('display'); setDirectDisplayParams(null); window.location.hash = '';}}
onClick={() => {
if (viewMode === 'display') {
goToAdminView();
} else {
setViewMode('display');
}
}}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${viewMode === 'display' ? 'bg-teal-500 text-white' : 'bg-slate-700 hover:bg-slate-600'}`}
>
Player Display
{viewMode === 'display' ? 'Admin Controls' : 'Player Display'}
</button>
</div>
</div>
@ -186,11 +172,11 @@ function App() {
)}
{!directDisplayParams && viewMode === 'admin' && isAuthReady && userId && <AdminView userId={userId} />}
{!directDisplayParams && viewMode === 'display' && isAuthReady && <DisplayView />}
{!isAuthReady && !error && <p>Authenticating...</p>} {/* Show auth message only if no other error */}
{!isAuthReady && !error && <p>Authenticating...</p>}
</main>
{!directDisplayParams && (
<footer className="bg-slate-900 p-4 text-center text-sm text-slate-400 mt-8">
TTRPG Initiative Tracker v0.1.13
TTRPG Initiative Tracker v0.1.15
</footer>
)}
</div>
@ -205,7 +191,7 @@ function AdminView({ userId }) {
const [initialActiveInfo, setInitialActiveInfo] = useState(null);
useEffect(() => {
if (!db) return; // Guard against Firebase not initialized
if (!db) return;
const campaignsCollectionRef = collection(db, CAMPAIGNS_COLLECTION);
const q = query(campaignsCollectionRef);
const unsubscribeCampaigns = onSnapshot(q, (snapshot) => {
@ -239,12 +225,16 @@ function AdminView({ userId }) {
}, [initialActiveInfo, campaigns, selectedCampaignId]);
const handleCreateCampaign = async (name) => {
const handleCreateCampaign = async (name, backgroundUrl) => {
if (!db || !name.trim()) return;
const newCampaignId = generateId();
try {
await setDoc(doc(db, CAMPAIGNS_COLLECTION, newCampaignId), {
name: name.trim(), ownerId: userId, createdAt: new Date().toISOString(), players: [],
name: name.trim(),
playerDisplayBackgroundUrl: backgroundUrl.trim() || '',
ownerId: userId,
createdAt: new Date().toISOString(),
players: [],
});
setShowCreateCampaignModal(false);
setSelectedCampaignId(newCampaignId);
@ -292,6 +282,7 @@ function AdminView({ userId }) {
>
<h3 className="text-xl font-semibold text-white">{campaign.name}</h3>
<p className="text-xs text-slate-400">ID: {campaign.id}</p>
{campaign.playerDisplayBackgroundUrl && <ImageIcon size={14} className="inline-block mr-1 text-slate-400" title="Has custom background"/>}
<button
onClick={(e) => {
e.stopPropagation();
@ -324,12 +315,23 @@ function AdminView({ userId }) {
function CreateCampaignForm({ onCreate, onCancel }) {
const [name, setName] = useState('');
const [backgroundUrl, setBackgroundUrl] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
onCreate(name, backgroundUrl);
};
return (
<form onSubmit={(e) => { e.preventDefault(); onCreate(name); }} className="space-y-4">
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="campaignName" className="block text-sm font-medium text-slate-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-slate-700 border border-slate-600 rounded-md shadow-sm focus:outline-none focus:ring-sky-500 focus:border-sky-500 sm:text-sm text-white" required />
</div>
<div>
<label htmlFor="backgroundUrl" className="block text-sm font-medium text-slate-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-slate-700 border border-slate-600 rounded-md shadow-sm focus:outline-none focus:ring-sky-500 focus:border-sky-500 sm:text-sm text-white" />
</div>
<div className="flex justify-end space-x-3">
<button type="button" onClick={onCancel} className="px-4 py-2 text-sm font-medium text-slate-300 bg-slate-600 hover:bg-slate-500 rounded-md transition-colors">Cancel</button>
<button type="submit" className="px-4 py-2 text-sm font-medium text-white bg-green-500 hover:bg-green-600 rounded-md transition-colors">Create</button>
@ -470,9 +472,27 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
const handleSetEncounterAsActiveDisplay = async (encounterId) => {
if (!db) return;
try {
await setDoc(doc(db, ACTIVE_DISPLAY_DOC), { activeCampaignId: campaignId, activeEncounterId: encounterId }, { merge: true });
console.log("Encounter set as active for DM's main display!");
} catch (err) { console.error("Error setting active display:", err); }
const currentActiveCampaign = activeDisplayInfo?.activeCampaignId;
const currentActiveEncounter = activeDisplayInfo?.activeEncounterId;
if (currentActiveCampaign === campaignId && currentActiveEncounter === encounterId) {
// Currently active, so toggle off
await updateDoc(doc(db, ACTIVE_DISPLAY_DOC), {
activeCampaignId: null,
activeEncounterId: null,
});
console.log("Encounter display toggled OFF for DM.");
} else {
// Not active or different encounter, so set this one as active
await setDoc(doc(db, ACTIVE_DISPLAY_DOC), {
activeCampaignId: campaignId,
activeEncounterId: encounterId,
}, { merge: true });
console.log("Encounter set as active for DM's main display!");
}
} catch (err) {
console.error("Error toggling active display:", err);
}
};
const handleCopyToClipboard = (encounterId) => {
@ -504,7 +524,13 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
{isDmActiveDisplay && <span className="text-xs text-green-400 font-semibold block mt-1">LIVE ON DM DISPLAY</span>}
</div>
<div className="flex items-center space-x-2">
<button onClick={() => handleSetEncounterAsActiveDisplay(encounter.id)} className={`p-1 rounded transition-colors ${isDmActiveDisplay ? 'bg-green-500 hover:bg-green-600 text-white' : 'text-teal-400 hover:text-teal-300 bg-slate-600 hover:bg-slate-500'}`} title="Set as DM's Active Display"><Eye size={18} /></button>
<button
onClick={() => handleSetEncounterAsActiveDisplay(encounter.id)}
className={`p-1 rounded transition-colors ${isDmActiveDisplay ? 'bg-red-500 hover:bg-red-600 text-white' : 'text-teal-400 hover:text-teal-300 bg-slate-600 hover:bg-slate-500'}`}
title={isDmActiveDisplay ? "Deactivate DM Display" : "Set as DM's Active Display"}
>
{isDmActiveDisplay ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
<button onClick={() => handleCopyToClipboard(encounter.id)} className="p-1 rounded transition-colors text-sky-400 hover:text-sky-300 bg-slate-600 hover:bg-slate-500" title="Copy Share Link for Players"><Share2 size={18} /></button>
{copiedLinkEncounterId === encounter.id && <span className="text-xs text-green-400">Copied!</span>}
<button onClick={(e) => { e.stopPropagation(); handleDeleteEncounter(encounter.id); }} className="p-1 rounded transition-colors text-red-400 hover:text-red-300 bg-slate-600 hover:bg-slate-500" title="Delete Encounter"><Trash2 size={18} /></button>
@ -727,7 +753,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
<ul className="space-y-2">
{sortedAdminParticipants.map((p, index) => {
const isCurrentTurn = encounter.isStarted && p.id === encounter.currentTurnParticipantId;
const isDraggable = !encounter.isStarted && tiedInitiatives.includes(Number(p.initiative)); // Ensure p.initiative is number for comparison
const isDraggable = !encounter.isStarted && tiedInitiatives.includes(Number(p.initiative));
return (
<li
key={p.id}
@ -872,15 +898,30 @@ function DisplayView({ campaignIdFromUrl, encounterIdFromUrl }) {
const [activeEncounterData, setActiveEncounterData] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const [campaignBackgroundUrl, setCampaignBackgroundUrl] = useState('');
useEffect(() => {
if (!db) {
setError("Firestore not available."); setIsLoading(false); return;
}
setIsLoading(true); setError(null); setActiveEncounterData(null);
setIsLoading(true); setError(null); setActiveEncounterData(null); setCampaignBackgroundUrl('');
let unsubscribeEncounter;
let unsubscribeCampaign;
const fetchCampaignBackground = async (cId) => {
if (!cId) return;
const campaignDocRef = doc(db, CAMPAIGNS_COLLECTION, cId);
unsubscribeCampaign = onSnapshot(campaignDocRef, (docSnap) => {
if (docSnap.exists()) {
setCampaignBackgroundUrl(docSnap.data().playerDisplayBackgroundUrl || '');
}
}, (err) => {
console.error("Error fetching campaign background:", err);
});
};
if (campaignIdFromUrl && encounterIdFromUrl) {
fetchCampaignBackground(campaignIdFromUrl);
const encounterPath = `${CAMPAIGNS_COLLECTION}/${campaignIdFromUrl}/encounters/${encounterIdFromUrl}`;
unsubscribeEncounter = onSnapshot(doc(db, encounterPath), (encDocSnap) => {
if (encDocSnap.exists()) setActiveEncounterData({ id: encDocSnap.id, ...encDocSnap.data() });
@ -893,6 +934,7 @@ function DisplayView({ campaignIdFromUrl, encounterIdFromUrl }) {
if (docSnap.exists()) {
const { activeCampaignId, activeEncounterId } = docSnap.data();
if (activeCampaignId && activeEncounterId) {
fetchCampaignBackground(activeCampaignId);
const encounterPath = `${CAMPAIGNS_COLLECTION}/${activeCampaignId}/encounters/${activeEncounterId}`;
if(unsubscribeEncounter) unsubscribeEncounter();
unsubscribeEncounter = onSnapshot(doc(db, encounterPath), (encDocSnap) => {
@ -903,9 +945,16 @@ function DisplayView({ campaignIdFromUrl, encounterIdFromUrl }) {
} else { setActiveEncounterData(null); setIsLoading(false); }
} else { setActiveEncounterData(null); setIsLoading(false); }
}, (err) => { console.error("Error fetching active display config:", err); setError("Could not load display configuration."); setIsLoading(false); });
return () => { unsubDisplayConfig(); if (unsubscribeEncounter) unsubscribeEncounter(); };
return () => {
unsubDisplayConfig();
if (unsubscribeEncounter) unsubscribeEncounter();
if (unsubscribeCampaign) unsubscribeCampaign();
};
}
return () => { if (unsubscribeEncounter) unsubscribeEncounter(); };
return () => {
if (unsubscribeEncounter) unsubscribeEncounter();
if (unsubscribeCampaign) unsubscribeCampaign();
};
}, [campaignIdFromUrl, encounterIdFromUrl]);
if (isLoading) return <div className="text-center py-10 text-2xl text-slate-300">Loading Player Display...</div>;
@ -915,7 +964,7 @@ function DisplayView({ campaignIdFromUrl, encounterIdFromUrl }) {
const { name, participants, round, currentTurnParticipantId, isStarted } = activeEncounterData;
let displayParticipants = [];
if (participants) { // Ensure participants array exists before trying to sort/filter
if (participants) {
if (isStarted && activeEncounterData.turnOrderIds?.length > 0 ) {
displayParticipants = activeEncounterData.turnOrderIds
.map(id => participants.find(p => p.id === id)).filter(p => p && p.isActive);
@ -932,35 +981,48 @@ function DisplayView({ campaignIdFromUrl, encounterIdFromUrl }) {
}
}
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 bg-slate-900 rounded-xl shadow-2xl">
<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 && 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' : ''}`}>
<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>
</div>
<div className="flex justify-between items-center">
<div className="w-full bg-slate-600 rounded-full h-6 md:h-8 relative overflow-hidden border-2 border-slate-500">
<div className={`h-full rounded-full transition-all ${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 mix-blend-difference px-2">
HP: {p.currentHp} / {p.maxHp}
</span>
)}
<div
className={`p-4 md:p-8 rounded-xl shadow-2xl ${!campaignBackgroundUrl ? 'bg-slate-900' : ''}`}
style={displayStyles}
>
<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 && 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' : ''}`}>
<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>
</div>
<div className="flex justify-between items-center">
<div className="w-full bg-slate-600 rounded-full h-6 md:h-8 relative overflow-hidden border-2 border-slate-500">
<div className={`h-full rounded-full transition-all ${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 mix-blend-difference px-2">
HP: {p.currentHp} / {p.maxHp}
</span>
)}
</div>
</div>
{p.conditions?.length > 0 && <p className="text-sm text-yellow-300 mt-2">Conditions: {p.conditions.join(', ')}</p>}
{!p.isActive && <p className="text-center text-lg font-semibold text-slate-300 mt-2">(Inactive)</p>}
</div>
{p.conditions?.length > 0 && <p className="text-sm text-yellow-300 mt-2">Conditions: {p.conditions.join(', ')}</p>}
{!p.isActive && <p className="text-center text-lg font-semibold text-slate-300 mt-2">(Inactive)</p>}
</div>
))}
))}
</div>
</div>
</div>
);
@ -989,4 +1051,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;