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:
david raistrick
2026-07-07 14:28:32 -04:00
parent c2bb5cca29
commit a5b6d61e83
+41 -5
View File
@@ -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>
)}