Generic (non-5e) ruleset mode + UI polish + combat switch fix #7

Merged
robert merged 25 commits from feat/generic-ruleset into main 2026-07-08 22:54:04 -04:00
5 changed files with 130 additions and 6 deletions
Showing only changes of commit 41c1e48874 - Show all commits
+20
View File
@@ -307,6 +307,26 @@ ttrpg-initiative-tracker/
* `docs/GLOSSARY.md` — domain terms (turn vs. round, etc.)
* `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.418.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
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.
+1 -1
View File
@@ -5,7 +5,7 @@ Backlog of bugs + long-term items. Milestones live in REWORK_PLAN.md.
## 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
+8
View File
@@ -63,6 +63,14 @@ git config core.hooksPath .githooks # enable pre-push test gate
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:
```bash
curl http://127.0.0.1:4001/health # -> {"ok":true}
+13 -3
View File
@@ -27,11 +27,15 @@ fi
# frontend: server storage, :3999
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 \
REACT_APP_STORAGE=server \
REACT_APP_BACKEND_URL=http://127.0.0.1:4001 \
REACT_APP_BACKEND_REALTIME_URL=ws://127.0.0.1:4001/ws \
REACT_APP_BACKEND_URL=http://${LAN_IP}:4001 \
REACT_APP_BACKEND_REALTIME_URL=ws://${LAN_IP}:4001/ws \
DANGEROUSLY_DISABLE_HOST_CHECK=true \
HOST=0.0.0.0 \
BROWSER=none PORT=3999 \
nohup npm start > tmp/fe.log 2>&1 &
echo $! > tmp/fe.pid
@@ -51,5 +55,11 @@ done
echo ""
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 "lan url : http://${LAN_IP}:3999 (phones/other devices on network)"
echo "logs : tmp/server.log tmp/fe.log"
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)"
+88 -2
View File
@@ -2489,6 +2489,53 @@ function AdminView({ userId }) {
);
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 [draggedCampaignId, setDraggedCampaignId] = useState(null);
const [selectedCampaignId, setSelectedCampaignId] = useState(null);
@@ -2735,6 +2782,22 @@ function AdminView({ userId }) {
>
<PlusCircle size={20} className="mr-2" /> Create Campaign
</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>
{!campaignsCollapsed && (
@@ -2963,7 +3026,10 @@ function DisplayView() {
const [campaignBackgroundUrl, setCampaignBackgroundUrl] = useState('');
const [isPlayerDisplayActive, setIsPlayerDisplayActive] = 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 wakeLockRef = useRef(null);
const currentParticipantRef = useRef(null);
@@ -3029,8 +3095,12 @@ function DisplayView() {
// Re-acquire after tab becomes visible again (browser auto-releases on hide)
const onVisChange = () => { if (document.visibilityState === 'visible') acquire(); };
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 () => {
document.removeEventListener('visibilitychange', onVisChange);
document.removeEventListener('fullscreenchange', onFsChange);
wakeLockRef.current?.release();
wakeLockRef.current = null;
};
@@ -3121,6 +3191,22 @@ function DisplayView() {
if (!isPlayerDisplayActive || !activeEncounterData) {
return (
<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" />
<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>
@@ -3153,7 +3239,7 @@ function DisplayView() {
>
<div className="fixed top-3 right-3 z-50 flex gap-2">
<button
onClick={() => setWakeLockEnabled(v => !v)}
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'}`}
>