feat: integrate initiative tracker into campaign view

Wires up InitiativeTracker and CombatStartModal into CampaignView with socket event handlers, a DM-only Combat button, two-column layout with combat sidebar, and responsive CSS.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Aaron Wood 2026-04-11 15:48:41 -04:00
parent 65c914e3e0
commit 247379ba57
2 changed files with 113 additions and 17 deletions

View file

@ -165,3 +165,54 @@
font-style: italic; font-style: italic;
grid-column: 1 / -1; grid-column: 1 / -1;
} }
/* ── Combat layout ── */
.content {
display: block;
}
.content.withCombat {
display: flex;
gap: 0;
align-items: flex-start;
}
.combatSidebar {
width: 190px;
flex-shrink: 0;
border-right: 1px solid rgba(var(--gold-rgb), 0.15);
padding-right: 0.5rem;
margin-right: 0.75rem;
min-height: 300px;
position: sticky;
top: 0;
}
.content.withCombat .grid {
flex: 1;
min-width: 0;
}
.addBtnActive {
background: rgba(var(--gold-rgb), 0.15) !important;
border: 1px solid rgba(var(--gold-rgb), 0.5) !important;
color: var(--gold) !important;
}
@media (max-width: 768px) {
.content.withCombat {
flex-direction: column;
}
.combatSidebar {
width: 100%;
border-right: none;
border-bottom: 1px solid rgba(var(--gold-rgb), 0.15);
padding-right: 0;
margin-right: 0;
padding-bottom: 0.5rem;
margin-bottom: 0.75rem;
position: static;
}
}

View file

@ -18,7 +18,9 @@ import {
undoRoll, undoRoll,
} from "../api"; } from "../api";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import type { Character, Gear, Talent, GameItem, RollResult } from "../types"; import type { Character, Gear, Talent, GameItem, RollResult, CombatState } from "../types.js";
import InitiativeTracker from "../components/InitiativeTracker.js";
import CombatStartModal from "../components/CombatStartModal.js";
import CharacterCard from "../components/CharacterCard"; import CharacterCard from "../components/CharacterCard";
import CharacterDetail from "../components/CharacterDetail"; import CharacterDetail from "../components/CharacterDetail";
import RollLog from "../components/RollLog"; import RollLog from "../components/RollLog";
@ -54,6 +56,8 @@ export default function CampaignView() {
const pendingRollRef = useRef<RollResult | null>(null); const pendingRollRef = useRef<RollResult | null>(null);
const [atmosphere, setAtmosphere] = useState<AtmosphereState>(defaultAtmosphere); const [atmosphere, setAtmosphere] = useState<AtmosphereState>(defaultAtmosphere);
const [focusSpells, setFocusSpells] = useState<Map<number, string>>(new Map()); const [focusSpells, setFocusSpells] = useState<Map<number, string>>(new Map());
const [combats, setCombats] = useState<CombatState[]>([]);
const [showCombatStart, setShowCombatStart] = useState(false);
function handleAtmosphereChange(next: AtmosphereState) { function handleAtmosphereChange(next: AtmosphereState) {
setAtmosphere(next); setAtmosphere(next);
@ -70,6 +74,7 @@ export default function CampaignView() {
if (c) setCampaignName(c.name); if (c) setCampaignName(c.name);
}); });
socket.emit("join-campaign", String(campaignId)); socket.emit("join-campaign", String(campaignId));
socket.emit("initiative:request-state", campaignId);
return () => { return () => {
socket.emit("leave-campaign", String(campaignId)); socket.emit("leave-campaign", String(campaignId));
}; };
@ -256,6 +261,14 @@ export default function CampaignView() {
void characterId; void characterId;
} }
function onInitiativeState(data: CombatState[]) {
setCombats(data);
}
function onInitiativeUpdated(data: CombatState[]) {
setCombats(data);
}
socket.on("character:created", onCharacterCreated); socket.on("character:created", onCharacterCreated);
socket.on("character:updated", onCharacterUpdated); socket.on("character:updated", onCharacterUpdated);
socket.on("character:deleted", onCharacterDeleted); socket.on("character:deleted", onCharacterDeleted);
@ -268,6 +281,8 @@ export default function CampaignView() {
socket.on("atmosphere:update", onAtmosphereUpdate); socket.on("atmosphere:update", onAtmosphereUpdate);
socket.on("roll:undone", onRollUndone); socket.on("roll:undone", onRollUndone);
socket.on("character:rested", onCharacterRested); socket.on("character:rested", onCharacterRested);
socket.on("initiative:state", onInitiativeState);
socket.on("initiative:updated", onInitiativeUpdated);
return () => { return () => {
socket.off("character:created", onCharacterCreated); socket.off("character:created", onCharacterCreated);
@ -282,6 +297,8 @@ export default function CampaignView() {
socket.off("atmosphere:update", onAtmosphereUpdate); socket.off("atmosphere:update", onAtmosphereUpdate);
socket.off("roll:undone", onRollUndone); socket.off("roll:undone", onRollUndone);
socket.off("character:rested", onCharacterRested); socket.off("character:rested", onCharacterRested);
socket.off("initiative:state", onInitiativeState);
socket.off("initiative:updated", onInitiativeUpdated);
}; };
}, []); }, []);
@ -387,6 +404,14 @@ export default function CampaignView() {
onAtmosphereChange={handleAtmosphereChange} onAtmosphereChange={handleAtmosphereChange}
/> />
)} )}
{role === "dm" && (
<button
className={`${styles.addBtn} ${combats.length > 0 ? styles.addBtnActive : ""}`}
onClick={() => setShowCombatStart(true)}
>
Combat
</button>
)}
{role === "dm" && ( {role === "dm" && (
<button className={styles.addBtn} onClick={handleInvite}> <button className={styles.addBtn} onClick={handleInvite}>
Invite Player Invite Player
@ -401,6 +426,18 @@ export default function CampaignView() {
</div> </div>
</div> </div>
<div className={`${styles.content} ${combats.length > 0 ? styles.withCombat : ""}`}>
{combats.length > 0 && (
<div className={styles.combatSidebar}>
<InitiativeTracker
combat={combats[0]}
characters={characters}
isDM={role === "dm"}
currentUserId={user?.userId ?? null}
campaignId={campaignId}
/>
</div>
)}
<div className={styles.grid}> <div className={styles.grid}>
{characters.length === 0 && ( {characters.length === 0 && (
<p className={styles.empty}> <p className={styles.empty}>
@ -419,6 +456,7 @@ export default function CampaignView() {
/> />
))} ))}
</div> </div>
</div>
{selectedCharacter && ( {selectedCharacter && (
<CharacterDetail <CharacterDetail
@ -445,6 +483,13 @@ export default function CampaignView() {
onClose={() => setShowCreate(false)} onClose={() => setShowCreate(false)}
/> />
)} )}
{showCombatStart && (
<CombatStartModal
characters={characters}
campaignId={campaignId}
onClose={() => setShowCombatStart(false)}
/>
)}
</div> </div>
<RollLog campaignId={campaignId} rolls={rolls} freshIds={freshIds} onUndoRoll={handleUndoRoll} /> <RollLog campaignId={campaignId} rolls={rolls} freshIds={freshIds} onUndoRoll={handleUndoRoll} />
<DiceTray roll={diceRoll} onAnimationComplete={handleDiceComplete} /> <DiceTray roll={diceRoll} onAnimationComplete={handleDiceComplete} />