cleaned up dm eyeball toggle

This commit is contained in:
Robert Johnson 2025-05-26 07:50:24 -04:00
parent c7215bb503
commit eb114910f8

View File

@ -38,14 +38,15 @@ if (missingKeys.length > 0) {
const APP_ID = process.env.REACT_APP_TRACKER_APP_ID || 'ttrpg-initiative-tracker-default';
const PUBLIC_DATA_PATH = `artifacts/${APP_ID}/public/data`;
const CAMPAIGNS_COLLECTION = `${PUBLIC_DATA_PATH}/campaigns`;
const ACTIVE_DISPLAY_DOC = `${PUBLIC_DATA_PATH}/activeDisplay/status`;
const ACTIVE_DISPLAY_DOC = `${PUBLIC_DATA_PATH}/activeDisplay/status`; // Single source for what player display shows
// --- Helper Functions ---
const generateId = () => crypto.randomUUID();
function getShareableLinkBase() {
return window.location.origin + window.location.pathname;
}
// getShareableLinkBase is no longer needed for individual encounter links if we have one player display URL
// function getShareableLinkBase() {
// return window.location.origin + window.location.pathname;
// }
// --- Main App Component ---
function App() {
@ -54,7 +55,7 @@ function App() {
const [viewMode, setViewMode] = useState('admin');
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const [directDisplayParams, setDirectDisplayParams] = useState(null);
// directDisplayParams and hash routing removed, Player Display solely relies on ACTIVE_DISPLAY_DOC
useEffect(() => {
if (!auth) {
@ -63,22 +64,6 @@ function App() {
setIsAuthReady(false);
return;
}
const handleHashChange = () => {
const hash = window.location.hash;
if (hash.startsWith('#/display/')) {
const parts = hash.substring('#/display/'.length).split('/');
if (parts.length === 2 && parts[0] && parts[1]) {
setDirectDisplayParams({ campaignId: parts[0], encounterId: parts[1] });
setViewMode('display');
} else {
setDirectDisplayParams(null);
}
} else {
setDirectDisplayParams(null);
}
};
window.addEventListener('hashchange', handleHashChange);
handleHashChange();
const initAuth = async () => {
try {
@ -101,7 +86,6 @@ function App() {
});
initAuth();
return () => {
window.removeEventListener('hashchange', handleHashChange);
unsubscribe();
};
}, []);
@ -131,13 +115,10 @@ 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
@ -164,21 +145,15 @@ function App() {
</div>
</div>
</header>
)}
<main className={`container mx-auto p-4 md:p-8 ${directDisplayParams ? 'pt-8' : ''}`}>
{directDisplayParams && isAuthReady && (
<DisplayView campaignIdFromUrl={directDisplayParams.campaignId} encounterIdFromUrl={directDisplayParams.encounterId} />
)}
{!directDisplayParams && viewMode === 'admin' && isAuthReady && userId && <AdminView userId={userId} />}
{!directDisplayParams && viewMode === 'display' && isAuthReady && <DisplayView />}
<main className={`container mx-auto p-4 md:p-8`}>
{viewMode === 'admin' && isAuthReady && userId && <AdminView userId={userId} />}
{viewMode === 'display' && isAuthReady && <DisplayView />} {/* DisplayView no longer needs URL params */}
{!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.15
</footer>
)}
<footer className="bg-slate-900 p-4 text-center text-sm text-slate-400 mt-8">
TTRPG Initiative Tracker v0.1.16
</footer>
</div>
);
}
@ -402,7 +377,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
const [selectedEncounterId, setSelectedEncounterId] = useState(null);
const [showCreateEncounterModal, setShowCreateEncounterModal] = useState(false);
const [activeDisplayInfo, setActiveDisplayInfo] = useState(null);
const [copiedLinkEncounterId, setCopiedLinkEncounterId] = useState(null);
// copiedLinkEncounterId removed as shareable links per encounter are removed
const encountersPath = `${CAMPAIGNS_COLLECTION}/${campaignId}/encounters`;
const selectedEncounterIdRef = useRef(selectedEncounterId);
@ -464,44 +439,39 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
await deleteDoc(doc(db, encountersPath, encounterId));
if (selectedEncounterId === encounterId) setSelectedEncounterId(null);
if (activeDisplayInfo && activeDisplayInfo.activeEncounterId === encounterId) {
await updateDoc(doc(db, ACTIVE_DISPLAY_DOC), { activeEncounterId: null });
await updateDoc(doc(db, ACTIVE_DISPLAY_DOC), { activeCampaignId: null, activeEncounterId: null });
}
} catch (err) { console.error("Error deleting encounter:", err); }
};
const handleSetEncounterAsActiveDisplay = async (encounterId) => {
const handleTogglePlayerDisplayForEncounter = async (encounterId) => {
if (!db) return;
try {
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), {
// Currently active, so toggle off (clear the active display)
await setDoc(doc(db, ACTIVE_DISPLAY_DOC), {
activeCampaignId: null,
activeEncounterId: null,
});
console.log("Encounter display toggled OFF for DM.");
}, { merge: true }); // Use set with merge to ensure document exists or is overwritten
console.log("Player Display for this encounter turned OFF.");
} else {
// Not active or different encounter, so set this one as active
// Not active or different encounter, so set this one as active for players
await setDoc(doc(db, ACTIVE_DISPLAY_DOC), {
activeCampaignId: campaignId,
activeEncounterId: encounterId,
}, { merge: true });
console.log("Encounter set as active for DM's main display!");
console.log("Encounter set as active for Player Display!");
}
} catch (err) {
console.error("Error toggling active display:", err);
console.error("Error toggling Player Display for encounter:", err);
}
};
const handleCopyToClipboard = (encounterId) => {
const link = `${getShareableLinkBase()}#/display/${campaignId}/${encounterId}`;
navigator.clipboard.writeText(link).then(() => {
setCopiedLinkEncounterId(encounterId);
setTimeout(() => setCopiedLinkEncounterId(null), 2000);
}).catch(err => console.error('Failed to copy link: ', err));
};
// Shareable link per encounter removed
// const handleCopyToClipboard = (encounterId) => { ... };
const selectedEncounter = encounters.find(e => e.id === selectedEncounterId);
@ -514,39 +484,28 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
{encounters.length === 0 && <p className="text-sm text-slate-400">No encounters yet.</p>}
<div className="space-y-3">
{encounters.map(encounter => {
const isDmActiveDisplay = activeDisplayInfo && activeDisplayInfo.activeCampaignId === campaignId && activeDisplayInfo.activeEncounterId === encounter.id;
const isLiveOnPlayerDisplay = activeDisplayInfo && activeDisplayInfo.activeCampaignId === campaignId && activeDisplayInfo.activeEncounterId === encounter.id;
return (
<div key={encounter.id} className={`p-3 rounded-md shadow transition-all ${selectedEncounterId === encounter.id ? 'bg-sky-600 ring-2 ring-sky-400' : 'bg-slate-700 hover:bg-slate-650'} ${isDmActiveDisplay ? 'ring-2 ring-green-500 shadow-md shadow-green-500/30' : ''}`}>
<div key={encounter.id} className={`p-3 rounded-md shadow transition-all ${selectedEncounterId === encounter.id ? 'bg-sky-600 ring-2 ring-sky-400' : 'bg-slate-700 hover:bg-slate-650'} ${isLiveOnPlayerDisplay ? 'ring-2 ring-green-500 shadow-md shadow-green-500/30' : ''}`}>
<div className="flex justify-between items-center">
<div onClick={() => setSelectedEncounterId(encounter.id)} className="cursor-pointer flex-grow">
<h4 className="font-medium text-white">{encounter.name}</h4>
<p className="text-xs text-slate-300">Participants: {encounter.participants?.length || 0}</p>
{isDmActiveDisplay && <span className="text-xs text-green-400 font-semibold block mt-1">LIVE ON DM DISPLAY</span>}
{isLiveOnPlayerDisplay && <span className="text-xs text-green-400 font-semibold block mt-1">LIVE ON PLAYER DISPLAY</span>}
</div>
<div className="flex items-center space-x-2">
<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"}
onClick={() => handleTogglePlayerDisplayForEncounter(encounter.id)}
className={`p-1 rounded transition-colors ${isLiveOnPlayerDisplay ? 'bg-red-500 hover:bg-red-600 text-white' : 'text-teal-400 hover:text-teal-300 bg-slate-600 hover:bg-slate-500'}`}
title={isLiveOnPlayerDisplay ? "Deactivate for Player Display" : "Activate for Player Display"}
>
{isDmActiveDisplay ? <EyeOff size={18} /> : <Eye size={18} />}
{isLiveOnPlayerDisplay ? <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>}
{/* Share button for individual encounter link removed */}
<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>
</div>
</div>
{selectedEncounterId === encounter.id && (
<div className="mt-2 pt-2 border-t border-slate-600">
<p className="text-xs text-slate-400">Shareable Link for Players:</p>
<div className="flex items-center gap-2">
<input type="text" readOnly value={`${getShareableLinkBase()}#/display/${campaignId}/${encounter.id}`} className="text-xs w-full bg-slate-600 px-3 py-2 border border-slate-500 rounded-md shadow-sm focus:outline-none focus:ring-sky-500 focus:border-sky-500 sm:text-sm text-white" />
<button onClick={() => handleCopyToClipboard(encounter.id)} className="px-4 py-2 text-xs font-medium text-slate-300 bg-slate-500 hover:bg-slate-400 rounded-md transition-colors p-1">
{copiedLinkEncounterId === encounter.id ? 'Copied!' : <CopyIcon size={14}/>}
</button>
</div>
</div>
)}
{/* Shareable link display removed */}
</div>
);
})}
@ -872,6 +831,8 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
console.warn("Attempting to end encounter without confirmation");
try {
await updateDoc(doc(db, encounterPath), { isStarted: false, currentTurnParticipantId: null, round: 0, turnOrderIds: [] });
// Optionally, also clear the active display when an encounter ends
// await updateDoc(doc(db, ACTIVE_DISPLAY_DOC), { activeCampaignId: null, activeEncounterId: null });
} catch (err) { console.error("Error ending encounter:", err); }
};
@ -894,72 +855,97 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
);
}
function DisplayView({ campaignIdFromUrl, encounterIdFromUrl }) {
function DisplayView() { // Removed campaignIdFromUrl, encounterIdFromUrl props
const [activeEncounterData, setActiveEncounterData] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const [campaignBackgroundUrl, setCampaignBackgroundUrl] = useState('');
const [isPlayerDisplayActive, setIsPlayerDisplayActive] = useState(false); // New state
useEffect(() => {
if (!db) {
setError("Firestore not available."); setIsLoading(false); return;
}
setIsLoading(true); setError(null); setActiveEncounterData(null); setCampaignBackgroundUrl('');
setIsLoading(true); setError(null); setActiveEncounterData(null); setCampaignBackgroundUrl(''); setIsPlayerDisplayActive(false);
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);
});
};
const activeDisplayRef = doc(db, ACTIVE_DISPLAY_DOC);
const unsubDisplayConfig = onSnapshot(activeDisplayRef, async (docSnap) => {
if (docSnap.exists()) {
const { activeCampaignId, activeEncounterId } = docSnap.data();
if (activeCampaignId && activeEncounterId) {
setIsPlayerDisplayActive(true); // An encounter is active for display
// Fetch campaign background
const campaignDocRef = doc(db, CAMPAIGNS_COLLECTION, activeCampaignId);
if (unsubscribeCampaign) unsubscribeCampaign(); // Clean up previous listener
unsubscribeCampaign = onSnapshot(campaignDocRef, (campSnap) => {
if (campSnap.exists()) {
setCampaignBackgroundUrl(campSnap.data().playerDisplayBackgroundUrl || '');
} else {
setCampaignBackgroundUrl(''); // Campaign not found
}
}, (err) => console.error("Error fetching campaign background for display:", err));
// Fetch encounter data
const encounterPath = `${CAMPAIGNS_COLLECTION}/${activeCampaignId}/encounters/${activeEncounterId}`;
if (unsubscribeEncounter) unsubscribeEncounter(); // Clean up previous listener
unsubscribeEncounter = onSnapshot(doc(db, encounterPath), (encDocSnap) => {
if (encDocSnap.exists()) {
setActiveEncounterData({ id: encDocSnap.id, ...encDocSnap.data() });
setError(null);
} else {
setActiveEncounterData(null);
setError("Active encounter data not found. The DM might have deleted it or it's misconfigured.");
}
setIsLoading(false);
}, (err) => {
console.error("Error fetching active encounter details for display:", err);
setError("Error loading active encounter data.");
setIsLoading(false);
});
} else {
// No active encounter set by DM
setActiveEncounterData(null);
setCampaignBackgroundUrl('');
setIsPlayerDisplayActive(false); // No encounter is active for display
setIsLoading(false);
}
} else {
// ACTIVE_DISPLAY_DOC doesn't exist
setActiveEncounterData(null);
setCampaignBackgroundUrl('');
setIsPlayerDisplayActive(false);
setIsLoading(false);
}
}, (err) => {
console.error("Error fetching active display config:", err);
setError("Could not load display configuration.");
setIsLoading(false);
});
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() });
else setError("The requested encounter was not found or is not accessible.");
setIsLoading(false);
}, (err) => { console.error("Error fetching specific encounter for display:", err); setError("Error loading encounter data from link."); setIsLoading(false); });
} else {
const activeDisplayRef = doc(db, ACTIVE_DISPLAY_DOC);
const unsubDisplayConfig = onSnapshot(activeDisplayRef, async (docSnap) => {
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) => {
if (encDocSnap.exists()) setActiveEncounterData({ id: encDocSnap.id, ...encDocSnap.data() });
else { setActiveEncounterData(null); setError("Active encounter not found. The DM might have deleted it.");}
setIsLoading(false);
}, (err) => { console.error("Error fetching active encounter details:", err); setError("Error loading active encounter data."); setIsLoading(false);});
} 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();
if (unsubscribeCampaign) unsubscribeCampaign();
};
}
return () => {
if (unsubscribeEncounter) unsubscribeEncounter();
if (unsubscribeCampaign) unsubscribeCampaign();
unsubDisplayConfig();
if (unsubscribeEncounter) unsubscribeEncounter();
if (unsubscribeCampaign) unsubscribeCampaign();
};
}, [campaignIdFromUrl, encounterIdFromUrl]);
}, []); // Removed URL params from dependencies
if (isLoading) return <div className="text-center py-10 text-2xl text-slate-300">Loading Player Display...</div>;
if (error) return <div className="text-center py-10 text-2xl text-red-400">{error}</div>;
if (!activeEncounterData) return <div className="text-center py-10 text-3xl text-slate-400">No active encounter to display.</div>;
if (!isPlayerDisplayActive || !activeEncounterData) {
return (
<div className="min-h-screen bg-black text-slate-400 flex flex-col items-center justify-center p-4 text-center">
<EyeOff size={64} className="mb-4 text-slate-500" />
<h2 className="text-3xl font-semibold">Game Session Paused</h2>
<p className="text-xl mt-2">The Dungeon Master has not activated an encounter for display.</p>
</div>
);
}
const { name, participants, round, currentTurnParticipantId, isStarted } = activeEncounterData;