feat: tablet-friendly encounter layout

- Combat controls always left column (narrow portrait, wide desktop)
- Short labels below lg breakpoint, full labels at lg+
- Sticky controls all viewports
- Collapsible Add Participants section (auto-collapse during combat)
- Fullscreen encounter toggle
- Modals via createPortal to escape stacking context
- Reduced padding/margins in portrait orientation
This commit is contained in:
david raistrick
2026-07-08 19:28:05 -04:00
parent fd03ae48c8
commit 91e23856b4
+98 -66
View File
@@ -1,4 +1,5 @@
import React, { useState, useEffect, useRef, useMemo, createContext, useContext, useCallback } from 'react';
import { createPortal } from 'react-dom';
import * as shared from '@ttrpg/shared';
import { initializeApp, getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken, getFirestore, where, orderBy, limit, offset, getStorage, getStorageMode } from './storage';
import { isDevToolsEnabled } from './config/devTools';
@@ -35,7 +36,7 @@ function ToastStack({ toasts, onDismiss }) {
function InfoModal({ message, onClose }) {
if (!message) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center p-4 z-50">
<div className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center p-4 z-[100]">
<div className="bg-stone-900 p-6 rounded-lg shadow-xl w-full max-w-md">
<div className="flex items-center mb-4">
<AlertTriangle size={24} className="text-yellow-400 mr-3 flex-shrink-0" />
@@ -346,8 +347,8 @@ function Modal({ onClose, title, children }) {
return () => window.removeEventListener('keydown', handleEsc);
}, [onClose]);
return (
<div className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center p-4 z-50">
return createPortal(
<div className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center p-4 z-[100]">
<div className="bg-stone-900 p-6 rounded-lg shadow-xl w-full max-w-md">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold text-amber-300 font-cinzel tracking-wide">{title}</h2>
@@ -357,7 +358,8 @@ function Modal({ onClose, title, children }) {
</div>
{children}
</div>
</div>
</div>,
document.body
);
}
@@ -391,8 +393,8 @@ function ConfirmationModal({ isOpen, onClose, onConfirm, title, message }) {
onClose();
};
return (
<div className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center p-4 z-50">
return createPortal(
<div className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center p-4 z-[100]">
<div className="bg-stone-900 p-6 rounded-lg shadow-xl w-full max-w-md">
<div className="flex items-center mb-4">
<AlertTriangle size={24} className="text-yellow-400 mr-3 flex-shrink-0" />
@@ -419,7 +421,8 @@ function ConfirmationModal({ isOpen, onClose, onConfirm, title, message }) {
</button>
</div>
</div>
</div>
</div>,
document.body
);
}
@@ -846,7 +849,7 @@ function CharacterManager({ campaignId, campaignCharacters, syncCharacters }) {
return (
<>
<div className="p-4 bg-stone-900 rounded-lg shadow">
<div className="p-2 md:p-4 bg-stone-900 rounded-lg shadow">
<div className="flex justify-between items-center mb-3">
<button
onClick={() => setIsOpen(!isOpen)}
@@ -1095,6 +1098,8 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
const [manualInitiative, setManualInitiative] = useState('');
const [monsterAc, setMonsterAc] = useState('');
const [asNpc, setAsNpc] = useState(false);
const combatActive = encounter.isStarted && !encounter.isPaused;
const [addSectionOpen, setAddSectionOpen] = useState(!combatActive);
const [editingParticipant, setEditingParticipant] = useState(null);
const [hpChangeValues, setHpChangeValues] = useState({});
const [draggedItemId, setDraggedItemId] = useState(null);
@@ -1489,17 +1494,30 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
return (
<>
<div className="p-3 bg-stone-900 rounded-md mt-4">
<div className="flex justify-between items-center mb-3">
<h4 className="text-lg font-medium text-amber-200 font-cinzel tracking-wide">Add Participants</h4>
<button
onClick={handleAddAllCampaignCharacters}
className="px-3 py-1.5 text-xs font-medium text-white bg-violet-700 hover:bg-violet-800 rounded-md transition-colors flex items-center"
disabled={!campaignCharacters || campaignCharacters.length === 0 || (encounter.isStarted && !encounter.isPaused)}
>
<Users2 size={16} className="mr-1.5" />
<Dices size={16} className="mr-1.5" /> Add All (Roll Init)
</button>
</div>
{(() => {
const open = addSectionOpen && !combatActive;
return (
<details open={open} className="mt-2">
<summary
onClick={(e) => { if (!combatActive) { e.preventDefault(); setAddSectionOpen(v => !v); } else { e.preventDefault(); } }}
className="cursor-pointer text-lg font-medium text-amber-200 font-cinzel tracking-wide flex items-center gap-2 select-none"
>
{open ? <ChevronDown size={20} /> : <ChevronRight size={20} />}
Add Participants
{combatActive && <span className="text-xs text-stone-400 font-normal ml-2">(pause to add)</span>}
</summary>
{open && (
<>
<div className="flex justify-end items-center mb-3 mt-2">
<button
onClick={handleAddAllCampaignCharacters}
className="px-3 py-1.5 text-xs font-medium text-white bg-violet-700 hover:bg-violet-800 rounded-md transition-colors flex items-center"
disabled={!campaignCharacters || campaignCharacters.length === 0 || combatActive}
>
<Users2 size={16} className="mr-1.5" />
<Dices size={16} className="mr-1.5" /> Add All (Roll Init)
</button>
</div>
{/* Warning when combat is active */}
{encounter.isStarted && !encounter.isPaused && (
@@ -1678,15 +1696,11 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
</button>
</div>
</form>
{lastRollDetails && (
<p className="text-sm text-green-400 mt-2 mb-2 text-center">
{lastRollDetails.manual
? `${lastRollDetails.name} (${lastRollDetails.type === 'character' ? 'Character' : lastRollDetails.type === 'monster' ? 'Monster' : lastRollDetails.type === 'npc' ? 'NPC' : lastRollDetails.type}): Set initiative ${lastRollDetails.total}`
: `${lastRollDetails.name} (${lastRollDetails.type === 'character' ? 'Character' : lastRollDetails.type === 'monster' ? 'Monster' : lastRollDetails.type === 'npc' ? 'NPC' : lastRollDetails.type}): Rolled d20 (${lastRollDetails.roll}) ${formatInitMod(lastRollDetails.mod)} = ${lastRollDetails.total} Initiative`
}
</p>
)}
</>
)}
</details>
);
})()}
{participants.length === 0 && <p className="text-sm text-stone-400">No participants added yet.</p>}
@@ -2017,6 +2031,16 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
})}
</ul>
{lastRollDetails && (
<p className="text-sm text-green-400 mt-2 mb-2 text-center">
{lastRollDetails.manual
? `${lastRollDetails.name} (${lastRollDetails.type === 'character' ? 'Character' : lastRollDetails.type === 'monster' ? 'Monster' : lastRollDetails.type === 'npc' ? 'NPC' : lastRollDetails.type}): Set initiative ${lastRollDetails.total}`
: `${lastRollDetails.name} (${lastRollDetails.type === 'character' ? 'Character' : lastRollDetails.type === 'monster' ? 'Monster' : lastRollDetails.type === 'npc' ? 'NPC' : lastRollDetails.type}): Rolled d20 (${lastRollDetails.roll}) ${formatInitMod(lastRollDetails.mod)} = ${lastRollDetails.total} Initiative`
}
</p>
)}
{editingParticipant && (
<EditParticipantModal
participant={editingParticipant}
@@ -2194,95 +2218,95 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
return (
<>
<div className="lg:sticky lg:top-4 p-4 bg-stone-900 rounded-md shadow-lg">
<h4 className="text-lg font-medium text-amber-200 mb-4 text-center font-cinzel tracking-wide">Combat Controls</h4>
<div className="flex flex-col gap-3">
<div className="md:sticky md:top-2 p-3 bg-stone-900 rounded-md shadow-lg">
<h4 className="text-base font-medium text-amber-200 mb-2 text-center font-cinzel tracking-wide">Combat Controls</h4>
<div className="flex flex-col gap-2">
{!encounter.isStarted ? (
<button
onClick={handleStartEncounter}
className="w-full px-4 py-3 text-sm font-medium text-white bg-red-700 hover:bg-red-800 rounded-md transition-colors flex items-center justify-center"
className="w-full min-w-0 px-3 py-2 text-sm font-medium text-white bg-red-700 hover:bg-red-800 rounded-md transition-colors flex items-center justify-center"
disabled={!encounter.participants || encounter.participants.filter(p => p.isActive).length === 0}
>
<PlayIcon size={18} className="mr-2" /> Start Combat
<PlayIcon size={16} className="mr-1 flex-shrink-0" /> <span className="truncate hidden lg:inline">Start Combat</span><span className="inline lg:hidden truncate">Start</span>
</button>
) : (
<>
<button
onClick={handleTogglePause}
className={`w-full px-4 py-3 text-sm font-medium text-white rounded-md transition-colors flex items-center justify-center ${encounter.isPaused ? 'bg-red-700 hover:bg-red-800' : 'bg-amber-600 hover:bg-amber-700'}`}
className={`w-full min-w-0 px-3 py-2 text-sm font-medium text-white rounded-md transition-colors flex items-center justify-center ${encounter.isPaused ? 'bg-red-700 hover:bg-red-800' : 'bg-amber-600 hover:bg-amber-700'}`}
title={encounter.isPaused
? 'Resume combat from the current turn. The initiative order will be recalculated to include any participants added while paused.'
: 'Pause combat to freeze the turn order. While paused, you can add or remove participants, adjust HP, and edit initiative values. The turn order will be recalculated when you resume.'}
>
{encounter.isPaused ? <PlayIcon size={18} className="mr-2" /> : <PauseIcon size={18} className="mr-2" />}
{encounter.isPaused ? 'Resume Combat' : 'Pause Combat'}
{encounter.isPaused ? <PlayIcon size={16} className="mr-1 flex-shrink-0" /> : <PauseIcon size={16} className="mr-1 flex-shrink-0" />}
<span className="truncate hidden lg:inline">{encounter.isPaused ? 'Resume Combat' : 'Pause Combat'}</span><span className="inline lg:hidden truncate">{encounter.isPaused ? 'Resume' : 'Pause'}</span>
</button>
<button
onClick={handleNextTurn}
className="w-full px-4 py-3 text-sm font-medium text-white bg-purple-700 hover:bg-purple-800 rounded-md transition-colors flex items-center justify-center"
className="w-full min-w-0 px-3 py-2 text-sm font-medium text-white bg-purple-700 hover:bg-purple-800 rounded-md transition-colors flex items-center justify-center"
disabled={!encounter.currentTurnParticipantId || encounter.isPaused}
>
<SkipForwardIcon size={18} className="mr-2" /> Next Turn
<SkipForwardIcon size={16} className="mr-1 flex-shrink-0" /> <span className="truncate hidden lg:inline">Next Turn</span><span className="inline lg:hidden truncate">Next</span>
</button>
<button
onClick={() => setShowEndConfirm(true)}
className="w-full px-4 py-3 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-md transition-colors flex items-center justify-center"
className="w-full min-w-0 px-3 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-md transition-colors flex items-center justify-center"
>
<StopCircleIcon size={18} className="mr-2" /> End Combat
<StopCircleIcon size={16} className="mr-1 flex-shrink-0" /> <span className="truncate hidden lg:inline">End Combat</span><span className="inline lg:hidden truncate">End</span>
</button>
{/* Round Counter */}
<div className="mt-2 pt-3 border-t border-stone-700">
<p className="text-center text-lg font-semibold text-amber-300 font-cinzel">Round: {encounter.round}</p>
<div className="mt-1 pt-2 border-t border-stone-700">
<p className="text-center text-base font-semibold text-amber-300 font-cinzel">Round: {encounter.round}</p>
{encounter.isPaused && (
<p className="text-center text-sm text-yellow-400 font-semibold mt-1">(Paused)</p>
<p className="text-center text-xs text-yellow-400 font-semibold mt-1">(Paused)</p>
)}
</div>
</>
)}
</div>
{/* Undo / Redo — queried on click (no live logs subscription during combat) */}
<div className="mt-3 flex gap-2">
{/* Undo / Redo */}
<div className="mt-2 flex gap-2">
<button
onClick={handleUndo}
className="flex-1 px-3 py-2 text-xs font-medium text-white bg-amber-700 hover:bg-amber-600 rounded-md transition-colors flex items-center justify-center"
className="flex-1 px-3 py-1.5 text-xs font-medium text-white bg-amber-700 hover:bg-amber-600 rounded-md transition-colors flex items-center justify-center"
title="Undo latest action for this encounter"
>
<Undo2 size={14} className="mr-1" /> Undo
<Undo2 size={12} className="mr-1" /> <span className="lg:hidden">Un</span><span className="hidden lg:inline">Undo</span>
</button>
<button
onClick={handleRedo}
className="flex-1 px-3 py-2 text-xs font-medium text-white bg-sky-700 hover:bg-sky-600 rounded-md transition-colors flex items-center justify-center"
className="flex-1 px-3 py-1.5 text-xs font-medium text-white bg-sky-700 hover:bg-sky-600 rounded-md transition-colors flex items-center justify-center"
title="Redo latest undone action for this encounter"
>
<Redo2 size={14} className="mr-1" /> Redo
<Redo2 size={12} className="mr-1" /> <span className="lg:hidden">Re</span><span className="hidden lg:inline">Redo</span>
</button>
</div>
{/* Display Settings */}
<div className="mt-3 pt-3 border-t border-stone-700">
<div className="mt-2 pt-2 border-t border-stone-700">
<h5 className="text-xs font-semibold text-stone-400 uppercase tracking-wider mb-2">Player Display</h5>
<label className="flex items-center justify-between cursor-pointer gap-2">
<span className="text-sm text-stone-300">Hide player HP</span>
<span className="text-xs text-stone-300">Hide player HP</span>
<button
role="switch"
aria-checked={hidePlayerHp}
onClick={handleToggleHidePlayerHp}
className={`relative inline-flex h-5 w-9 flex-shrink-0 rounded-full border-2 border-transparent transition-colors focus:outline-none ${hidePlayerHp ? 'bg-amber-600' : 'bg-stone-600'}`}
className={`relative inline-flex h-4 w-8 flex-shrink-0 rounded-full border-2 border-transparent transition-colors focus:outline-none ${hidePlayerHp ? 'bg-amber-600' : 'bg-stone-600'}`}
>
<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-3 w-3 transform rounded-full bg-white shadow transition-transform ${hidePlayerHp ? 'translate-x-4' : 'translate-x-0'}`} />
</button>
</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>
<span className="text-xs 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'}`}
className={`relative inline-flex h-4 w-8 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'}`} />
<span className={`inline-block h-3 w-3 transform rounded-full bg-white shadow transition-transform ${hideNpcHp ? 'translate-x-4' : 'translate-x-0'}`} />
</button>
</label>
</div>
@@ -2317,6 +2341,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
catch { return null; }
});
const [showCreateModal, setShowCreateModal] = useState(false);
const [encounterFullscreen, setEncounterFullscreen] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [itemToDelete, setItemToDelete] = useState(null);
const [draggedEncounterId, setDraggedEncounterId] = useState(null);
@@ -2504,7 +2529,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
return (
<>
<div className="mt-6 p-4 bg-stone-900 rounded-lg shadow">
<div className="mt-3 md:mt-6 p-2 md:p-4 bg-stone-900 rounded-lg shadow">
<div className="flex justify-between items-center mb-3">
<h3 className="text-xl font-semibold text-amber-300 font-cinzel tracking-wide flex items-center">
<Swords size={24} className="mr-2" /> Encounters
@@ -2596,13 +2621,20 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
)}
{selectedEncounter && (
<div className="mt-6 p-4 bg-stone-900 rounded-lg shadow-inner">
<h3 className="text-xl font-semibold text-amber-300 mb-3 font-cinzel tracking-wide">
Managing Encounter: {selectedEncounter.name}
<div className={`mt-3 md:mt-6 p-2 md:p-4 bg-stone-900 rounded-lg shadow-inner ${encounterFullscreen ? 'fixed inset-0 z-50 m-0 rounded-none overflow-y-auto' : ''}`}>
<h3 className="text-xl font-semibold text-amber-300 mb-3 font-cinzel tracking-wide flex items-center justify-between">
<span>Managing Encounter: {selectedEncounter.name}</span>
<button
onClick={() => setEncounterFullscreen(v => !v)}
className="ml-2 p-1.5 rounded-md text-stone-300 bg-stone-700 hover:bg-stone-600 transition-colors flex-shrink-0"
title={encounterFullscreen ? 'Exit fullscreen encounter' : 'Fullscreen encounter'}
>
{encounterFullscreen ? <Minimize2 size={18} /> : <Maximize2 size={18} />}
</button>
</h3>
<div className="flex flex-col lg:flex-row gap-4">
{/* Combat Controls - Left Side (Sticky on large screens) */}
<div className="lg:w-64 flex-shrink-0">
<div className="flex flex-row gap-4">
{/* Combat Controls - always left */}
<div className="w-32 md:w-64 flex-shrink-0 sticky top-4 self-start">
<InitiativeControls
campaignId={campaignId}
encounter={selectedEncounter}
@@ -2960,7 +2992,7 @@ function AdminView({ userId }) {
return (
<>
<div className="space-y-6">
<div className="space-y-3 md:space-y-6">
<div>
<div className="flex justify-between items-center mb-4">
<button
@@ -3112,7 +3144,7 @@ function AdminView({ userId }) {
)}
{selectedCampaign && (
<div className="mt-6 p-6 bg-stone-900 rounded-lg shadow-xl">
<div className="mt-3 md:mt-6 p-3 md:p-6 bg-stone-900 rounded-lg shadow-xl">
<h2 className="text-2xl font-semibold text-amber-300 mb-4 font-cinzel tracking-wide">
Managing: {selectedCampaign.name}
</h2>
@@ -3839,7 +3871,7 @@ function LogsView() {
</div>
</header>
<main className="container mx-auto p-4 md:p-8">
<main className="mx-auto p-4 md:p-8 max-w-7xl">
{isLoading ? (
<LoadingSpinner message="Loading logs..." />
) : logs.length === 0 ? (