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
|
// ENCOUNTER MANAGER COMPONENT
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharacters }) {
|
function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharacters, encounterStartedRef, encounterActiveRef }) {
|
||||||
const { showToast } = useUIFeedback();
|
const { showToast } = useUIFeedback();
|
||||||
const { data: encountersData, isLoading: isLoadingEncounters } = useFirestoreCollection(
|
const { data: encountersData, isLoading: isLoadingEncounters } = useFirestoreCollection(
|
||||||
campaignId ? getPath.encounters(campaignId) : null
|
campaignId ? getPath.encounters(campaignId) : null
|
||||||
@@ -2130,6 +2130,15 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
|
|||||||
|
|
||||||
const selectedEncounter = encounters?.find(e => e.id === selectedEncounterId);
|
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) {
|
if (isLoadingEncounters && campaignId) {
|
||||||
return <p className="text-center text-stone-300 mt-4">Loading encounters...</p>;
|
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 className="flex justify-between items-center">
|
||||||
<div onClick={() => setSelectedEncounterId(encounter.id)} className="cursor-pointer flex-grow">
|
<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">
|
<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>
|
</p>
|
||||||
{isLive && (
|
{isLive && (
|
||||||
<span className="text-xs text-green-400 font-semibold block mt-1">
|
<span className="text-xs text-green-400 font-semibold block mt-1">
|
||||||
@@ -2265,6 +2274,19 @@ function AdminView({ userId }) {
|
|||||||
|
|
||||||
const [campaignsWithDetails, setCampaignsWithDetails] = useState([]);
|
const [campaignsWithDetails, setCampaignsWithDetails] = useState([]);
|
||||||
const [selectedCampaignId, setSelectedCampaignId] = useState(null);
|
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 [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
const [itemToDelete, setItemToDelete] = useState(null);
|
const [itemToDelete, setItemToDelete] = useState(null);
|
||||||
@@ -2324,10 +2346,15 @@ function AdminView({ userId }) {
|
|||||||
}, [campaignsData]);
|
}, [campaignsData]);
|
||||||
|
|
||||||
useEffect(() => {
|
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 (
|
if (
|
||||||
initialActiveInfo &&
|
initialActiveInfo &&
|
||||||
initialActiveInfo.activeCampaignId &&
|
initialActiveInfo.activeCampaignId &&
|
||||||
campaignsWithDetails.length > 0
|
campaignsWithDetails.length > 0 &&
|
||||||
|
!encounterStartedRef.current &&
|
||||||
|
!encounterActiveRef.current
|
||||||
) {
|
) {
|
||||||
const campaignExists = campaignsWithDetails.some(c => c.id === initialActiveInfo.activeCampaignId);
|
const campaignExists = campaignsWithDetails.some(c => c.id === initialActiveInfo.activeCampaignId);
|
||||||
if (campaignExists && selectedCampaignId !== initialActiveInfo.activeCampaignId) {
|
if (campaignExists && selectedCampaignId !== initialActiveInfo.activeCampaignId) {
|
||||||
@@ -2500,7 +2527,14 @@ function AdminView({ userId }) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={campaign.id}
|
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}
|
className={cardClasses}
|
||||||
style={cardStyle}
|
style={cardStyle}
|
||||||
>
|
>
|
||||||
@@ -2574,6 +2608,8 @@ function AdminView({ userId }) {
|
|||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
campaignCharacters={selectedCampaign.characters || []}
|
campaignCharacters={selectedCampaign.characters || []}
|
||||||
|
encounterStartedRef={encounterStartedRef}
|
||||||
|
encounterActiveRef={encounterActiveRef}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user