2 Commits
Author SHA1 Message Date
david raistrick 3d0dc79206 feat(UI): hide NPC/monster HP toggle (player display)
Mirrors hidePlayerHp. New hideNpcHp flag on activeDisplay doc.
- AdminView: 'Hide NPC/monster HP' switch in Player Display settings.
- DisplayView: HP bar gated by !(hideNpcHp && p.type !== 'character').
  Covers monsters + NPCs (all non-player).
- Default false (show NPC HP) — opposite of player HP default true.

updateDoc patch (BUG-4 pattern). No clobber.

Test: HideHpToggle selector scoped to 'hide player hp' (now 2 switches).
2026-07-02 11:29:02 -04:00
david raistrick 4b8b8bccfb feat(UI): collapsible Campaigns section
Campaigns heading -> clickable button. ChevronDown expanded,
ChevronRight collapsed. Toggles grid visibility. Shows count
(e.g. 'Campaigns (61)'). Preserves Create Campaign button.

State: campaignsCollapsed in AdminView. aria-expanded/controls for a11y.
2026-07-02 11:17:04 -04:00
2 changed files with 48 additions and 10 deletions
+42 -4
View File
@@ -8,7 +8,7 @@ import {
UserCheck, UserX, HeartCrack, HeartPulse, Zap, EyeOff, ExternalLink, AlertTriangle, UserCheck, UserX, HeartCrack, HeartPulse, Zap, EyeOff, ExternalLink, AlertTriangle,
Play as PlayIcon, Pause as PauseIcon, SkipForward as SkipForwardIcon, Play as PlayIcon, Pause as PauseIcon, SkipForward as SkipForwardIcon,
StopCircle as StopCircleIcon, Users2, Dices, ChevronUp, ChevronDown, ScrollText, StopCircle as StopCircleIcon, Users2, Dices, ChevronUp, ChevronDown, ScrollText,
Maximize2, Minimize2, Moon, Coffee, Clock Maximize2, Minimize2, Moon, Coffee, Clock, ChevronRight
} from 'lucide-react'; } from 'lucide-react';
// Custom CSS for death animation (player view only) // Custom CSS for death animation (player view only)
@@ -1658,6 +1658,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
const [showEndConfirm, setShowEndConfirm] = useState(false); const [showEndConfirm, setShowEndConfirm] = useState(false);
const { data: activeDisplayData } = useFirestoreDocument(getPath.activeDisplay()); const { data: activeDisplayData } = useFirestoreDocument(getPath.activeDisplay());
const hidePlayerHp = activeDisplayData?.hidePlayerHp ?? true; const hidePlayerHp = activeDisplayData?.hidePlayerHp ?? true;
const hideNpcHp = activeDisplayData?.hideNpcHp ?? false;
const handleToggleHidePlayerHp = async () => { const handleToggleHidePlayerHp = async () => {
if (!db) return; if (!db) return;
@@ -1668,6 +1669,15 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
} }
}; };
const handleToggleHideNpcHp = async () => {
if (!db) return;
try {
await storage.updateDoc(getPath.activeDisplay(), { hideNpcHp: !hideNpcHp });
} catch (err) {
console.error("Error toggling hideNpcHp:", err);
}
};
const handleStartEncounter = async () => { const handleStartEncounter = async () => {
if (!db || !encounter.participants || encounter.participants.length === 0) { if (!db || !encounter.participants || encounter.participants.length === 0) {
alert("Add participants first."); alert("Add participants first.");
@@ -1915,6 +1925,17 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${hidePlayerHp ? 'translate-x-4' : 'translate-x-0'}`} /> <span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${hidePlayerHp ? 'translate-x-4' : 'translate-x-0'}`} />
</button> </button>
</label> </label>
<label className="flex items-center justify-between cursor-pointer gap-2 mt-2">
<span className="text-sm text-stone-300">Hide NPC/monster HP</span>
<button
role="switch"
aria-checked={hideNpcHp}
onClick={handleToggleHideNpcHp}
className={`relative inline-flex h-5 w-9 flex-shrink-0 rounded-full border-2 border-transparent transition-colors focus:outline-none ${hideNpcHp ? 'bg-amber-600' : 'bg-stone-600'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${hideNpcHp ? 'translate-x-4' : 'translate-x-0'}`} />
</button>
</label>
</div> </div>
</div> </div>
@@ -2195,6 +2216,7 @@ function AdminView({ userId }) {
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);
const [campaignsCollapsed, setCampaignsCollapsed] = useState(false);
useEffect(() => { useEffect(() => {
if (campaignsData && db) { if (campaignsData && db) {
@@ -2322,7 +2344,18 @@ function AdminView({ userId }) {
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<div className="flex justify-between items-center mb-4"> <div className="flex justify-between items-center mb-4">
<h2 className="text-2xl font-semibold text-amber-300 font-cinzel tracking-wide">Campaigns</h2> <button
onClick={() => setCampaignsCollapsed(c => !c)}
className="flex items-center gap-2 text-2xl font-semibold text-amber-300 font-cinzel tracking-wide hover:text-amber-200 transition-colors"
aria-expanded={!campaignsCollapsed}
aria-controls="campaigns-grid"
>
{campaignsCollapsed
? <ChevronRight size={24} />
: <ChevronDown size={24} />}
Campaigns
<span className="text-sm font-normal text-stone-400">({campaignsWithDetails.length})</span>
</button>
<button <button
onClick={() => setShowCreateModal(true)} onClick={() => setShowCreateModal(true)}
className="bg-red-700 hover:bg-red-800 text-white font-bold py-2 px-4 rounded-lg flex items-center transition-colors" className="bg-red-700 hover:bg-red-800 text-white font-bold py-2 px-4 rounded-lg flex items-center transition-colors"
@@ -2331,11 +2364,13 @@ function AdminView({ userId }) {
</button> </button>
</div> </div>
{!campaignsCollapsed && (
<>
{campaignsWithDetails.length === 0 && !isLoadingCampaigns && ( {campaignsWithDetails.length === 0 && !isLoadingCampaigns && (
<p className="text-stone-400">No campaigns yet. Create one to get started!</p> <p className="text-stone-400">No campaigns yet. Create one to get started!</p>
)} )}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div id="campaigns-grid" className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{campaignsWithDetails.map(campaign => { {campaignsWithDetails.map(campaign => {
const cardStyle = campaign.playerDisplayBackgroundUrl const cardStyle = campaign.playerDisplayBackgroundUrl
? { backgroundImage: `url(${campaign.playerDisplayBackgroundUrl})` } ? { backgroundImage: `url(${campaign.playerDisplayBackgroundUrl})` }
@@ -2383,6 +2418,8 @@ function AdminView({ userId }) {
); );
})} })}
</div> </div>
</>
)}
</div> </div>
{showCreateModal && ( {showCreateModal && (
@@ -2576,6 +2613,7 @@ function DisplayView() {
const { name, participants, round, currentTurnParticipantId, isStarted, isPaused } = activeEncounterData; const { name, participants, round, currentTurnParticipantId, isStarted, isPaused } = activeEncounterData;
const hidePlayerHp = activeDisplayData?.hidePlayerHp ?? true; const hidePlayerHp = activeDisplayData?.hidePlayerHp ?? true;
const hideNpcHp = activeDisplayData?.hideNpcHp ?? false;
let participantsToRender = []; let participantsToRender = [];
if (participants) { if (participants) {
@@ -2677,7 +2715,7 @@ function DisplayView() {
</span> </span>
</div> </div>
{!(hidePlayerHp && p.type === 'character') && ( {!(hidePlayerHp && p.type === 'character') && !(hideNpcHp && p.type !== 'character') && (
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div className="w-full bg-stone-700 rounded-full h-6 md:h-8 relative overflow-hidden border-2 border-stone-600"> <div className="w-full bg-stone-700 rounded-full h-6 md:h-8 relative overflow-hidden border-2 border-stone-600">
<div <div
+1 -1
View File
@@ -43,7 +43,7 @@ describe('BUG-4: hide-player-HP toggle preserves activeDisplay', () => {
await selectCampaignByName('Camp'); await selectCampaignByName('Camp');
// find the hide-player-HP toggle (role switch) // find the hide-player-HP toggle (role switch)
const toggle = await screen.findByRole('switch', { name: /hide/i }, { timeout: 3000 }); const toggle = await screen.findByRole('switch', { name: /hide player hp/i }, { timeout: 3000 });
// toggle ON // toggle ON
fireEvent.click(toggle); fireEvent.click(toggle);