cleaned up dm eyeball toggle
This commit is contained in:
parent
c7215bb503
commit
eb114910f8
230
src/App.js
230
src/App.js
@ -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;
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user