From 65c914e3e0f9bc1dde24b887d4fce33c3eab65ef Mon Sep 17 00:00:00 2001 From: Aaron Wood Date: Sat, 11 Apr 2026 15:46:11 -0400 Subject: [PATCH] feat: add CombatStartModal for DM combat setup --- .../components/CombatStartModal.module.css | 225 ++++++++++++++++++ client/src/components/CombatStartModal.tsx | 163 +++++++++++++ 2 files changed, 388 insertions(+) create mode 100644 client/src/components/CombatStartModal.module.css create mode 100644 client/src/components/CombatStartModal.tsx diff --git a/client/src/components/CombatStartModal.module.css b/client/src/components/CombatStartModal.module.css new file mode 100644 index 0000000..31a8e18 --- /dev/null +++ b/client/src/components/CombatStartModal.module.css @@ -0,0 +1,225 @@ +.backdrop { + position: absolute; + inset: 0; + background: var(--bg-overlay, rgba(0,0,0,0.6)); + display: flex; + align-items: center; + justify-content: center; + z-index: 200; + padding: 1rem; +} + +.modal { + background: var(--bg-modal, #1a1814); + background-image: var(--texture-surface), var(--texture-speckle); + background-size: 256px 256px, 128px 128px; + border: 2px solid rgba(var(--gold-rgb), 0.3); + border-radius: 4px; + width: 100%; + max-width: 480px; + max-height: 85vh; + overflow-y: auto; + padding: 1.25rem; + box-shadow: 0 8px 40px rgba(0,0,0,0.7); +} + +.modalHeader { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1.25rem; +} + +.modalTitle { + font-family: var(--font-display, "Cinzel", Georgia, serif); + font-size: 1rem; + font-weight: 700; + color: var(--gold); + letter-spacing: 0.05em; +} + +.closeBtn { + background: none; + border: none; + color: var(--text-secondary); + font-size: 1.2rem; + cursor: pointer; + padding: 0.1rem 0.3rem; +} + +.closeBtn:hover { + color: var(--text-primary); +} + +.field { + margin-bottom: 1rem; +} + +.fieldLabel { + display: block; + font-family: var(--font-display, "Cinzel", Georgia, serif); + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-secondary); + margin-bottom: 0.4rem; +} + +.input { + width: 100%; + font-size: 0.82rem; + color: var(--text-primary); + background: var(--bg-input, rgba(0,0,0,0.3)); + border: 1px solid rgba(var(--gold-rgb), 0.2); + border-radius: 3px; + padding: 0.4rem 0.6rem; + font-family: inherit; +} + +.input:focus { + outline: none; + border-color: rgba(var(--gold-rgb), 0.5); +} + +.charList { + display: flex; + flex-direction: column; + gap: 0.3rem; + background: var(--bg-inset, rgba(0,0,0,0.2)); + border: 1px solid rgba(var(--gold-rgb), 0.12); + border-radius: 3px; + padding: 0.5rem 0.6rem; +} + +.charRow { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + font-size: 0.82rem; + color: var(--text-primary); +} + +.charRow input[type="checkbox"] { + accent-color: var(--gold); +} + +.charDot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.charName { + flex: 1; +} + +.enemyRow { + display: flex; + gap: 0.4rem; + align-items: center; + margin-bottom: 0.35rem; +} + +.enemyRow .input { + flex: 1; +} + +.hpInput { + max-width: 80px; + flex: 0 0 80px !important; +} + +.removeRowBtn { + font-size: 0.7rem; + padding: 0.25rem 0.4rem; + background: none; + border: 1px solid rgba(var(--gold-rgb), 0.15); + border-radius: 3px; + color: var(--text-tertiary); + cursor: pointer; + flex-shrink: 0; + transition: all 0.12s; +} + +.removeRowBtn:hover:not(:disabled) { + color: var(--danger); + border-color: rgba(var(--danger-rgb), 0.3); +} + +.removeRowBtn:disabled { + opacity: 0.3; + cursor: not-allowed; +} + +.addRowBtn { + font-family: var(--font-display, "Cinzel", Georgia, serif); + font-size: 0.68rem; + font-weight: 600; + padding: 0.3rem 0.6rem; + background: transparent; + border: 1px dashed rgba(var(--gold-rgb), 0.3); + border-radius: 3px; + color: var(--text-secondary); + cursor: pointer; + width: 100%; + margin-top: 0.15rem; + transition: all 0.12s; +} + +.addRowBtn:hover { + border-color: rgba(var(--gold-rgb), 0.5); + color: var(--gold); +} + +.actions { + display: flex; + gap: 0.5rem; + margin-top: 1.25rem; + padding-top: 1rem; + border-top: 1px solid rgba(var(--gold-rgb), 0.15); +} + +.startBtn { + font-family: var(--font-display, "Cinzel", Georgia, serif); + font-size: 0.82rem; + font-weight: 700; + letter-spacing: 0.05em; + padding: 0.5rem 1rem; + background: rgba(var(--gold-rgb), 0.85); + border: none; + border-radius: 4px; + color: var(--btn-active-text); + cursor: pointer; + flex: 1; + transition: filter 0.12s; +} + +.startBtn:hover:not(:disabled) { + filter: brightness(1.1); +} + +.startBtn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.cancelBtn { + font-family: var(--font-display, "Cinzel", Georgia, serif); + font-size: 0.82rem; + font-weight: 600; + padding: 0.5rem 1rem; + background: transparent; + border: 1px solid rgba(var(--gold-rgb), 0.3); + border-radius: 4px; + color: var(--text-secondary); + cursor: pointer; + transition: all 0.12s; +} + +.cancelBtn:hover { + border-color: rgba(var(--gold-rgb), 0.5); + color: var(--gold); +} diff --git a/client/src/components/CombatStartModal.tsx b/client/src/components/CombatStartModal.tsx new file mode 100644 index 0000000..daf0a24 --- /dev/null +++ b/client/src/components/CombatStartModal.tsx @@ -0,0 +1,163 @@ +import { useState } from "react"; +import socket from "../socket.js"; +import type { Character } from "../types.js"; +import styles from "./CombatStartModal.module.css"; + +interface CombatStartModalProps { + characters: Character[]; + campaignId: number; + onClose: () => void; +} + +interface EnemyEntry { + key: number; + name: string; + hp_max: string; +} + +let enemyKey = 0; + +export default function CombatStartModal({ + characters, + campaignId, + onClose, +}: CombatStartModalProps) { + const [label, setLabel] = useState(""); + const [selectedCharIds, setSelectedCharIds] = useState>( + new Set(characters.map((c) => c.id)) + ); + const [enemies, setEnemies] = useState([ + { key: enemyKey++, name: "", hp_max: "" }, + ]); + + function toggleChar(id: number) { + setSelectedCharIds((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + } + + function updateEnemy(key: number, field: "name" | "hp_max", value: string) { + setEnemies((prev) => + prev.map((e) => (e.key === key ? { ...e, [field]: value } : e)) + ); + } + + function addEnemyRow() { + setEnemies((prev) => [...prev, { key: enemyKey++, name: "", hp_max: "" }]); + } + + function removeEnemyRow(key: number) { + setEnemies((prev) => prev.filter((e) => e.key !== key)); + } + + function handleStart() { + const validEnemies = enemies + .filter((e) => e.name.trim() && parseInt(e.hp_max, 10) > 0) + .map((e) => ({ name: e.name.trim(), hp_max: parseInt(e.hp_max, 10) })); + + socket.emit("initiative:start", { + campaignId, + label: label.trim() || undefined, + character_ids: Array.from(selectedCharIds), + enemies: validEnemies, + }); + onClose(); + } + + const canStart = selectedCharIds.size > 0; + + return ( +
+
e.stopPropagation()}> +
+ Start Combat + +
+ + {/* Optional label */} +
+ + setLabel(e.target.value)} + /> +
+ + {/* Character selection */} +
+ +
+ {characters.map((c) => ( + + ))} +
+
+ + {/* Enemy entries */} +
+ + {enemies.map((e) => ( +
+ updateEnemy(e.key, "name", ev.target.value)} + /> + updateEnemy(e.key, "hp_max", ev.target.value)} + /> + +
+ ))} + +
+ +
+ + +
+
+
+ ); +}