diff --git a/client/src/components/CharacterCard.module.css b/client/src/components/CharacterCard.module.css index a876e62..e03c7ab 100644 --- a/client/src/components/CharacterCard.module.css +++ b/client/src/components/CharacterCard.module.css @@ -73,10 +73,18 @@ .meta { font-size: 0.85rem; color: var(--text-tertiary); - margin-bottom: 0.6rem; + margin-bottom: 0.3rem; margin-left: 3.4rem; } +.focusIndicator { + font-size: 0.72rem; + color: var(--gold); + font-weight: 600; + margin-left: 3.4rem; + margin-bottom: 0.4rem; +} + .vitalsRow { display: flex; align-items: center; diff --git a/client/src/components/CharacterCard.tsx b/client/src/components/CharacterCard.tsx index 5333c8f..c59f90c 100644 --- a/client/src/components/CharacterCard.tsx +++ b/client/src/components/CharacterCard.tsx @@ -21,6 +21,7 @@ interface CharacterCardProps { onUpdate: (characterId: number, data: Partial) => void; onClick: (characterId: number) => void; canEdit?: boolean; + focusSpell?: string; } export default function CharacterCard({ @@ -29,6 +30,7 @@ export default function CharacterCard({ onUpdate, onClick, canEdit = true, + focusSpell, }: CharacterCardProps) { return (
{character.ancestry} {character.class}
+ {focusSpell && ( +
+ ● Focusing: {focusSpell} +
+ )}
e.stopPropagation()}> ("view"); + const [spells, setSpells] = useState([]); + const [castResult, setCastResult] = useState<{ result: SpellCastResult; spellName: string } | null>(null); + + useEffect(() => { + if (["Wizard", "Priest"].includes(character.class)) { + getCharacterSpells(character.id).then(setSpells); + } + }, [character.id, character.class]); + + async function handleRest() { + await restCharacter(character.id); + setSpells((prev) => prev.map((s) => ({ ...s, exhausted: 0, focus_active: 0, focus_started_at: null }))); + } return (
@@ -78,7 +94,37 @@ export default function CharacterDetail({ onRemoveTalent={onRemoveTalent} onDelete={onDelete} /> + + setSpells((prev) => [...prev, spell])} + onSpellRemoved={(spellId) => + setSpells((prev) => prev.filter((s) => s.spell_id !== spellId)) + } + onSpellCast={(result, spellName) => setCastResult({ result, spellName })} + onSpellsUpdated={setSpells} + /> + + {canEdit && ["Wizard", "Priest"].includes(character.class) && ( +
+ +
+ )}
+ + {castResult && ( + setCastResult(null)} + /> + )}
); } diff --git a/client/src/components/RollEntry.module.css b/client/src/components/RollEntry.module.css index f65458c..79500b1 100644 --- a/client/src/components/RollEntry.module.css +++ b/client/src/components/RollEntry.module.css @@ -129,6 +129,36 @@ text-transform: uppercase; } +.undoRow { + margin-top: 0.25rem; + display: flex; + justify-content: flex-end; +} + +.undoBtn { + font-size: 0.65rem; + font-weight: 600; + padding: 0.15rem 0.5rem; + background: none; + border: 1px solid rgba(var(--danger-rgb), 0.35); + border-radius: 2px; + color: var(--text-tertiary); + cursor: pointer; + transition: all 0.12s; +} + +.undoBtn:hover { + color: var(--danger); + border-color: rgba(var(--danger-rgb), 0.6); + background: rgba(var(--danger-rgb), 0.06); +} + +.reverted { + font-size: 0.65rem; + color: var(--text-faint); + font-style: italic; +} + .critBanner { font-family: "Cinzel", Georgia, serif; text-align: center; diff --git a/client/src/components/RollEntry.tsx b/client/src/components/RollEntry.tsx index 56fdca9..4860482 100644 --- a/client/src/components/RollEntry.tsx +++ b/client/src/components/RollEntry.tsx @@ -4,6 +4,7 @@ import styles from "./RollEntry.module.css"; interface RollEntryProps { roll: RollResult; fresh?: boolean; + onUndo?: () => void; } function timeAgo(dateStr: string): string { @@ -18,7 +19,7 @@ function timeAgo(dateStr: string): string { return `${hours}h ago`; } -export default function RollEntry({ roll, fresh }: RollEntryProps) { +export default function RollEntry({ roll, fresh, onUndo }: RollEntryProps) { const { rolls, advantage, disadvantage, dice_expression } = roll; const isAdvantage = advantage && rolls.length === 2; @@ -77,6 +78,19 @@ export default function RollEntry({ roll, fresh }: RollEntryProps) { )}
{roll.total}
+ {roll.subtype === "spell_cast" && ( +
+ {roll.undone ? ( + Reverted + ) : ( + onUndo && ( + + ) + )} +
+ )} ); } diff --git a/client/src/components/RollLog.tsx b/client/src/components/RollLog.tsx index 8beccdb..cd03d1e 100644 --- a/client/src/components/RollLog.tsx +++ b/client/src/components/RollLog.tsx @@ -8,11 +8,12 @@ interface RollLogProps { campaignId: number; rolls: RollResult[]; freshIds: Set; + onUndoRoll?: (rollId: number) => void; } type MobileState = "hidden" | "peek" | "open"; -export default function RollLog({ campaignId, rolls, freshIds }: RollLogProps) { +export default function RollLog({ campaignId, rolls, freshIds, onUndoRoll }: RollLogProps) { const [collapsed, setCollapsed] = useState(false); const [mobileState, setMobileState] = useState("peek"); const [input, setInput] = useState(""); @@ -96,7 +97,12 @@ export default function RollLog({ campaignId, rolls, freshIds }: RollLogProps) {
{rolls.length === 0 &&

No rolls yet

} {rolls.map((roll) => ( - + onUndoRoll(roll.id) : undefined} + /> ))}
diff --git a/client/src/components/SpellCastResult.module.css b/client/src/components/SpellCastResult.module.css new file mode 100644 index 0000000..555abc2 --- /dev/null +++ b/client/src/components/SpellCastResult.module.css @@ -0,0 +1,160 @@ +.overlay { + position: fixed; + inset: 0; + background: rgba(var(--shadow-rgb), 0.82); + display: flex; + align-items: center; + justify-content: center; + z-index: 500; + padding: 1rem; + animation: fadeIn 0.15s ease-out; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.modal { + background-color: var(--bg-modal); + background-image: var(--texture-surface), var(--texture-speckle); + background-size: 256px 256px, 128px 128px; + background-repeat: repeat, repeat; + border: 2px solid rgba(var(--gold-rgb), 0.3); + border-radius: 6px; + padding: 2rem 2.5rem; + max-width: 420px; + width: 100%; + text-align: center; + box-shadow: + 0 12px 48px rgba(var(--shadow-rgb), 0.8), + 0 4px 12px rgba(var(--shadow-rgb), 0.5), + inset 0 1px 0 rgba(var(--gold-rgb), 0.1); + animation: popIn 0.2s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +@keyframes popIn { + from { transform: scale(0.88); opacity: 0; } + to { transform: scale(1); opacity: 1; } +} + +.spellName { + font-family: var(--font-display, "Cinzel", Georgia, serif); + font-size: 1.15rem; + font-weight: 700; + color: var(--gold); + letter-spacing: 0.06em; + margin-bottom: 0.75rem; +} + +.roll { + font-size: 0.9rem; + color: var(--text-secondary); + margin-bottom: 0.5rem; +} + +.dc { + color: var(--text-tertiary); +} + +/* ── Result label variants ── */ + +.resultLabel { + font-family: var(--font-display, "Cinzel", Georgia, serif); + font-size: 1.6rem; + font-weight: 700; + letter-spacing: 0.06em; + margin: 0.75rem 0; + text-transform: uppercase; +} + +.success { + color: var(--gold); + text-shadow: 0 0 16px rgba(var(--gold-rgb), 0.45); +} + +.failure { + color: var(--text-secondary); +} + +.critSuccess { + color: var(--crit); + text-shadow: + 0 0 20px rgba(var(--crit-rgb), 0.6), + 0 0 40px rgba(var(--crit-rgb), 0.3); + animation: critGlow 1s ease-in-out infinite alternate; +} + +@keyframes critGlow { + from { text-shadow: 0 0 16px rgba(var(--crit-rgb), 0.5), 0 0 32px rgba(var(--crit-rgb), 0.2); } + to { text-shadow: 0 0 28px rgba(var(--crit-rgb), 0.8), 0 0 56px rgba(var(--crit-rgb), 0.4); } +} + +.critFail { + color: var(--danger); + text-shadow: 0 0 16px rgba(var(--danger-rgb), 0.5); +} + +/* ── Notes ── */ + +.note { + font-size: 0.82rem; + color: var(--text-secondary); + font-style: italic; + margin: 0.5rem 0 0; +} + +/* ── Mishap block ── */ + +.mishap { + margin-top: 1rem; + background: rgba(var(--danger-rgb), 0.08); + border: 1px solid rgba(var(--danger-rgb), 0.3); + border-radius: 4px; + padding: 0.75rem 1rem; + text-align: left; +} + +.mishapTitle { + font-family: var(--font-display, "Cinzel", Georgia, serif); + font-size: 0.85rem; + font-weight: 700; + color: var(--danger); + margin-bottom: 0.4rem; + letter-spacing: 0.04em; +} + +.mishapDesc { + font-size: 0.8rem; + color: var(--text-primary); + line-height: 1.45; + margin: 0 0 0.4rem; +} + +.mishapEffect { + font-size: 0.78rem; + color: var(--danger); + font-weight: 600; + margin: 0; +} + +/* ── Dismiss button ── */ + +.closeBtn { + font-family: var(--font-display, "Cinzel", Georgia, serif); + font-size: 0.8rem; + font-weight: 700; + letter-spacing: 0.06em; + padding: 0.5rem 1.5rem; + margin-top: 1.25rem; + background: var(--btn-gold-bg); + border: none; + border-radius: 3px; + color: var(--btn-active-text); + cursor: pointer; + transition: filter 0.15s; +} + +.closeBtn:hover { + filter: brightness(1.1); +} diff --git a/client/src/components/SpellCastResult.tsx b/client/src/components/SpellCastResult.tsx new file mode 100644 index 0000000..1aba434 --- /dev/null +++ b/client/src/components/SpellCastResult.tsx @@ -0,0 +1,56 @@ +import type { SpellCastResult as CastResult } from "../types.js"; +import styles from "./SpellCastResult.module.css"; + +interface Props { + result: CastResult; + spellName: string; + onClose: () => void; +} + +export default function SpellCastResult({ result, spellName, onClose }: Props) { + const labels = { + success: { text: "Success", cls: styles.success }, + failure: { text: "Failure", cls: styles.failure }, + crit_success: { text: "Critical Success!", cls: styles.critSuccess }, + crit_fail: { text: "Critical Failure!", cls: styles.critFail }, + }; + const label = labels[result.result]; + const mishap = result.mishapResult as Record | null; + + return ( +
+
e.stopPropagation()}> +
{spellName}
+
+ {result.roll} {result.modifier >= 0 ? "+" : ""} + {result.modifier} = {result.total} + vs DC {result.dc} +
+
{label.text}
+ + {result.result === "failure" && ( +

Spell exhausted until rest.

+ )} + {result.result === "crit_success" && ( +

Double one numerical effect!

+ )} + {result.result === "crit_fail" && mishap && ( +
+
⚠ Wizard Mishap
+

{String(mishap.description ?? "")}

+ {mishap.damage ? ( +

Took {String(mishap.damage)} damage

+ ) : null} +
+ )} + {result.result === "crit_fail" && !mishap && ( +

Spell exhausted. Deity is displeased — penance required.

+ )} + + +
+
+ ); +} diff --git a/client/src/components/SpellList.module.css b/client/src/components/SpellList.module.css new file mode 100644 index 0000000..6c2619f --- /dev/null +++ b/client/src/components/SpellList.module.css @@ -0,0 +1,255 @@ +.container { + margin-top: 1.5rem; + padding-top: 1rem; + border-top: 1px solid rgba(var(--gold-rgb), 0.15); +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.75rem; +} + +.sectionTitle { + font-family: var(--font-display, "Cinzel", Georgia, serif); + font-size: 0.9rem; + font-weight: 700; + color: var(--gold); + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.addBtn { + font-family: var(--font-display, "Cinzel", Georgia, serif); + font-size: 0.72rem; + font-weight: 600; + padding: 0.3rem 0.65rem; + background: transparent; + border: 1px solid rgba(var(--gold-rgb), 0.4); + border-radius: 3px; + color: var(--gold); + cursor: pointer; + letter-spacing: 0.04em; + transition: all 0.15s; +} + +.addBtn:hover { + background: rgba(var(--gold-rgb), 0.1); + border-color: var(--gold); +} + +/* ── Picker ── */ + +.picker { + background: var(--bg-inset); + border: 1px solid rgba(var(--gold-rgb), 0.15); + border-radius: 4px; + padding: 0.5rem; + margin-bottom: 1rem; + display: flex; + flex-direction: column; + gap: 0.25rem; + max-height: 260px; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: rgba(var(--gold-rgb), 0.2) transparent; +} + +.pickerItem { + display: flex; + align-items: center; + gap: 0.5rem; + background: none; + border: 1px solid transparent; + border-radius: 3px; + padding: 0.4rem 0.6rem; + text-align: left; + cursor: pointer; + color: var(--text-primary); + font-size: 0.8rem; + transition: all 0.12s; +} + +.pickerItem:hover { + background: rgba(var(--gold-rgb), 0.08); + border-color: rgba(var(--gold-rgb), 0.25); +} + +.tierBadge { + font-family: var(--font-display, "Cinzel", Georgia, serif); + font-size: 0.65rem; + font-weight: 700; + color: var(--gold); + background: rgba(var(--gold-rgb), 0.1); + border: 1px solid rgba(var(--gold-rgb), 0.25); + border-radius: 2px; + padding: 0.1rem 0.3rem; + flex-shrink: 0; +} + +.pickerName { + flex: 1; + color: var(--text-primary); +} + +.focusBadge { + font-size: 0.65rem; + color: var(--gold); + background: rgba(var(--gold-rgb), 0.08); + border: 1px solid rgba(var(--gold-rgb), 0.2); + border-radius: 2px; + padding: 0.1rem 0.3rem; +} + +/* ── Empty state ── */ + +.empty { + color: var(--text-tertiary); + font-size: 0.8rem; + font-style: italic; + margin: 0.25rem 0; +} + +/* ── Tier groups ── */ + +.tierGroup { + margin-bottom: 0.75rem; +} + +.tierLabel { + font-family: var(--font-display, "Cinzel", Georgia, serif); + font-size: 0.68rem; + font-weight: 700; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.1em; + margin-bottom: 0.4rem; + padding-bottom: 0.2rem; + border-bottom: 1px solid rgba(var(--gold-rgb), 0.1); +} + +/* ── Individual spell row ── */ + +.spell { + background: var(--bg-inset); + border: 1px solid rgba(var(--gold-rgb), 0.1); + border-radius: 4px; + padding: 0.6rem 0.75rem; + margin-bottom: 0.4rem; + transition: border-color 0.15s, opacity 0.15s; +} + +.spell.exhausted { + opacity: 0.5; +} + +.spell.exhausted .spellName { + text-decoration: line-through; + color: var(--text-tertiary); +} + +.spell.focusing { + border-color: rgba(var(--gold-rgb), 0.55); + box-shadow: 0 0 8px rgba(var(--gold-rgb), 0.12), inset 0 0 12px rgba(var(--gold-rgb), 0.04); +} + +.spellMain { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + margin-bottom: 0.25rem; +} + +.spellName { + font-family: var(--font-display, "Cinzel", Georgia, serif); + font-size: 0.82rem; + font-weight: 700; + color: var(--text-primary); +} + +.focusActive { + font-size: 0.68rem; + color: var(--gold); + font-weight: 600; +} + +.exhaustedLabel { + font-size: 0.65rem; + color: var(--text-tertiary); + background: rgba(var(--shadow-rgb), 0.3); + border-radius: 2px; + padding: 0.1rem 0.3rem; +} + +.lockedLabel { + font-size: 0.65rem; + color: var(--danger); + background: rgba(var(--danger-rgb), 0.1); + border: 1px solid rgba(var(--danger-rgb), 0.25); + border-radius: 2px; + padding: 0.1rem 0.3rem; +} + +.spellMeta { + font-size: 0.7rem; + color: var(--text-tertiary); + margin-bottom: 0.3rem; +} + +.spellDesc { + font-size: 0.76rem; + color: var(--text-secondary); + line-height: 1.4; + margin-bottom: 0.5rem; +} + +.spellActions { + display: flex; + align-items: center; + gap: 0.5rem; +} + +/* Cast button — gold primary */ +.castBtn { + font-family: var(--font-display, "Cinzel", Georgia, serif); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.06em; + padding: 0.3rem 0.8rem; + background: var(--btn-gold-bg); + border: none; + border-radius: 3px; + color: var(--btn-active-text); + cursor: pointer; + transition: filter 0.15s, opacity 0.15s; +} + +.castBtn:hover:not(:disabled) { + filter: brightness(1.1); +} + +.castBtn:disabled { + opacity: 0.4; + cursor: not-allowed; + filter: none; +} + +/* Remove button — subtle, red on hover */ +.removeBtn { + font-size: 0.75rem; + padding: 0.25rem 0.5rem; + background: none; + border: 1px solid rgba(var(--shadow-rgb), 0.3); + border-radius: 3px; + color: var(--text-tertiary); + cursor: pointer; + transition: all 0.15s; +} + +.removeBtn:hover { + color: var(--danger); + border-color: rgba(var(--danger-rgb), 0.4); + background: rgba(var(--danger-rgb), 0.06); +} diff --git a/client/src/components/SpellList.tsx b/client/src/components/SpellList.tsx new file mode 100644 index 0000000..15664c5 --- /dev/null +++ b/client/src/components/SpellList.tsx @@ -0,0 +1,168 @@ +import { useState, useEffect } from "react"; +import type { CharacterSpell, Spell, SpellCastResult } from "../types.js"; +import { getSpells, addCharacterSpell, removeCharacterSpell, castSpell } from "../api.js"; +import styles from "./SpellList.module.css"; + +interface SpellListProps { + characterId: number; + characterClass: string; + spells: CharacterSpell[]; + mode: "view" | "edit"; + canEdit: boolean; + onSpellAdded: (spell: CharacterSpell) => void; + onSpellRemoved: (spellId: number) => void; + onSpellCast: (result: SpellCastResult, spellName: string) => void; + onSpellsUpdated: (spells: CharacterSpell[]) => void; +} + +export default function SpellList({ + characterId, + characterClass, + spells, + mode, + canEdit, + onSpellAdded, + onSpellRemoved, + onSpellCast, + onSpellsUpdated, +}: SpellListProps) { + const [allSpells, setAllSpells] = useState([]); + const [showPicker, setShowPicker] = useState(false); + const [casting, setCasting] = useState(null); + + const isCaster = ["Wizard", "Priest"].includes(characterClass); + + useEffect(() => { + if (!isCaster) return; + const spellClass = characterClass.toLowerCase(); + getSpells(spellClass).then(setAllSpells); + }, [characterClass, isCaster]); + + if (!isCaster) return null; + + const knownIds = new Set(spells.map((s) => s.spell_id)); + const availableToAdd = allSpells.filter((s) => !knownIds.has(s.id)); + + const byTier: Record = {}; + for (const s of spells) { + byTier[s.tier] = byTier[s.tier] ?? []; + byTier[s.tier].push(s); + } + + async function handleCast(spell: CharacterSpell) { + setCasting(spell.spell_id); + try { + const result = await castSpell(characterId, spell.spell_id); + onSpellCast(result, spell.name); + const exhausted = result.result === "success" || result.result === "crit_success" ? 0 : 1; + onSpellsUpdated( + spells.map((s) => (s.spell_id === spell.spell_id ? { ...s, exhausted } : s)) + ); + } catch (err) { + console.error("Cast failed", err); + } finally { + setCasting(null); + } + } + + async function handleRemove(spellId: number) { + await removeCharacterSpell(characterId, spellId); + onSpellRemoved(spellId); + } + + async function handleAdd(spell: Spell) { + const cs = await addCharacterSpell(characterId, spell.id); + onSpellAdded(cs); + setShowPicker(false); + } + + return ( +
+
+ Spells + {canEdit && mode === "edit" && ( + + )} +
+ + {showPicker && ( +
+ {availableToAdd.length === 0 && ( +

All available spells known.

+ )} + {availableToAdd.map((s) => ( + + ))} +
+ )} + + {spells.length === 0 && !showPicker && ( +

+ No spells known.{canEdit && mode === "edit" ? " Add some above." : ""} +

+ )} + + {Object.entries(byTier) + .sort(([a], [b]) => Number(a) - Number(b)) + .map(([tier, tierSpells]) => ( +
+
Tier {tier}
+ {tierSpells.map((s) => ( +
+
+ {s.name} + {s.focus_active ? ( + ● Focusing + ) : null} + {s.exhausted ? ( + Exhausted + ) : null} + {s.locked_until ? ( + Locked + ) : null} +
+
+ {s.duration} · {s.range} +
+
{s.description}
+
+ {canEdit && ( + + )} + {canEdit && mode === "edit" && ( + + )} +
+
+ ))} +
+ ))} +
+ ); +} diff --git a/client/src/pages/CampaignView.tsx b/client/src/pages/CampaignView.tsx index a6695c7..8af0643 100644 --- a/client/src/pages/CampaignView.tsx +++ b/client/src/pages/CampaignView.tsx @@ -15,6 +15,7 @@ import { getRolls, getMyCampaignRole, generateInvite, + undoRoll, } from "../api"; import { useAuth } from "../context/AuthContext"; import type { Character, Gear, Talent, GameItem, RollResult } from "../types"; @@ -60,6 +61,7 @@ export default function CampaignView() { } | null>(null); const pendingRollRef = useRef(null); const [atmosphere, setAtmosphere] = useState(defaultAtmosphere); + const [focusSpells, setFocusSpells] = useState>(new Map()); function handleAtmosphereChange(next: AtmosphereState) { setAtmosphere(next); @@ -249,19 +251,19 @@ export default function CampaignView() { } } - socket.on("character:created", onCharacterCreated); - socket.on("character:updated", onCharacterUpdated); - socket.on("character:deleted", onCharacterDeleted); - socket.on("stat:updated", onStatUpdated); - socket.on("gear:added", onGearAdded); - socket.on("gear:removed", onGearRemoved); - socket.on("talent:added", onTalentAdded); - socket.on("talent:removed", onTalentRemoved); - socket.on("roll:result", onRollResult); function onAtmosphereUpdate(data: AtmosphereState) { setAtmosphere(data); } + function onRollUndone({ rollId }: { rollId: number }) { + setRolls((prev) => prev.map((r) => r.id === rollId ? { ...r, undone: true } : r)); + } + + function onCharacterRested({ characterId }: { characterId: number }) { + // SpellList local state already updated via handleRest; suppress unused warning + void characterId; + } + socket.on("character:created", onCharacterCreated); socket.on("character:updated", onCharacterUpdated); socket.on("character:deleted", onCharacterDeleted); @@ -272,6 +274,8 @@ export default function CampaignView() { socket.on("talent:removed", onTalentRemoved); socket.on("roll:result", onRollResult); socket.on("atmosphere:update", onAtmosphereUpdate); + socket.on("roll:undone", onRollUndone); + socket.on("character:rested", onCharacterRested); return () => { socket.off("character:created", onCharacterCreated); @@ -284,6 +288,8 @@ export default function CampaignView() { socket.off("talent:removed", onTalentRemoved); socket.off("roll:result", onRollResult); socket.off("atmosphere:update", onAtmosphereUpdate); + socket.off("roll:undone", onRollUndone); + socket.off("character:rested", onCharacterRested); }; }, []); @@ -358,6 +364,11 @@ export default function CampaignView() { await removeTalent(characterId, talentId); } + async function handleUndoRoll(rollId: number) { + await undoRoll(campaignId, rollId); + setRolls((prev) => prev.map((r) => r.id === rollId ? { ...r, undone: true } : r)); + } + async function handleInvite() { try { const { url } = await generateInvite(campaignId); @@ -415,6 +426,7 @@ export default function CampaignView() { onUpdate={handleUpdate} onClick={setSelectedId} canEdit={role === "dm" || char.user_id === user?.userId} + focusSpell={focusSpells.get(char.id)} /> ))} @@ -507,7 +519,7 @@ export default function CampaignView() { )} - +