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:
+98
-66
@@ -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 ? (
|
||||
|
||||
Reference in New Issue
Block a user