Generic ruleset UI + campaign/encounter tags + switch-during-combat fix
Campaign + encounter cards: ruleset tag (5e/GEN), create date visible. CreateEncounterForm keyed by campaignId so default ruleset syncs on campaign switch. Campaign switch during active combat: - encounterStartedRef (unpaused) blocks switch + toast - encounterActiveRef (started paused-or-not) gates display-follow effect - manualSelectRef tracks user clicks; external display change clears it (BUG-12 follow still works for replay/other-DM) - Prevents revert race when EncounterManager unmounts and refs go false UI: - campaign card: ruleset tag bottom-right, opposite delete - encounter card: tag inline title, date left of participants count - EncounterManager fetches campaignDoc for default ruleset inheritance Tests green: app 100, shared 186, server 40.
This commit is contained in:
+41
-5
@@ -1993,7 +1993,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
|
||||
// ENCOUNTER MANAGER COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharacters }) {
|
||||
function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharacters, encounterStartedRef, encounterActiveRef }) {
|
||||
const { showToast } = useUIFeedback();
|
||||
const { data: encountersData, isLoading: isLoadingEncounters } = useFirestoreCollection(
|
||||
campaignId ? getPath.encounters(campaignId) : null
|
||||
@@ -2130,6 +2130,15 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
|
||||
|
||||
const selectedEncounter = encounters?.find(e => e.id === selectedEncounterId);
|
||||
|
||||
useEffect(() => {
|
||||
if (encounterStartedRef) {
|
||||
encounterStartedRef.current = !!(selectedEncounter && selectedEncounter.isStarted && !selectedEncounter.isPaused);
|
||||
}
|
||||
if (encounterActiveRef) {
|
||||
encounterActiveRef.current = !!(selectedEncounter && selectedEncounter.isStarted);
|
||||
}
|
||||
}, [selectedEncounter, encounterStartedRef, encounterActiveRef]);
|
||||
|
||||
if (isLoadingEncounters && campaignId) {
|
||||
return <p className="text-center text-stone-300 mt-4">Loading encounters...</p>;
|
||||
}
|
||||
@@ -2166,9 +2175,9 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
|
||||
>
|
||||
<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>
|
||||
<h4 className="font-medium text-white">{encounter.name} <span className={`ml-1 px-1.5 py-0.5 rounded text-xs font-bold tracking-wide ${encounter.ruleset === 'generic' ? 'bg-purple-900/80 text-purple-200 border border-purple-500' : 'bg-amber-900/80 text-amber-200 border border-amber-500'}`}>{encounter.ruleset === 'generic' ? 'GEN' : '5e'}</span></h4>
|
||||
<p className="text-xs text-stone-300">
|
||||
Participants: {encounter.participants?.length || 0}
|
||||
{encounter.createdAt && `${new Date(encounter.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false })} · `}Participants: {encounter.participants?.length || 0}
|
||||
</p>
|
||||
{isLive && (
|
||||
<span className="text-xs text-green-400 font-semibold block mt-1">
|
||||
@@ -2265,6 +2274,19 @@ function AdminView({ userId }) {
|
||||
|
||||
const [campaignsWithDetails, setCampaignsWithDetails] = useState([]);
|
||||
const [selectedCampaignId, setSelectedCampaignId] = useState(null);
|
||||
const encounterStartedRef = useRef(false);
|
||||
const encounterActiveRef = useRef(false);
|
||||
const manualSelectRef = useRef(false);
|
||||
const prevDisplayCampaignRef = useRef(null);
|
||||
|
||||
// External display change (replay/other DM) = clear manual override, allow follow.
|
||||
useEffect(() => {
|
||||
const cur = initialActiveInfo?.activeCampaignId || null;
|
||||
if (cur !== prevDisplayCampaignRef.current) {
|
||||
manualSelectRef.current = false;
|
||||
prevDisplayCampaignRef.current = cur;
|
||||
}
|
||||
}, [initialActiveInfo]);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [itemToDelete, setItemToDelete] = useState(null);
|
||||
@@ -2324,10 +2346,15 @@ function AdminView({ userId }) {
|
||||
}, [campaignsData]);
|
||||
|
||||
useEffect(() => {
|
||||
// Skip follow only if user manually selected AND display hasn't changed.
|
||||
// (external display change clears manualSelectRef via prevDisplay effect)
|
||||
if (manualSelectRef.current && selectedCampaignId !== initialActiveInfo?.activeCampaignId) return;
|
||||
if (
|
||||
initialActiveInfo &&
|
||||
initialActiveInfo.activeCampaignId &&
|
||||
campaignsWithDetails.length > 0
|
||||
campaignsWithDetails.length > 0 &&
|
||||
!encounterStartedRef.current &&
|
||||
!encounterActiveRef.current
|
||||
) {
|
||||
const campaignExists = campaignsWithDetails.some(c => c.id === initialActiveInfo.activeCampaignId);
|
||||
if (campaignExists && selectedCampaignId !== initialActiveInfo.activeCampaignId) {
|
||||
@@ -2500,7 +2527,14 @@ function AdminView({ userId }) {
|
||||
return (
|
||||
<div
|
||||
key={campaign.id}
|
||||
onClick={() => setSelectedCampaignId(campaign.id)}
|
||||
onClick={() => {
|
||||
if (encounterStartedRef.current) {
|
||||
showToast('End or pause active encounter before switching campaigns.');
|
||||
return;
|
||||
}
|
||||
manualSelectRef.current = true;
|
||||
setSelectedCampaignId(campaign.id);
|
||||
}}
|
||||
className={cardClasses}
|
||||
style={cardStyle}
|
||||
>
|
||||
@@ -2574,6 +2608,8 @@ function AdminView({ userId }) {
|
||||
: null
|
||||
}
|
||||
campaignCharacters={selectedCampaign.characters || []}
|
||||
encounterStartedRef={encounterStartedRef}
|
||||
encounterActiveRef={encounterActiveRef}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user