Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41c1e48874 | ||
|
|
863e8b3719 |
@@ -307,6 +307,26 @@ ttrpg-initiative-tracker/
|
|||||||
* `docs/GLOSSARY.md` — domain terms (turn vs. round, etc.)
|
* `docs/GLOSSARY.md` — domain terms (turn vs. round, etc.)
|
||||||
* `TODO.md` — known bugs and backlog
|
* `TODO.md` — known bugs and backlog
|
||||||
|
|
||||||
|
## Prevent Sleep (Wake Lock)
|
||||||
|
|
||||||
|
DM and Player views have a Coffee/Moon toggle to keep screen awake during combat.
|
||||||
|
|
||||||
|
**Requires secure context** (HTTPS or localhost). The Screen Wake Lock API refuses
|
||||||
|
on plain HTTP.
|
||||||
|
|
||||||
|
- **Prod (HTTPS):** works automatically on all devices.
|
||||||
|
- **Local dev (localhost):** works automatically — localhost is a trusted origin.
|
||||||
|
- **LAN IP (phones, plain HTTP):** wake lock fails silently. Two options:
|
||||||
|
1. **Android Chrome flag (dev testing):** `chrome://flags/#unsafely-treat-insecure-origin-as-secure` → add full URL with port, e.g. `http://10.0.0.5:3999`. Enable flag, relaunch.
|
||||||
|
2. **mkcert local CA:** generate trusted cert for LAN IP. Heavier setup.
|
||||||
|
|
||||||
|
**iOS Safari:** Wake Lock API broken in standalone PWA mode (iOS 16.4–18.4, WebKit bug 254545). Fixed in 18.4+. Works in browser tab (not installed PWA).
|
||||||
|
|
||||||
|
**Troubleshooting:** if toggle shows active but screen still sleeps, check:
|
||||||
|
- Battery saver / power save mode (browser refuses)
|
||||||
|
- Chrome devtools console for `WakeLockError`
|
||||||
|
- Secure context: `window.isSecureContext` must be `true`
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
If you want to contribute, send me a message here: [https://discourse.draft13.com/c/ttrpg-initiative-tracker/16](https://discourse.draft13.com/c/ttrpg-initiative-tracker/16), and I can add to this Gitea instance and you can feel free to fork the repository and submit pull requests. For major changes, please pose a topic to the Discourse instance above linked above first to discuss what you would like to change.
|
If you want to contribute, send me a message here: [https://discourse.draft13.com/c/ttrpg-initiative-tracker/16](https://discourse.draft13.com/c/ttrpg-initiative-tracker/16), and I can add to this Gitea instance and you can feel free to fork the repository and submit pull requests. For major changes, please pose a topic to the Discourse instance above linked above first to discuss what you would like to change.
|
||||||
|
|||||||
@@ -5,16 +5,17 @@ Backlog of bugs + long-term items. Milestones live in REWORK_PLAN.md.
|
|||||||
## Open
|
## Open
|
||||||
|
|
||||||
|
|
||||||
fullscreen and dont lock on main app dm view and the no-game-player view
|
x fullscreen and dont lock on main app dm view and the no-game-player view ...and doesnt actually prevent lock on android
|
||||||
|
|
||||||
also better vert tab layout - labelt friendly
|
also better vert tab layout - labelt friendly
|
||||||
|
|
||||||
needs AC for players dude
|
x needs AC for players dude
|
||||||
|
|
||||||
and quick entry hp
|
and quick entry hp
|
||||||
|
|
||||||
hp do not carry from encounter to ecnounter!!!
|
hp do not carry from encounter to ecnounter!!!
|
||||||
|
^feature to add back to campaign character after encounter?
|
||||||
|
maybe campaign toggle in charc section (choosing each end is DM overload will be missed forgotten done wront)
|
||||||
|
|
||||||
hp wont go over max and no temp hp support
|
hp wont go over max and no temp hp support
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,14 @@ git config core.hooksPath .githooks # enable pre-push test gate
|
|||||||
|
|
||||||
Idempotent: if port busy, leaves existing proc as-is.
|
Idempotent: if port busy, leaves existing proc as-is.
|
||||||
|
|
||||||
|
**LAN access (phones):** frontend binds 0.0.0.0, LAN IP auto-detected.
|
||||||
|
Other devices hit `http://<lan-ip>:3999`.
|
||||||
|
|
||||||
|
**Wake Lock (Prevent Sleep) on LAN IP:** requires secure context.
|
||||||
|
Plain HTTP on LAN IP = wake lock silently fails. Android Chrome flag workaround:
|
||||||
|
`chrome://flags/#unsafely-treat-insecure-origin-as-secure` → add
|
||||||
|
`http://<lan-ip>:3999` (with port), enable, relaunch. See README for full details.
|
||||||
|
|
||||||
Smoke check:
|
Smoke check:
|
||||||
```bash
|
```bash
|
||||||
curl http://127.0.0.1:4001/health # -> {"ok":true}
|
curl http://127.0.0.1:4001/health # -> {"ok":true}
|
||||||
|
|||||||
+13
-3
@@ -27,11 +27,15 @@ fi
|
|||||||
|
|
||||||
# frontend: server storage, :3999
|
# frontend: server storage, :3999
|
||||||
if ! lsof -ti :3999 >/dev/null 2>&1; then
|
if ! lsof -ti :3999 >/dev/null 2>&1; then
|
||||||
echo "starting frontend :3999..."
|
# detect LAN ip so other devices (phones) can reach backend
|
||||||
|
LAN_IP=$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || echo 127.0.0.1)
|
||||||
|
echo "starting frontend :3999 (lan ip: $LAN_IP)..."
|
||||||
NODE_ENV=development REACT_APP_DEV_TOOLS=1 \
|
NODE_ENV=development REACT_APP_DEV_TOOLS=1 \
|
||||||
REACT_APP_STORAGE=server \
|
REACT_APP_STORAGE=server \
|
||||||
REACT_APP_BACKEND_URL=http://127.0.0.1:4001 \
|
REACT_APP_BACKEND_URL=http://${LAN_IP}:4001 \
|
||||||
REACT_APP_BACKEND_REALTIME_URL=ws://127.0.0.1:4001/ws \
|
REACT_APP_BACKEND_REALTIME_URL=ws://${LAN_IP}:4001/ws \
|
||||||
|
DANGEROUSLY_DISABLE_HOST_CHECK=true \
|
||||||
|
HOST=0.0.0.0 \
|
||||||
BROWSER=none PORT=3999 \
|
BROWSER=none PORT=3999 \
|
||||||
nohup npm start > tmp/fe.log 2>&1 &
|
nohup npm start > tmp/fe.log 2>&1 &
|
||||||
echo $! > tmp/fe.pid
|
echo $! > tmp/fe.pid
|
||||||
@@ -51,5 +55,11 @@ done
|
|||||||
echo ""
|
echo ""
|
||||||
echo "backend : http://127.0.0.1:4001 (curl http://127.0.0.1:4001/health)"
|
echo "backend : http://127.0.0.1:4001 (curl http://127.0.0.1:4001/health)"
|
||||||
echo "frontend : http://127.0.0.1:3999 (admin / player /display)"
|
echo "frontend : http://127.0.0.1:3999 (admin / player /display)"
|
||||||
|
echo "lan url : http://${LAN_IP}:3999 (phones/other devices on network)"
|
||||||
echo "logs : tmp/server.log tmp/fe.log"
|
echo "logs : tmp/server.log tmp/fe.log"
|
||||||
echo "stop : ./scripts/dev-stop.sh"
|
echo "stop : ./scripts/dev-stop.sh"
|
||||||
|
echo ""
|
||||||
|
echo "NOTE: Prevent Sleep (wake lock) requires secure context (HTTPS/localhost)."
|
||||||
|
echo " LAN IP (http) won't work unless Android Chrome flag set:"
|
||||||
|
echo " chrome://flags/#unsafely-treat-insecure-origin-as-secure"
|
||||||
|
echo " Add: http://${LAN_IP}:3999 (include port)"
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// AC field on participant + builders.
|
||||||
|
const shared = require('@ttrpg/shared');
|
||||||
|
const { makeParticipant, buildMonsterParticipant, buildCharacterParticipant, rollHpFormula } = shared;
|
||||||
|
|
||||||
|
describe('AC field', () => {
|
||||||
|
test('makeParticipant accepts ac', () => {
|
||||||
|
const p = makeParticipant({ name: 'Orc', type: 'monster', initiative: 10, maxHp: 15, currentHp: 15, ac: 13 });
|
||||||
|
expect(p.ac).toBe(13);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('makeParticipant ac defaults null', () => {
|
||||||
|
const p = makeParticipant({ name: 'Orc', type: 'monster', initiative: 10, maxHp: 15, currentHp: 15 });
|
||||||
|
expect(p.ac).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildMonsterParticipant accepts ac', () => {
|
||||||
|
const { participant } = buildMonsterParticipant({ name: 'Goblin', maxHp: 7, initMod: 2, ac: 15 });
|
||||||
|
expect(participant.ac).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildMonsterParticipant ac defaults null', () => {
|
||||||
|
const { participant } = buildMonsterParticipant({ name: 'Goblin', maxHp: 7 });
|
||||||
|
expect(participant.ac).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildCharacterParticipant accepts character ac', () => {
|
||||||
|
const { participant } = buildCharacterParticipant({ id: 'c1', name: 'Fighter', defaultMaxHp: 30, defaultInitMod: 2, defaultAc: 18 });
|
||||||
|
expect(participant.ac).toBe(18);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildCharacterParticipant ac defaults null when unset', () => {
|
||||||
|
const { participant } = buildCharacterParticipant({ id: 'c1', name: 'Fighter', defaultMaxHp: 30 });
|
||||||
|
expect(participant.ac).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
+4
-1
@@ -361,6 +361,7 @@ function makeParticipant(opts) {
|
|||||||
deathSaveSuccesses: opts.deathSaveSuccesses || 0,
|
deathSaveSuccesses: opts.deathSaveSuccesses || 0,
|
||||||
deathSaveFailures: opts.deathSaveFailures || 0,
|
deathSaveFailures: opts.deathSaveFailures || 0,
|
||||||
hpFormula: opts.hpFormula || null,
|
hpFormula: opts.hpFormula || null,
|
||||||
|
ac: opts.ac !== undefined ? opts.ac : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,12 +378,13 @@ function buildCharacterParticipant(character) {
|
|||||||
initiative: finalInitiative,
|
initiative: finalInitiative,
|
||||||
maxHp,
|
maxHp,
|
||||||
currentHp: maxHp,
|
currentHp: maxHp,
|
||||||
|
ac: character.defaultAc !== undefined ? character.defaultAc : null,
|
||||||
}),
|
}),
|
||||||
roll: { roll: initiativeRoll, mod: modifier, total: finalInitiative },
|
roll: { roll: initiativeRoll, mod: modifier, total: finalInitiative },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildMonsterParticipant({ name, maxHp, initMod, asNpc, hpFormula }) {
|
function buildMonsterParticipant({ name, maxHp, initMod, asNpc, hpFormula, ac }) {
|
||||||
const initiativeRoll = rollD20();
|
const initiativeRoll = rollD20();
|
||||||
const modifier = initMod !== undefined ? initMod : MONSTER_DEFAULT_INIT_MOD;
|
const modifier = initMod !== undefined ? initMod : MONSTER_DEFAULT_INIT_MOD;
|
||||||
const finalInitiative = initiativeRoll + modifier;
|
const finalInitiative = initiativeRoll + modifier;
|
||||||
@@ -406,6 +408,7 @@ function buildMonsterParticipant({ name, maxHp, initMod, asNpc, hpFormula }) {
|
|||||||
maxHp: hp,
|
maxHp: hp,
|
||||||
currentHp: hp,
|
currentHp: hp,
|
||||||
hpFormula: hpFormula || null,
|
hpFormula: hpFormula || null,
|
||||||
|
ac: ac !== undefined ? ac : null,
|
||||||
}),
|
}),
|
||||||
roll: { roll: initiativeRoll, mod: modifier, total: finalInitiative, hpRoll },
|
roll: { roll: initiativeRoll, mod: modifier, total: finalInitiative, hpRoll },
|
||||||
};
|
};
|
||||||
|
|||||||
+216
-61
@@ -583,6 +583,7 @@ function EditParticipantModal({ participant, onClose, onSave }) {
|
|||||||
const [currentHp, setCurrentHp] = useState(participant.currentHp);
|
const [currentHp, setCurrentHp] = useState(participant.currentHp);
|
||||||
const [maxHp, setMaxHp] = useState(participant.maxHp);
|
const [maxHp, setMaxHp] = useState(participant.maxHp);
|
||||||
const [hpFormula, setHpFormula] = useState(participant.hpFormula || '');
|
const [hpFormula, setHpFormula] = useState(participant.hpFormula || '');
|
||||||
|
const [ac, setAc] = useState(participant.ac != null ? participant.ac : '');
|
||||||
const [asNpc, setAsNpc] = useState(participant.type === 'npc');
|
const [asNpc, setAsNpc] = useState(participant.type === 'npc');
|
||||||
|
|
||||||
const handleSubmit = (e) => {
|
const handleSubmit = (e) => {
|
||||||
@@ -602,6 +603,7 @@ function EditParticipantModal({ participant, onClose, onSave }) {
|
|||||||
currentHp: finalCurrentHp,
|
currentHp: finalCurrentHp,
|
||||||
maxHp: finalMaxHp,
|
maxHp: finalMaxHp,
|
||||||
hpFormula: (participant.type === 'monster' || participant.type === 'npc') ? (hpFormula.trim() || null) : null,
|
hpFormula: (participant.type === 'monster' || participant.type === 'npc') ? (hpFormula.trim() || null) : null,
|
||||||
|
ac: ac !== '' ? parseInt(ac, 10) : null,
|
||||||
type: participant.type === 'monster' || participant.type === 'npc' ? (asNpc ? 'npc' : 'monster') : participant.type,
|
type: participant.type === 'monster' || participant.type === 'npc' ? (asNpc ? 'npc' : 'monster') : participant.type,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -618,15 +620,27 @@ function EditParticipantModal({ participant, onClose, onSave }) {
|
|||||||
className="mt-1 block w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
|
className="mt-1 block w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="flex gap-4">
|
||||||
<label htmlFor="edit-initiative" className="block text-sm font-medium text-stone-300">Initiative</label>
|
<div className="flex-1">
|
||||||
<input
|
<label htmlFor="edit-initiative" className="block text-sm font-medium text-stone-300">Initiative</label>
|
||||||
type="number"
|
<input
|
||||||
id="edit-initiative"
|
type="number"
|
||||||
value={initiative}
|
id="edit-initiative"
|
||||||
onChange={(e) => setInitiative(e.target.value)}
|
value={initiative}
|
||||||
className="mt-1 block w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
|
onChange={(e) => setInitiative(e.target.value)}
|
||||||
/>
|
className="mt-1 block w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="block text-sm font-medium text-stone-300">AC</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={ac}
|
||||||
|
onChange={(e) => setAc(e.target.value)}
|
||||||
|
placeholder="optional"
|
||||||
|
className="mt-1 block w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
@@ -656,7 +670,7 @@ function EditParticipantModal({ participant, onClose, onSave }) {
|
|||||||
type="text"
|
type="text"
|
||||||
value={hpFormula}
|
value={hpFormula}
|
||||||
onChange={(e) => setHpFormula(e.target.value)}
|
onChange={(e) => setHpFormula(e.target.value)}
|
||||||
placeholder="2d6+9"
|
placeholder="e.g. 2d6+9"
|
||||||
className="flex-1 px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
|
className="flex-1 px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@@ -717,6 +731,7 @@ function CharacterManager({ campaignId, campaignCharacters }) {
|
|||||||
const [characterName, setCharacterName] = useState('');
|
const [characterName, setCharacterName] = useState('');
|
||||||
const [defaultMaxHp, setDefaultMaxHp] = useState(DEFAULT_MAX_HP);
|
const [defaultMaxHp, setDefaultMaxHp] = useState(DEFAULT_MAX_HP);
|
||||||
const [defaultInitMod, setDefaultInitMod] = useState(DEFAULT_INIT_MOD);
|
const [defaultInitMod, setDefaultInitMod] = useState(DEFAULT_INIT_MOD);
|
||||||
|
const [defaultAc, setDefaultAc] = useState('');
|
||||||
const [editingCharacter, setEditingCharacter] = useState(null);
|
const [editingCharacter, setEditingCharacter] = useState(null);
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
const [itemToDelete, setItemToDelete] = useState(null);
|
const [itemToDelete, setItemToDelete] = useState(null);
|
||||||
@@ -762,7 +777,8 @@ function CharacterManager({ campaignId, campaignCharacters }) {
|
|||||||
id: generateId(),
|
id: generateId(),
|
||||||
name: characterName.trim(),
|
name: characterName.trim(),
|
||||||
defaultMaxHp: hp,
|
defaultMaxHp: hp,
|
||||||
defaultInitMod: initMod
|
defaultInitMod: initMod,
|
||||||
|
defaultAc: defaultAc !== '' ? parseInt(defaultAc, 10) : null
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -772,12 +788,13 @@ function CharacterManager({ campaignId, campaignCharacters }) {
|
|||||||
setCharacterName('');
|
setCharacterName('');
|
||||||
setDefaultMaxHp(DEFAULT_MAX_HP);
|
setDefaultMaxHp(DEFAULT_MAX_HP);
|
||||||
setDefaultInitMod(DEFAULT_INIT_MOD);
|
setDefaultInitMod(DEFAULT_INIT_MOD);
|
||||||
|
setDefaultAc('');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error adding character:", err);
|
console.error("Error adding character:", err);
|
||||||
showToast("Failed to add character. Please try again."); }
|
showToast("Failed to add character. Please try again."); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdateCharacter = async (characterId, newName, newDefaultMaxHp, newDefaultInitMod) => {
|
const handleUpdateCharacter = async (characterId, newName, newDefaultMaxHp, newDefaultInitMod, newDefaultAc) => {
|
||||||
if (!db || !newName.trim() || !campaignId) return;
|
if (!db || !newName.trim() || !campaignId) return;
|
||||||
|
|
||||||
const hp = parseInt(newDefaultMaxHp, 10);
|
const hp = parseInt(newDefaultMaxHp, 10);
|
||||||
@@ -794,7 +811,7 @@ function CharacterManager({ campaignId, campaignCharacters }) {
|
|||||||
|
|
||||||
const updatedCharacters = campaignCharacters.map(c =>
|
const updatedCharacters = campaignCharacters.map(c =>
|
||||||
c.id === characterId
|
c.id === characterId
|
||||||
? { ...c, name: newName.trim(), defaultMaxHp: hp, defaultInitMod: initMod }
|
? { ...c, name: newName.trim(), defaultMaxHp: hp, defaultInitMod: initMod, defaultAc: newDefaultAc !== undefined && newDefaultAc !== '' ? parseInt(newDefaultAc, 10) : null }
|
||||||
: c
|
: c
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -843,8 +860,8 @@ function CharacterManager({ campaignId, campaignCharacters }) {
|
|||||||
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<>
|
<>
|
||||||
<form onSubmit={(e) => { e.preventDefault(); handleAddCharacter(); }} className="grid grid-cols-1 sm:grid-cols-3 gap-2 mb-4 items-end">
|
<form onSubmit={(e) => { e.preventDefault(); handleAddCharacter(); }} className="grid grid-cols-2 sm:grid-cols-12 gap-2 mb-4 items-end">
|
||||||
<div className="sm:col-span-1">
|
<div className="col-span-2 sm:col-span-6">
|
||||||
<label htmlFor="characterName" className="block text-xs font-medium text-stone-400">
|
<label htmlFor="characterName" className="block text-xs font-medium text-stone-400">
|
||||||
Name
|
Name
|
||||||
</label>
|
</label>
|
||||||
@@ -857,19 +874,7 @@ function CharacterManager({ campaignId, campaignCharacters }) {
|
|||||||
className="w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
|
className="w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full sm:w-auto">
|
<div className="sm:col-span-2">
|
||||||
<label htmlFor="defaultMaxHp" className="block text-xs font-medium text-stone-400">
|
|
||||||
Default HP
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
id="defaultMaxHp"
|
|
||||||
value={defaultMaxHp}
|
|
||||||
onChange={(e) => setDefaultMaxHp(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="w-full sm:w-auto">
|
|
||||||
<label htmlFor="defaultInitMod" className="block text-xs font-medium text-stone-400">
|
<label htmlFor="defaultInitMod" className="block text-xs font-medium text-stone-400">
|
||||||
Init Mod
|
Init Mod
|
||||||
</label>
|
</label>
|
||||||
@@ -881,9 +886,33 @@ function CharacterManager({ campaignId, campaignCharacters }) {
|
|||||||
className="w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
|
className="w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<label htmlFor="defaultAc" className="block text-xs font-medium text-stone-400">
|
||||||
|
AC
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="defaultAc"
|
||||||
|
value={defaultAc}
|
||||||
|
onChange={(e) => setDefaultAc(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<label htmlFor="defaultMaxHp" className="block text-xs font-medium text-stone-400">
|
||||||
|
HP
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="defaultMaxHp"
|
||||||
|
value={defaultMaxHp}
|
||||||
|
onChange={(e) => setDefaultMaxHp(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="sm:col-span-3 sm:w-auto sm:justify-self-end px-4 py-2 text-sm font-medium text-white bg-amber-700 hover:bg-amber-800 rounded-md transition-colors flex items-center justify-center"
|
className="col-span-2 sm:col-span-12 sm:w-auto sm:justify-self-end px-4 py-2 text-sm font-medium text-white bg-amber-700 hover:bg-amber-800 rounded-md transition-colors flex items-center justify-center"
|
||||||
>
|
>
|
||||||
<PlusCircle size={18} className="mr-1" /> Add Character
|
<PlusCircle size={18} className="mr-1" /> Add Character
|
||||||
</button>
|
</button>
|
||||||
@@ -914,31 +943,51 @@ function CharacterManager({ campaignId, campaignCharacters }) {
|
|||||||
character.id,
|
character.id,
|
||||||
editingCharacter.name,
|
editingCharacter.name,
|
||||||
editingCharacter.defaultMaxHp,
|
editingCharacter.defaultMaxHp,
|
||||||
editingCharacter.defaultInitMod
|
editingCharacter.defaultInitMod,
|
||||||
|
editingCharacter.defaultAc
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
className="flex-grow flex flex-wrap gap-2 items-center"
|
className="flex-grow flex flex-wrap gap-2 items-center"
|
||||||
>
|
>
|
||||||
<input
|
<div className="flex flex-col flex-grow min-w-[100px]">
|
||||||
type="text"
|
<label className="text-[10px] text-stone-400 mb-0.5">Name</label>
|
||||||
value={editingCharacter.name}
|
<input
|
||||||
onChange={(e) => setEditingCharacter({ ...editingCharacter, name: e.target.value })}
|
type="text"
|
||||||
className="flex-grow min-w-[100px] px-2 py-1 bg-stone-700 border border-stone-600 rounded-md text-white"
|
value={editingCharacter.name}
|
||||||
/>
|
onChange={(e) => setEditingCharacter({ ...editingCharacter, name: e.target.value })}
|
||||||
<input
|
className="w-full px-2 py-1 bg-stone-700 border border-stone-600 rounded-md text-white"
|
||||||
type="number"
|
/>
|
||||||
value={editingCharacter.defaultMaxHp}
|
</div>
|
||||||
onChange={(e) => setEditingCharacter({ ...editingCharacter, defaultMaxHp: e.target.value })}
|
<div className="flex flex-col">
|
||||||
className="w-20 px-2 py-1 bg-stone-700 border border-stone-600 rounded-md text-white"
|
<label className="text-[10px] text-stone-400 mb-0.5">HP</label>
|
||||||
title="Default Max HP"
|
<input
|
||||||
/>
|
type="number"
|
||||||
<input
|
value={editingCharacter.defaultMaxHp}
|
||||||
type="number"
|
onChange={(e) => setEditingCharacter({ ...editingCharacter, defaultMaxHp: e.target.value })}
|
||||||
value={editingCharacter.defaultInitMod}
|
className="w-20 px-2 py-1 bg-stone-700 border border-stone-600 rounded-md text-white"
|
||||||
onChange={(e) => setEditingCharacter({ ...editingCharacter, defaultInitMod: e.target.value })}
|
title="Default Max HP"
|
||||||
className="w-20 px-2 py-1 bg-stone-700 border border-stone-600 rounded-md text-white"
|
/>
|
||||||
title="Default Init Mod"
|
</div>
|
||||||
/>
|
<div className="flex flex-col">
|
||||||
|
<label className="text-[10px] text-stone-400 mb-0.5">Init</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={editingCharacter.defaultInitMod}
|
||||||
|
onChange={(e) => setEditingCharacter({ ...editingCharacter, defaultInitMod: e.target.value })}
|
||||||
|
className="w-20 px-2 py-1 bg-stone-700 border border-stone-600 rounded-md text-white"
|
||||||
|
title="Default Init Mod"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<label className="text-[10px] text-stone-400 mb-0.5">AC</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={editingCharacter.defaultAc ?? ''}
|
||||||
|
onChange={(e) => setEditingCharacter({ ...editingCharacter, defaultAc: e.target.value })}
|
||||||
|
className="w-16 px-2 py-1 bg-stone-700 border border-stone-600 rounded-md text-white"
|
||||||
|
title="AC"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<button type="submit" className="p-1 text-green-400 hover:text-green-300">
|
<button type="submit" className="p-1 text-green-400 hover:text-green-300">
|
||||||
<Save size={18} />
|
<Save size={18} />
|
||||||
</button>
|
</button>
|
||||||
@@ -955,7 +1004,7 @@ function CharacterManager({ campaignId, campaignCharacters }) {
|
|||||||
<span className="text-stone-100">
|
<span className="text-stone-100">
|
||||||
{character.name}{' '}
|
{character.name}{' '}
|
||||||
<span className="text-xs text-stone-400">
|
<span className="text-xs text-stone-400">
|
||||||
(HP: {character.defaultMaxHp || 'N/A'}, Init Mod: {formatInitMod(character.defaultInitMod)})
|
(HP: {character.defaultMaxHp || 'N/A'}, Init Mod: {formatInitMod(character.defaultInitMod)}{character.defaultAc != null ? `, AC: ${character.defaultAc}` : ''})
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<div className="flex space-x-2">
|
<div className="flex space-x-2">
|
||||||
@@ -964,7 +1013,8 @@ function CharacterManager({ campaignId, campaignCharacters }) {
|
|||||||
id: character.id,
|
id: character.id,
|
||||||
name: character.name,
|
name: character.name,
|
||||||
defaultMaxHp: character.defaultMaxHp || DEFAULT_MAX_HP,
|
defaultMaxHp: character.defaultMaxHp || DEFAULT_MAX_HP,
|
||||||
defaultInitMod: character.defaultInitMod || DEFAULT_INIT_MOD
|
defaultInitMod: character.defaultInitMod || DEFAULT_INIT_MOD,
|
||||||
|
defaultAc: character.defaultAc ?? ''
|
||||||
})}
|
})}
|
||||||
className="p-1 rounded transition-colors text-yellow-400 hover:text-yellow-300 bg-stone-700 hover:bg-stone-600"
|
className="p-1 rounded transition-colors text-yellow-400 hover:text-yellow-300 bg-stone-700 hover:bg-stone-600"
|
||||||
aria-label="Edit character"
|
aria-label="Edit character"
|
||||||
@@ -1013,6 +1063,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
|||||||
const [maxHp, setMaxHp] = useState('');
|
const [maxHp, setMaxHp] = useState('');
|
||||||
const [hpFormula, setHpFormula] = useState('');
|
const [hpFormula, setHpFormula] = useState('');
|
||||||
const [manualInitiative, setManualInitiative] = useState('');
|
const [manualInitiative, setManualInitiative] = useState('');
|
||||||
|
const [monsterAc, setMonsterAc] = useState('');
|
||||||
const [asNpc, setAsNpc] = useState(false);
|
const [asNpc, setAsNpc] = useState(false);
|
||||||
const [editingParticipant, setEditingParticipant] = useState(null);
|
const [editingParticipant, setEditingParticipant] = useState(null);
|
||||||
const [hpChangeValues, setHpChangeValues] = useState({});
|
const [hpChangeValues, setHpChangeValues] = useState({});
|
||||||
@@ -1095,6 +1146,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
|||||||
maxHp: currentMaxHp,
|
maxHp: currentMaxHp,
|
||||||
currentHp: currentMaxHp,
|
currentHp: currentMaxHp,
|
||||||
hpFormula: participantType === 'monster' ? (hpFormula.trim() || null) : null,
|
hpFormula: participantType === 'monster' ? (hpFormula.trim() || null) : null,
|
||||||
|
ac: participantType === 'monster' && monsterAc !== '' ? parseInt(monsterAc, 10) : (participantType === 'character' ? (campaignCharacters.find(c => c.id === selectedCharacterId)?.defaultAc ?? null) : null),
|
||||||
conditions: [],
|
conditions: [],
|
||||||
isActive: true,
|
isActive: true,
|
||||||
status: 'conscious',
|
status: 'conscious',
|
||||||
@@ -1120,6 +1172,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
|||||||
setHpFormula('');
|
setHpFormula('');
|
||||||
setSelectedCharacterId('');
|
setSelectedCharacterId('');
|
||||||
setMonsterInitMod(MONSTER_DEFAULT_INIT_MOD);
|
setMonsterInitMod(MONSTER_DEFAULT_INIT_MOD);
|
||||||
|
setMonsterAc('');
|
||||||
setAsNpc(false);
|
setAsNpc(false);
|
||||||
setManualInitiative('');
|
setManualInitiative('');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1411,7 +1464,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
|||||||
className="mt-1 w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
|
className="mt-1 w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="md:col-span-3">
|
<div className="md:col-span-2">
|
||||||
<label htmlFor="monsterInitMod" className="block text-sm font-medium text-stone-300">
|
<label htmlFor="monsterInitMod" className="block text-sm font-medium text-stone-300">
|
||||||
Init Mod
|
Init Mod
|
||||||
</label>
|
</label>
|
||||||
@@ -1423,7 +1476,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
|||||||
className="mt-1 w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
|
className="mt-1 w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="md:col-span-3">
|
<div className="md:col-span-2">
|
||||||
<label htmlFor="manualInitiative" className="block text-sm font-medium text-stone-300">
|
<label htmlFor="manualInitiative" className="block text-sm font-medium text-stone-300">
|
||||||
Initiative <span className="text-xs text-stone-400">(blank=roll)</span>
|
Initiative <span className="text-xs text-stone-400">(blank=roll)</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -1436,7 +1489,17 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
|||||||
className="mt-1 w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
|
className="mt-1 w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="md:col-span-3">
|
<div className="md:col-span-2">
|
||||||
|
<label htmlFor="monsterAc" className="block text-sm font-medium text-stone-300">AC</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="monsterAc"
|
||||||
|
value={monsterAc}
|
||||||
|
onChange={(e) => setMonsterAc(e.target.value)}
|
||||||
|
className="mt-1 w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2">
|
||||||
<label htmlFor="monsterMaxHp" className="block text-sm font-medium text-stone-300">
|
<label htmlFor="monsterMaxHp" className="block text-sm font-medium text-stone-300">
|
||||||
Max HP
|
Max HP
|
||||||
</label>
|
</label>
|
||||||
@@ -1448,16 +1511,16 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
|||||||
className="mt-1 w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
|
className="mt-1 w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="md:col-span-3">
|
<div className="md:col-span-4">
|
||||||
<label htmlFor="monsterHpFormula" className="block text-sm font-medium text-stone-300">
|
<label htmlFor="monsterHpFormula" className="block text-sm font-medium text-stone-300">
|
||||||
HP Formula (e.g. 2d6+9)
|
HP Formula
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="monsterHpFormula"
|
id="monsterHpFormula"
|
||||||
value={hpFormula}
|
value={hpFormula}
|
||||||
onChange={(e) => setHpFormula(e.target.value)}
|
onChange={(e) => setHpFormula(e.target.value)}
|
||||||
placeholder="2d6+9"
|
placeholder="e.g. 2d6+9"
|
||||||
className="mt-1 w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
|
className="mt-1 w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1579,6 +1642,12 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
|||||||
<p className="font-semibold text-lg text-white">
|
<p className="font-semibold text-lg text-white">
|
||||||
{isZeroHp && <span className="mr-2">☠️</span>}
|
{isZeroHp && <span className="mr-2">☠️</span>}
|
||||||
{p.name} <span className="text-xs">({participantDisplayType})</span>
|
{p.name} <span className="text-xs">({participantDisplayType})</span>
|
||||||
|
{p.ac != null && (
|
||||||
|
<span className="ml-2 inline-flex items-baseline gap-1 px-2 py-0.5 bg-sky-900 border border-sky-500 rounded">
|
||||||
|
<span className="text-[10px] text-sky-300 font-semibold tracking-wide">AC</span>
|
||||||
|
<span className="text-sky-100 text-lg font-bold leading-none">{p.ac}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{isCurrentTurn && !encounter.isPaused && (
|
{isCurrentTurn && !encounter.isPaused && (
|
||||||
<span className="ml-2 px-2 py-0.5 bg-yellow-400 text-black text-xs font-bold rounded-full inline-flex items-center">
|
<span className="ml-2 px-2 py-0.5 bg-yellow-400 text-black text-xs font-bold rounded-full inline-flex items-center">
|
||||||
<Zap size={12} className="mr-1" /> CURRENT
|
<Zap size={12} className="mr-1" /> CURRENT
|
||||||
@@ -1604,7 +1673,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
|||||||
onFocus={(e) => e.target.select()}
|
onFocus={(e) => e.target.select()}
|
||||||
onBlur={(e) => { if (e.target.value !== String(p.initiative)) handleInlineInitiative(p.id, e.target.value); }}
|
onBlur={(e) => { if (e.target.value !== String(p.initiative)) handleInlineInitiative(p.id, e.target.value); }}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur(); }}
|
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur(); }}
|
||||||
className="w-10 px-1 py-0.5 bg-stone-800 border border-stone-700 rounded-md shadow-sm text-white text-sm focus:outline-none focus:ring-1 focus:ring-amber-600 focus:border-amber-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
className="w-8 px-1 py-0.5 bg-stone-800 border border-stone-700 rounded-md shadow-sm text-white text-sm text-center focus:outline-none focus:ring-1 focus:ring-amber-600 focus:border-amber-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
aria-label={`Initiative for ${p.name}`}
|
aria-label={`Initiative for ${p.name}`}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
@@ -2420,6 +2489,53 @@ function AdminView({ userId }) {
|
|||||||
);
|
);
|
||||||
const { data: initialActiveInfo } = useFirestoreDocument(getPath.activeDisplay());
|
const { data: initialActiveInfo } = useFirestoreDocument(getPath.activeDisplay());
|
||||||
|
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
const [wakeLockEnabled, setWakeLockEnabled] = useState(() => {
|
||||||
|
try { return localStorage.getItem('ttrpg.wakeLock') === 'true'; }
|
||||||
|
catch { return false; }
|
||||||
|
});
|
||||||
|
const wakeLockRef = useRef(null);
|
||||||
|
|
||||||
|
const toggleFullscreen = () => {
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
document.documentElement.requestFullscreen();
|
||||||
|
} else {
|
||||||
|
document.exitFullscreen();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onFsChange = () => setIsFullscreen(!!document.fullscreenElement);
|
||||||
|
document.addEventListener('fullscreenchange', onFsChange);
|
||||||
|
return () => document.removeEventListener('fullscreenchange', onFsChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!wakeLockEnabled) {
|
||||||
|
wakeLockRef.current?.release();
|
||||||
|
wakeLockRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const acquire = async () => {
|
||||||
|
try { wakeLockRef.current = await navigator.wakeLock.request('screen'); }
|
||||||
|
catch (e) {
|
||||||
|
console.error('Wake lock failed:', e);
|
||||||
|
showToast('Prevent Sleep failed. Requires HTTPS or Chrome flag: chrome://flags/#unsafely-treat-insecure-origin-as-secure');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
acquire();
|
||||||
|
const onVisChange = () => { if (document.visibilityState === 'visible') acquire(); };
|
||||||
|
const onFsChange = () => { if (document.fullscreenElement) acquire(); };
|
||||||
|
document.addEventListener('visibilitychange', onVisChange);
|
||||||
|
document.addEventListener('fullscreenchange', onFsChange);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('visibilitychange', onVisChange);
|
||||||
|
document.removeEventListener('fullscreenchange', onFsChange);
|
||||||
|
wakeLockRef.current?.release();
|
||||||
|
wakeLockRef.current = null;
|
||||||
|
};
|
||||||
|
}, [wakeLockEnabled, showToast]);
|
||||||
|
|
||||||
const [campaignsWithDetails, setCampaignsWithDetails] = useState([]);
|
const [campaignsWithDetails, setCampaignsWithDetails] = useState([]);
|
||||||
const [draggedCampaignId, setDraggedCampaignId] = useState(null);
|
const [draggedCampaignId, setDraggedCampaignId] = useState(null);
|
||||||
const [selectedCampaignId, setSelectedCampaignId] = useState(null);
|
const [selectedCampaignId, setSelectedCampaignId] = useState(null);
|
||||||
@@ -2666,6 +2782,22 @@ function AdminView({ userId }) {
|
|||||||
>
|
>
|
||||||
<PlusCircle size={20} className="mr-2" /> Create Campaign
|
<PlusCircle size={20} className="mr-2" /> Create Campaign
|
||||||
</button>
|
</button>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setWakeLockEnabled(v => { localStorage.setItem('ttrpg.wakeLock', String(!v)); return !v; })}
|
||||||
|
title={wakeLockEnabled ? 'Allow sleep' : 'Prevent sleep'}
|
||||||
|
className={`p-2 rounded-lg transition-all ${wakeLockEnabled ? 'bg-amber-600 hover:bg-amber-700 text-white' : 'bg-stone-800 hover:bg-stone-700 text-stone-300 hover:text-white'}`}
|
||||||
|
>
|
||||||
|
{wakeLockEnabled ? <Coffee size={20} /> : <Moon size={20} />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={toggleFullscreen}
|
||||||
|
title={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
|
||||||
|
className="bg-stone-800 hover:bg-stone-700 text-stone-300 hover:text-white p-2 rounded-lg transition-all"
|
||||||
|
>
|
||||||
|
{isFullscreen ? <Minimize2 size={20} /> : <Maximize2 size={20} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!campaignsCollapsed && (
|
{!campaignsCollapsed && (
|
||||||
@@ -2894,7 +3026,10 @@ function DisplayView() {
|
|||||||
const [campaignBackgroundUrl, setCampaignBackgroundUrl] = useState('');
|
const [campaignBackgroundUrl, setCampaignBackgroundUrl] = useState('');
|
||||||
const [isPlayerDisplayActive, setIsPlayerDisplayActive] = useState(false);
|
const [isPlayerDisplayActive, setIsPlayerDisplayActive] = useState(false);
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
const [wakeLockEnabled, setWakeLockEnabled] = useState(false);
|
const [wakeLockEnabled, setWakeLockEnabled] = useState(() => {
|
||||||
|
try { return localStorage.getItem('ttrpg.wakeLock') === 'true'; }
|
||||||
|
catch { return false; }
|
||||||
|
});
|
||||||
const [displayParticipants, setDisplayParticipants] = useState([]);
|
const [displayParticipants, setDisplayParticipants] = useState([]);
|
||||||
const wakeLockRef = useRef(null);
|
const wakeLockRef = useRef(null);
|
||||||
const currentParticipantRef = useRef(null);
|
const currentParticipantRef = useRef(null);
|
||||||
@@ -2960,8 +3095,12 @@ function DisplayView() {
|
|||||||
// Re-acquire after tab becomes visible again (browser auto-releases on hide)
|
// Re-acquire after tab becomes visible again (browser auto-releases on hide)
|
||||||
const onVisChange = () => { if (document.visibilityState === 'visible') acquire(); };
|
const onVisChange = () => { if (document.visibilityState === 'visible') acquire(); };
|
||||||
document.addEventListener('visibilitychange', onVisChange);
|
document.addEventListener('visibilitychange', onVisChange);
|
||||||
|
// Re-acquire on fullscreen change (Android discards wakeLock on screen off)
|
||||||
|
const onFsChange = () => { if (document.fullscreenElement) acquire(); };
|
||||||
|
document.addEventListener('fullscreenchange', onFsChange);
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('visibilitychange', onVisChange);
|
document.removeEventListener('visibilitychange', onVisChange);
|
||||||
|
document.removeEventListener('fullscreenchange', onFsChange);
|
||||||
wakeLockRef.current?.release();
|
wakeLockRef.current?.release();
|
||||||
wakeLockRef.current = null;
|
wakeLockRef.current = null;
|
||||||
};
|
};
|
||||||
@@ -3052,6 +3191,22 @@ function DisplayView() {
|
|||||||
if (!isPlayerDisplayActive || !activeEncounterData) {
|
if (!isPlayerDisplayActive || !activeEncounterData) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-black text-stone-400 flex flex-col items-center justify-center p-4 text-center">
|
<div className="min-h-screen bg-black text-stone-400 flex flex-col items-center justify-center p-4 text-center">
|
||||||
|
<div className="fixed top-3 right-3 z-50 flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setWakeLockEnabled(v => { localStorage.setItem('ttrpg.wakeLock', String(!v)); return !v; })}
|
||||||
|
title={wakeLockEnabled ? 'Allow sleep' : 'Prevent sleep'}
|
||||||
|
className={`p-2 rounded-lg transition-all ${wakeLockEnabled ? 'bg-amber-600 hover:bg-amber-700 text-white' : 'bg-stone-800 bg-opacity-80 hover:bg-opacity-100 text-stone-300 hover:text-white'}`}
|
||||||
|
>
|
||||||
|
{wakeLockEnabled ? <Coffee size={20} /> : <Moon size={20} />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={toggleFullscreen}
|
||||||
|
title={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
|
||||||
|
className="bg-stone-800 bg-opacity-80 hover:bg-opacity-100 text-stone-300 hover:text-white p-2 rounded-lg transition-all"
|
||||||
|
>
|
||||||
|
{isFullscreen ? <Minimize2 size={20} /> : <Maximize2 size={20} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<EyeOff size={64} className="mb-4 text-stone-500" />
|
<EyeOff size={64} className="mb-4 text-stone-500" />
|
||||||
<h2 className="text-3xl font-semibold font-cinzel tracking-wide">Game Session Paused</h2>
|
<h2 className="text-3xl font-semibold font-cinzel tracking-wide">Game Session Paused</h2>
|
||||||
<p className="text-xl mt-2">The Dungeon Master has not activated an encounter for display.</p>
|
<p className="text-xl mt-2">The Dungeon Master has not activated an encounter for display.</p>
|
||||||
@@ -3084,7 +3239,7 @@ function DisplayView() {
|
|||||||
>
|
>
|
||||||
<div className="fixed top-3 right-3 z-50 flex gap-2">
|
<div className="fixed top-3 right-3 z-50 flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setWakeLockEnabled(v => !v)}
|
onClick={() => setWakeLockEnabled(v => { localStorage.setItem('ttrpg.wakeLock', String(!v)); return !v; })}
|
||||||
title={wakeLockEnabled ? 'Allow sleep' : 'Prevent sleep'}
|
title={wakeLockEnabled ? 'Allow sleep' : 'Prevent sleep'}
|
||||||
className={`p-2 rounded-lg transition-all ${wakeLockEnabled ? 'bg-amber-600 hover:bg-amber-700 text-white' : 'bg-stone-800 bg-opacity-80 hover:bg-opacity-100 text-stone-300 hover:text-white'}`}
|
className={`p-2 rounded-lg transition-all ${wakeLockEnabled ? 'bg-amber-600 hover:bg-amber-700 text-white' : 'bg-stone-800 bg-opacity-80 hover:bg-opacity-100 text-stone-300 hover:text-white'}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ describe('Campaign -> Firebase', () => {
|
|||||||
|
|
||||||
// CharacterManager form
|
// CharacterManager form
|
||||||
fireEvent.change(screen.getByPlaceholderText('Character name'), { target: { value: 'Brog' } });
|
fireEvent.change(screen.getByPlaceholderText('Character name'), { target: { value: 'Brog' } });
|
||||||
fireEvent.change(screen.getByLabelText(/Default HP/i), { target: { value: '25' } });
|
fireEvent.change(screen.getByLabelText(/^HP$/i), { target: { value: '25' } });
|
||||||
fireEvent.change(screen.getByLabelText(/Init Mod/i), { target: { value: '3' } });
|
fireEvent.change(screen.getByLabelText(/Init Mod/i), { target: { value: '3' } });
|
||||||
fireEvent.click(screen.getByRole('button', { name: /Add Character/i }));
|
fireEvent.click(screen.getByRole('button', { name: /Add Character/i }));
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ async function addCharacterToEncounter(name = 'Hero', hp = 10) {
|
|||||||
await selectCampaignByName(`DS-${name}`);
|
await selectCampaignByName(`DS-${name}`);
|
||||||
|
|
||||||
fireEvent.change(screen.getByPlaceholderText('Character name'), { target: { value: name } });
|
fireEvent.change(screen.getByPlaceholderText('Character name'), { target: { value: name } });
|
||||||
fireEvent.change(screen.getByLabelText(/Default HP/i), { target: { value: String(hp) } });
|
fireEvent.change(screen.getByLabelText(/^HP$/i), { target: { value: String(hp) } });
|
||||||
fireEvent.click(screen.getByRole('button', { name: /Add Character/i }));
|
fireEvent.click(screen.getByRole('button', { name: /Add Character/i }));
|
||||||
await waitFor(() => findCalls('updateDoc', '/campaigns/').find(c => c.data.players?.length === 1));
|
await waitFor(() => findCalls('updateDoc', '/campaigns/').find(c => c.data.players?.length === 1));
|
||||||
const charId = findCalls('updateDoc', '/campaigns/').find(c => c.data.players?.length === 1).data.players[0].id;
|
const charId = findCalls('updateDoc', '/campaigns/').find(c => c.data.players?.length === 1).data.players[0].id;
|
||||||
|
|||||||
Reference in New Issue
Block a user