feat(ui): replace all alert() with toast + info modal

Native alert() vanished instantly (browser focus loss when player display
window or devtools steals focus). Replaced all 23 alert() calls with two
in-app feedback paths via React context:

- ToastStack: bottom-right stack, 6s auto-dismiss + manual X. For transient
  failures (storage throws, catch blocks).
- InfoModal: yellow notice modal with OK button, persistent until dismissed.
  For validations (dup-add, empty/invalid fields).

UIFeedbackProvider wraps all 3 App branches (admin/player/logs).
useUIFeedback() hook in 6 components (CharacterManager, ParticipantManager,
InitiativeControls, EncounterManager, LogsView, AdminView).

244 tests green (App 84, shared 133, server 27).
This commit is contained in:
david raistrick
2026-07-04 22:27:32 -04:00
parent 7bcf01dcf9
commit 3423319b9b
2 changed files with 195 additions and 127 deletions
+81 -75
View File
@@ -2,140 +2,146 @@
Backlog of bugs + long-term items. Milestones live in REWORK_PLAN.md. Backlog of bugs + long-term items. Milestones live in REWORK_PLAN.md.
## Open bugs ## Open
### BUG-7: reorderParticipants not logged ### BUG: add participants duplication alert disappears fast
- FIXED — now returns `log: { message, undo }`. Handler calls logAction. - Duplicate-add throws alert but toast vanishes before user reads.
Undo snapshots participants[] + turnOrderIds + pointer. - Fix: extend toast duration OR require manual dismiss.
- Found second gap: deathSave also returned null log. Fixed (message + undo).
- Logging contract test added: turn.logging.test.js (all mutating ops logged,
no-ops null, undo payloads valid). Documents addParticipants +
updateParticipant gaps (null log, intentional).
## Open features ### FEAT: filter already-added from add-participant dropdown
- Character list dropdown shows characters already in encounter.
- Filter them out (or disable with strike-through).
### FEAT-2: upgrade app internal logs to be parseable ### FEAT-2: structured combat logs (parseable)
- Goal: combat logs in storage store enough structured state to run - Goal: logs in storage carry enough state to run skip/rotation analysis
skip/rotation analysis on ANY historic round --- not just replay stdout. on ANY historic round not just replay stdout.
- Current logs: `{timestamp, message, encounterName, undo}`. Parser must - Current: `{timestamp, message, encounterName, undo}`. Parser guesses
guess roster from message strings. Brittle. roster from message strings. Brittle.
- Upgrade: add structured fields at turn-state mutation log sites in - Upgrade: structured fields at turn-state mutation log sites in App.js
App.js (startEncounter, toggleActive, addParticipant, removeParticipant, (startEncounter, toggleActive, addParticipant, removeParticipant,
applyHpChange death/revive, togglePause, nextTurn): applyHpChange death/revive, togglePause, nextTurn):
``` ```
turnSnapshot: { round, currentTurnParticipantId, turnOrderIds, activeIds } turnSnapshot: { round, currentTurnParticipantId, turnOrderIds, activeIds }
``` ```
- Then `scripts/analyze-turns.js` ingests app logs directly (adapter fetch). - Then `scripts/analyze-turns.js` ingests app logs directly. Works on real
Works on real game sessions, any round, deterministic. game sessions, any round, deterministic.
### FEAT-M6: transactional undo
### FEAT-M6: Transactional undo (moved from REWORK_PLAN)
- Every mutating action writes event: `(type, payload, undo_payload, undone, ts)`. - Every mutating action writes event: `(type, payload, undo_payload, undone, ts)`.
- Undo = apply `undo_payload` in same SQLite tx, flip `undone`. Transactional, - Undo = apply `undo_payload` in same SQLite tx, flip `undone`. Transactional,
no stale clobber. no stale clobber.
- Replaces fragile `/logs` snapshot-write undo. - Replaces fragile `/logs` snapshot-write undo.
- Migration: keep old undo working for existing entries until cleared; new - Migration: keep old undo for existing entries; new format for new.
format for new entries.
- Related: BUG-7 (reorder no undo).
### Death saves: D&D 5e model (FIXED)
- FIXED — separate success/fail tracking. `deathSave(enc, id, type, n)`
type='success'|'fail'. Fields: `deathSaves`, `deathFails`, `isStable`,
`isDying`. 3 success=stable, 3 fail=dead. Returns `{patch, log, status}`.
Old single-counter broken (successes missing, treated saves as fails).
Old data lost (user OK — feature didn't work). UI: green ✓ row + red ✕ row.
### Custom conditions field (DONE)
- FIXED — per-campaign freeform conditions. Input field in picker → persists to
`campaigns/{id}.customConditions[]`. Merged with built-ins at render.
ParticipantManager subscribes campaign doc. DisplayView renders custom as
raw label. toggleCondition unchanged (any string id). Dedup case-insensitive.
Enter or button to add. maxLength 40.
- Conditions hardcoded list. No way for DM to add custom.
### Test integrated timeouts
- No per-test timeout. Tests hang on regression. Add jest timeout (<60s).
### add participants duplication throws a alert but it disappears fast
### add particopents list dropdown - remove ones already added
## Done (history) ## Done (history)
### Architecture: 1-list turn order model ### Architecture: 1-list turn order model (slot, never sort)
- Single source: turnOrderIds === participants.map(id). No re-sort after - Single source: turnOrderIds === participants.map(id). No re-sort after
startEncounter. nextTurn skips inactive (predicate), inactive stay in slot. startEncounter. nextTurn skips inactive (predicate), inactive stay in slot.
- Drag (reorder) = same-init tie-break only. Cross-init blocked. - Drag (reorder) = same-init tie-break only. Cross-init blocked.
- startEncounter sorts ALL participants by init once, then frozen. - startEncounter sorts ALL participants by init once, then frozen.
- addParticipant/updateParticipant slot by init (slotIndexForInit), preserve - addParticipant/updateParticipant slot by init (slotIndexForInit), preserve
drag order. Display renders participants[] directly (no sort). drag order. Display renders participants[] directly (no sort).
- Static guard test errs if `.sort(` reintroduced. - Static guard test errs if `.sort(` reintroduced outside allowlist.
- Design doc: docs/INITIATIVE_ORDERING.md.
### BUG-1: addParticipant + pause/resume corrupts turn rotation ### Single source of truth: combat logic
- All 15 App.js handlers delegate to @ttrpg/shared. ~498 lines inline dupes deleted.
- shared/turn.js = only place turn logic lives.
### Storage parity (firebase + server adapters)
- Neutral queryConstraints ({__type:'orderBy'|'limit'}) honored by both adapters.
- Shared contract test runs both identically. Memory adapter deleted; factory
throws on unknown mode.
- ws storage mode renamed to server (env var + adapter name).
### Logging contract
- Every mutating op logs message + undo payload. No-op = null log.
- Structural enforcement: per-op contract test (turn.logging.test.js) +
static source-scan guard (static.no-unlogged.test.js).
### Custom conditions per campaign (DONE)
- Freeform per-campaign conditions. Add applies to participant + persists to
campaign palette in one step. Badge render uses merged allConditions
(built-ins + custom). toggleCondition accepts any string. Dedup
case-insensitive. maxLength 40. Combat + replay tests prove arbitrary
string ids survive round-trip.
### Death saves: D&D 5e model (DONE)
- Separate success/fail tracking. deathSave(enc, id, type, n),
type='success'|'fail'. Fields: deathSaves, deathFails, isStable, isDying.
3 success=stable, 3 fail=dead. Returns {patch, log, status, isDying}.
Old single-counter broken (successes missing). Old data lost (feature
never worked in prod). UI: green ✓ + red ✕ rows, Stabilized/Dying badges.
### FEAT-3: initiative first-class entry (DONE)
- Initiative field at add-char, add-monster, edit participant.
- Inline edit wired. Tie-break = drag order.
### Test timeouts (DONE)
- jest.setTimeout(10000) in setupTests.js (CRA blocks config-level timeout).
### Warning = failure in tests (DONE)
- console.error/warn throw in test env.
### BUG-1: addParticipant + pause/resume corrupts rotation
- RESOLVED as side effect of BUG-2 fix. - RESOLVED as side effect of BUG-2 fix.
### BUG-2: addParticipant allows duplicate id ### BUG-2: addParticipant allows duplicate id
- FIXED (addParticipant throws on dup id). - FIXED (addParticipant throws on dup id).
### BUG-4: hide-player-HP breaks display view ### BUG-4: hide-player-HP breaks display view
- FIXED --- mock honors setDoc{merge}, all 5 activeDisplay sites use merge. - FIXED mock honors setDoc{merge}, all 5 activeDisplay sites use merge.
### BUG-5: mid-round addParticipant/revive corrupts rotation ### BUG-5: mid-round addParticipant/revive corrupts rotation
- FIXED --- slot-array + DRY advance core `nextActiveAfter`. - FIXED slot-array + DRY advance core nextActiveAfter.
### BUG-6: reorderParticipants doesn't update turnOrderIds ### BUG-6: reorderParticipants doesn't update turnOrderIds
- FIXED structurally by 1-list model. - FIXED structurally by 1-list model.
### BUG-7: reorderParticipants not logged
- FIXED — returns log:{message, undo}. Handler calls logAction. deathSave,
addParticipants, updateParticipant logging gaps also closed.
### BUG-8: server adapter has no reconnect ### BUG-8: server adapter has no reconnect
- FIXED --- onclose reconnects + re-subscribes existing paths. - FIXED onclose reconnects + re-subscribes existing paths.
### BUG-10: deact+reactivate same round double-acts participant ### BUG-10: deact+reactivate same round double-acts participant
- FIXED --- 1-list model keeps slot position on toggle. Reactivate does not - FIXED 1-list model keeps slot position on toggle. Reactivate does not
grant second turn. Test: turn.bug10.test.js. grant second turn. Test: turn.bug10.test.js.
### BUG-11: FE Combat.scenario test crashes ### BUG-11: FE Combat.scenario test crashes
- FIXED --- moved to shared/turn.combat.test.js, pure functions, 100 rounds. - FIXED moved to shared/turn.combat.test.js, pure functions, 100 rounds.
### BUG-12: campaign selection follows activeDisplay ### BUG-12: campaign selection follows activeDisplay
- FIXED. - FIXED.
### BUG-13: reorderParticipants crossing current pointer = ambiguous acted-semantics ### BUG-13: reorderParticipants crossing current pointer = ambiguous
- FIXED --- block cross-pointer reorder during active encounter (both dirs). - FIXED block cross-pointer reorder during active encounter (both dirs).
Full fix needs actedThisRound tracking. Pragmatic block prevents skip/double. Full fix needs actedThisRound tracking. Pragmatic block prevents skip/double.
Pre-combat: free reorder. Pre-combat: free reorder. Test: turn.bug13.test.js.
### BUG-14: addParticipant init-insertion breaks after drag-reorder ### BUG-14: addParticipant init-insertion breaks after drag-reorder
- FIXED --- slotIndexForInit scans current list (post-drag aware). - FIXED slotIndexForInit scans current list (post-drag aware).
### BUG-15: DisplayView re-sorts (drag order not preserved) ### BUG-15: DisplayView re-sorts (drag order not preserved)
- FIXED --- display renders participants[] directly. - FIXED display renders participants[] directly.
### BUG-16: subscribeCollection hook drops queryConstraints ### BUG-16: subscribeCollection hook drops queryConstraints
- FIXED --- neutral builders, both adapters honor orderBy/limit. - FIXED neutral builders, both adapters honor orderBy/limit.
### BUG-17: dead SDK imports in App.js ### BUG-17: dead SDK imports in App.js
- FIXED --- trimmed (auth + getFirestore + getStorage remain). - FIXED trimmed (auth + getFirestore + getStorage remain).
### BUG-18: stale comments reference deleted memory adapter ### BUG-18: stale comments reference deleted memory adapter
- FIXED. - FIXED.
### FEAT-1: Dead participants stay in turn order ### FEAT-1: Dead participants stay in turn order
- DONE --- applyHpChange no longer flips isActive on death. Dead stay in - DONE applyHpChange no longer flips isActive on death. Dead stay in
rotation, nextTurn visits them, PCs get death-save turn. rotation, nextTurn visits them, PCs get death-save turn.
### feat: add all characters to participants list
- DONE --- addParticipants bulk add wired (App.js:943).
### Warning = failure in tests
- DONE --- console.error/warn throw in test env.
### combat.scenario 100 rounds not turns ### combat.scenario 100 rounds not turns
- DONE --- loops by actual round-wrap count. - DONE loops by actual round-wrap count.
### FEAT-3: initiative first-class entry (add + edit) DONE ### feat: add all characters to participants list
- Current: only initMod at char-build. No initiative field at add-participant - DONE — addParticipants bulk add wired.
or edit. 3-step to set after other steps.
- Need: initiative field at add-char, add-monster, AND edit participant.
- Related: tie-break = drag order (current, works). Expose clearly.
+114 -52
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef, useMemo } from 'react'; import React, { useState, useEffect, useRef, useMemo, createContext, useContext, useCallback } from 'react';
import * as shared from '@ttrpg/shared'; import * as shared from '@ttrpg/shared';
import { initializeApp, getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken, getFirestore, orderBy, limit, getStorage, getStorageMode } from './storage'; import { initializeApp, getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken, getFirestore, orderBy, limit, getStorage, getStorageMode } from './storage';
import { import {
@@ -6,9 +6,78 @@ import {
UserCheck, UserX, HeartCrack, HeartPulse, Zap, EyeOff, ExternalLink, AlertTriangle, UserCheck, UserX, HeartCrack, HeartPulse, Zap, EyeOff, ExternalLink, AlertTriangle,
Play as PlayIcon, Pause as PauseIcon, SkipForward as SkipForwardIcon, Play as PlayIcon, Pause as PauseIcon, SkipForward as SkipForwardIcon,
StopCircle as StopCircleIcon, Users2, Dices, ChevronUp, ChevronDown, ScrollText, StopCircle as StopCircleIcon, Users2, Dices, ChevronUp, ChevronDown, ScrollText,
Maximize2, Minimize2, Moon, Coffee, Clock, ChevronRight Maximize2, Minimize2, Moon, Coffee, Clock, ChevronRight, CheckCircle2, X
} from 'lucide-react'; } from 'lucide-react';
// ----- UI feedback: toast (transient) + info modal (persistent) -----
const UIFeedbackContext = createContext(null);
const useUIFeedback = () => useContext(UIFeedbackContext);
function ToastStack({ toasts, onDismiss }) {
if (!toasts.length) return null;
return (
<div className="fixed bottom-4 right-4 z-[60] flex flex-col gap-2 max-w-sm">
{toasts.map(t => (
<div key={t.id} className="flex items-start gap-2 bg-stone-800 border border-red-600 text-stone-100 px-3 py-2 rounded-md shadow-lg">
<AlertTriangle size={16} className="text-red-400 flex-shrink-0 mt-0.5" />
<span className="text-sm flex-grow">{t.message}</span>
<button onClick={() => onDismiss(t.id)} className="text-stone-400 hover:text-stone-200 flex-shrink-0" aria-label="Dismiss">
<X size={14} />
</button>
</div>
))}
</div>
);
}
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="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" />
<h2 className="text-xl font-semibold text-yellow-300">Notice</h2>
</div>
<p className="text-stone-300 mb-6">{message}</p>
<div className="flex justify-end">
<button onClick={onClose} className="px-4 py-2 text-sm font-medium text-stone-900 bg-amber-400 hover:bg-amber-300 rounded-md transition-colors">
OK
</button>
</div>
</div>
</div>
);
}
function UIFeedbackProvider({ children }) {
const [toasts, setToasts] = useState([]);
const [info, setInfo] = useState(null);
const nextId = useRef(1);
const showToast = useCallback((message) => {
const id = nextId.current++;
setToasts(prev => [...prev, { id, message }]);
setTimeout(() => setToasts(prev => prev.filter(t => t.id !== id)), 6000);
}, []);
const showInfo = useCallback((message) => {
setInfo(message);
}, []);
const dismissToast = useCallback((id) => {
setToasts(prev => prev.filter(t => t.id !== id));
}, []);
return (
<UIFeedbackContext.Provider value={{ showToast, showInfo }}>
{children}
<ToastStack toasts={toasts} onDismiss={dismissToast} />
<InfoModal message={info} onClose={() => setInfo(null)} />
</UIFeedbackContext.Provider>
);
}
// Custom CSS for death animation (player view only) // Custom CSS for death animation (player view only)
const deathAnimationStyles = ` const deathAnimationStyles = `
@keyframes death-dissolve { @keyframes death-dissolve {
@@ -533,6 +602,7 @@ function EditParticipantModal({ participant, onClose, onSave }) {
// ============================================================================ // ============================================================================
function CharacterManager({ campaignId, campaignCharacters }) { function CharacterManager({ campaignId, campaignCharacters }) {
const { showToast, showInfo } = useUIFeedback();
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);
@@ -548,12 +618,10 @@ function CharacterManager({ campaignId, campaignCharacters }) {
const initMod = parseInt(defaultInitMod, 10); const initMod = parseInt(defaultInitMod, 10);
if (isNaN(hp) || hp <= 0) { if (isNaN(hp) || hp <= 0) {
alert("Please enter a valid positive number for Default Max HP."); showInfo("Please enter a valid positive number for Default Max HP."); return;
return;
} }
if (isNaN(initMod)) { if (isNaN(initMod)) {
alert("Please enter a valid number for Default Initiative Modifier."); showInfo("Please enter a valid number for Default Initiative Modifier."); return;
return;
} }
const newCharacter = { const newCharacter = {
@@ -572,8 +640,7 @@ function CharacterManager({ campaignId, campaignCharacters }) {
setDefaultInitMod(DEFAULT_INIT_MOD); setDefaultInitMod(DEFAULT_INIT_MOD);
} catch (err) { } catch (err) {
console.error("Error adding character:", err); console.error("Error adding character:", err);
alert("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) => {
@@ -583,13 +650,11 @@ function CharacterManager({ campaignId, campaignCharacters }) {
const initMod = parseInt(newDefaultInitMod, 10); const initMod = parseInt(newDefaultInitMod, 10);
if (isNaN(hp) || hp <= 0) { if (isNaN(hp) || hp <= 0) {
alert("Please enter a valid positive number for Default Max HP."); showInfo("Please enter a valid positive number for Default Max HP."); setEditingCharacter(null);
setEditingCharacter(null);
return; return;
} }
if (isNaN(initMod)) { if (isNaN(initMod)) {
alert("Please enter a valid number for Default Initiative Modifier."); showInfo("Please enter a valid number for Default Initiative Modifier."); setEditingCharacter(null);
setEditingCharacter(null);
return; return;
} }
@@ -604,8 +669,7 @@ function CharacterManager({ campaignId, campaignCharacters }) {
setEditingCharacter(null); setEditingCharacter(null);
} catch (err) { } catch (err) {
console.error("Error updating character:", err); console.error("Error updating character:", err);
alert("Failed to update character. Please try again."); showToast("Failed to update character. Please try again."); }
}
}; };
const requestDeleteCharacter = (characterId, charName) => { const requestDeleteCharacter = (characterId, charName) => {
@@ -622,8 +686,7 @@ function CharacterManager({ campaignId, campaignCharacters }) {
await storage.updateDoc(getPath.campaign(campaignId), { players: updatedCharacters }); await storage.updateDoc(getPath.campaign(campaignId), { players: updatedCharacters });
} catch (err) { } catch (err) {
console.error("Error deleting character:", err); console.error("Error deleting character:", err);
alert("Failed to delete character. Please try again."); showToast("Failed to delete character. Please try again."); }
}
setShowDeleteConfirm(false); setShowDeleteConfirm(false);
setItemToDelete(null); setItemToDelete(null);
@@ -798,6 +861,7 @@ function CharacterManager({ campaignId, campaignCharacters }) {
// ============================================================================ // ============================================================================
function ParticipantManager({ encounter, encounterPath, campaignCharacters, campaignId }) { function ParticipantManager({ encounter, encounterPath, campaignCharacters, campaignId }) {
const { showToast, showInfo } = useUIFeedback();
const [participantName, setParticipantName] = useState(''); const [participantName, setParticipantName] = useState('');
const [participantType, setParticipantType] = useState('monster'); const [participantType, setParticipantType] = useState('monster');
const [selectedCharacterId, setSelectedCharacterId] = useState(''); const [selectedCharacterId, setSelectedCharacterId] = useState('');
@@ -860,7 +924,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
return; return;
} }
if (participants.some(p => p.type === 'character' && p.originalCharacterId === selectedCharacterId)) { if (participants.some(p => p.type === 'character' && p.originalCharacterId === selectedCharacterId)) {
alert(`${character.name} is already in this encounter.`); showInfo(`${character.name} is already in this encounter.`);
return; return;
} }
nameToAdd = character.name; nameToAdd = character.name;
@@ -914,8 +978,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
setIsNpc(false); setIsNpc(false);
setManualInitiative(''); setManualInitiative('');
} catch (err) { } catch (err) {
alert("Failed to add participant. Please try again."); showToast("Failed to add participant. Please try again."); }
}
}; };
const handleAddAllCampaignCharacters = async () => { const handleAddAllCampaignCharacters = async () => {
@@ -950,8 +1013,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
}); });
if (newParticipants.length === 0) { if (newParticipants.length === 0) {
alert("All campaign characters are already in this encounter."); showInfo("All campaign characters are already in this encounter."); return;
return;
} }
try { try {
@@ -964,8 +1026,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
}); });
} }
} catch (err) { } catch (err) {
alert("Failed to add all characters. Please try again."); showToast("Failed to add all characters. Please try again."); }
}
}; };
const handleUpdateParticipant = async (updatedData) => { const handleUpdateParticipant = async (updatedData) => {
@@ -981,8 +1042,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
} }
setEditingParticipant(null); setEditingParticipant(null);
} catch (err) { } catch (err) {
alert("Failed to update participant. Please try again."); showToast("Failed to update participant. Please try again."); }
}
}; };
// Inline initiative edit (FEAT-3): blur/Enter commits. Reslots participant // Inline initiative edit (FEAT-3): blur/Enter commits. Reslots participant
@@ -1022,8 +1082,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
updates: log.undo, updates: log.undo,
}); });
} catch (err) { } catch (err) {
alert("Failed to delete participant. Please try again."); showToast("Failed to delete participant. Please try again."); }
}
setShowDeleteConfirm(false); setShowDeleteConfirm(false);
setItemToDelete(null); setItemToDelete(null);
}; };
@@ -1612,6 +1671,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
// ============================================================================ // ============================================================================
function InitiativeControls({ campaignId, encounter, encounterPath }) { function InitiativeControls({ campaignId, encounter, encounterPath }) {
const { showToast, showInfo } = useUIFeedback();
const [showEndConfirm, setShowEndConfirm] = useState(false); const [showEndConfirm, setShowEndConfirm] = useState(false);
const { data: activeDisplayData } = useFirestoreDocument(getPath.activeDisplay()); const { data: activeDisplayData } = useFirestoreDocument(getPath.activeDisplay());
const hidePlayerHp = activeDisplayData?.hidePlayerHp ?? true; const hidePlayerHp = activeDisplayData?.hidePlayerHp ?? true;
@@ -1637,8 +1697,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
const handleStartEncounter = async () => { const handleStartEncounter = async () => {
if (!db || !encounter.participants || encounter.participants.length === 0) { if (!db || !encounter.participants || encounter.participants.length === 0) {
alert("Add participants first."); showInfo("Add participants first."); return;
return;
} }
try { try {
@@ -1650,8 +1709,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
updates: log.undo, updates: log.undo,
}); });
} catch (err) { } catch (err) {
alert(err.message || "Failed to start encounter. Please try again."); showToast(err.message || "Failed to start encounter. Please try again."); }
}
}; };
const handleTogglePause = async () => { const handleTogglePause = async () => {
@@ -1682,8 +1740,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
} catch (err) { } catch (err) {
// nextTurn throws if no active participants — auto-end combat // nextTurn throws if no active participants — auto-end combat
if (err.message === 'Encounter not running.' || err.message === 'No active turn.') return; if (err.message === 'Encounter not running.' || err.message === 'No active turn.') return;
alert("No active participants left."); showInfo("No active participants left."); await storage.updateDoc(encounterPath, {
await storage.updateDoc(encounterPath, {
isStarted: false, isStarted: false,
isPaused: false, isPaused: false,
currentTurnParticipantId: null, currentTurnParticipantId: null,
@@ -1703,8 +1760,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
updates: log.undo, updates: log.undo,
}); });
} catch (err) { } catch (err) {
alert("Failed to end encounter. Please try again."); showToast("Failed to end encounter. Please try again."); }
}
setShowEndConfirm(false); setShowEndConfirm(false);
}; };
@@ -1805,6 +1861,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
// ============================================================================ // ============================================================================
function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharacters }) { function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharacters }) {
const { showToast, showInfo } = useUIFeedback();
const { data: encountersData, isLoading: isLoadingEncounters } = useFirestoreCollection( const { data: encountersData, isLoading: isLoadingEncounters } = useFirestoreCollection(
campaignId ? getPath.encounters(campaignId) : null campaignId ? getPath.encounters(campaignId) : null
); );
@@ -1871,8 +1928,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
setSelectedEncounterId(newEncounterId); setSelectedEncounterId(newEncounterId);
} catch (err) { } catch (err) {
console.error("Error creating encounter:", err); console.error("Error creating encounter:", err);
alert("Failed to create encounter. Please try again."); showToast("Failed to create encounter. Please try again."); }
}
}; };
const requestDeleteEncounter = (encounterId, encounterName) => { const requestDeleteEncounter = (encounterId, encounterName) => {
@@ -1897,8 +1953,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
} }
} catch (err) { } catch (err) {
console.error("Error deleting encounter:", err); console.error("Error deleting encounter:", err);
alert("Failed to delete encounter. Please try again."); showToast("Failed to delete encounter. Please try again."); }
}
setShowDeleteConfirm(false); setShowDeleteConfirm(false);
setItemToDelete(null); setItemToDelete(null);
@@ -2051,6 +2106,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
// ============================================================================ // ============================================================================
function AdminView({ userId }) { function AdminView({ userId }) {
const { showToast, showInfo } = useUIFeedback();
const { data: campaignsData, isLoading: isLoadingCampaigns, error: campaignsError } = useFirestoreCollection( const { data: campaignsData, isLoading: isLoadingCampaigns, error: campaignsError } = useFirestoreCollection(
getPath.campaigns() getPath.campaigns()
); );
@@ -2124,8 +2180,7 @@ function AdminView({ userId }) {
setSelectedCampaignId(newCampaignId); setSelectedCampaignId(newCampaignId);
} catch (err) { } catch (err) {
console.error("Error creating campaign:", err); console.error("Error creating campaign:", err);
alert("Failed to create campaign. Please try again."); showToast("Failed to create campaign. Please try again."); }
}
}; };
const requestDeleteCampaign = (campaignId, campaignName) => { const requestDeleteCampaign = (campaignId, campaignName) => {
@@ -2160,8 +2215,7 @@ function AdminView({ userId }) {
} }
} catch (err) { } catch (err) {
console.error("Error deleting campaign:", err); console.error("Error deleting campaign:", err);
alert("Failed to delete campaign. Please try again."); showToast("Failed to delete campaign. Please try again."); }
}
setShowDeleteConfirm(false); setShowDeleteConfirm(false);
setItemToDelete(null); setItemToDelete(null);
@@ -2619,6 +2673,7 @@ function DisplayView() {
// ============================================================================ // ============================================================================
function LogsView() { function LogsView() {
const { showToast } = useUIFeedback();
const { data: logs, isLoading } = useFirestoreCollection(getPath.logs(), LOG_QUERY); const { data: logs, isLoading } = useFirestoreCollection(getPath.logs(), LOG_QUERY);
const [showClearConfirm, setShowClearConfirm] = useState(false); const [showClearConfirm, setShowClearConfirm] = useState(false);
const [undoingId, setUndoingId] = useState(null); const [undoingId, setUndoingId] = useState(null);
@@ -2647,8 +2702,7 @@ function LogsView() {
await storage.updateDoc(`${getPath.logs()}/${entry.id}`, { undone: true }); await storage.updateDoc(`${getPath.logs()}/${entry.id}`, { undone: true });
} catch (err) { } catch (err) {
console.error('Error undoing action:', err); console.error('Error undoing action:', err);
alert('Failed to roll back. The encounter may have changed or no longer exists.'); showToast("Failed to roll back. The encounter may have changed or no longer exists."); }
}
setUndoingId(null); setUndoingId(null);
}; };
@@ -2825,19 +2879,26 @@ function App() {
if (isPlayerViewOnlyMode) { if (isPlayerViewOnlyMode) {
return ( return (
<div className="min-h-screen bg-stone-950 text-stone-100 font-garamond"> <UIFeedbackProvider>
{isAuthReady && <DisplayView />} <div className="min-h-screen bg-stone-950 text-stone-100 font-garamond">
{!isAuthReady && !error && <p>Authenticating for Player Display...</p>} {isAuthReady && <DisplayView />}
</div> {!isAuthReady && !error && <p>Authenticating for Player Display...</p>}
</div>
</UIFeedbackProvider>
); );
} }
if (isLogsMode) { if (isLogsMode) {
return isAuthReady ? <LogsView /> : <LoadingSpinner message="Authenticating..." />; return (
<UIFeedbackProvider>
{isAuthReady ? <LogsView /> : <LoadingSpinner message="Authenticating..." />}
</UIFeedbackProvider>
);
} }
return ( return (
<div className="min-h-screen bg-stone-950 text-stone-100 font-garamond"> <UIFeedbackProvider>
<div className="min-h-screen bg-stone-950 text-stone-100 font-garamond">
<header className="bg-stone-950 p-4 shadow-lg border-b border-amber-900"> <header className="bg-stone-950 p-4 shadow-lg border-b border-amber-900">
<div className="container mx-auto flex justify-between items-center"> <div className="container mx-auto flex justify-between items-center">
<h1 className="text-3xl font-bold text-amber-400 font-cinzel tracking-wide">TTRPG Initiative Tracker</h1> <h1 className="text-3xl font-bold text-amber-400 font-cinzel tracking-wide">TTRPG Initiative Tracker</h1>
@@ -2868,7 +2929,8 @@ function App() {
<footer className="bg-stone-950 p-4 text-center text-sm text-stone-400 mt-8"> <footer className="bg-stone-950 p-4 text-center text-sm text-stone-400 mt-8">
TTRPG Initiative Tracker {APP_VERSION} TTRPG Initiative Tracker {APP_VERSION}
</footer> </footer>
</div> </div>
</UIFeedbackProvider>
); );
} }