feat: spellcasting UI — SpellList, cast result modal, undo button, rest button, focus indicator
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ba02ffcfcb
commit
9399fc2186
12 changed files with 804 additions and 16 deletions
|
|
@ -73,10 +73,18 @@
|
||||||
.meta {
|
.meta {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
margin-bottom: 0.6rem;
|
margin-bottom: 0.3rem;
|
||||||
margin-left: 3.4rem;
|
margin-left: 3.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.focusIndicator {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--gold);
|
||||||
|
font-weight: 600;
|
||||||
|
margin-left: 3.4rem;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
.vitalsRow {
|
.vitalsRow {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ interface CharacterCardProps {
|
||||||
onUpdate: (characterId: number, data: Partial<Character>) => void;
|
onUpdate: (characterId: number, data: Partial<Character>) => void;
|
||||||
onClick: (characterId: number) => void;
|
onClick: (characterId: number) => void;
|
||||||
canEdit?: boolean;
|
canEdit?: boolean;
|
||||||
|
focusSpell?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CharacterCard({
|
export default function CharacterCard({
|
||||||
|
|
@ -29,6 +30,7 @@ export default function CharacterCard({
|
||||||
onUpdate,
|
onUpdate,
|
||||||
onClick,
|
onClick,
|
||||||
canEdit = true,
|
canEdit = true,
|
||||||
|
focusSpell,
|
||||||
}: CharacterCardProps) {
|
}: CharacterCardProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -49,6 +51,11 @@ export default function CharacterCard({
|
||||||
<div className={styles.meta}>
|
<div className={styles.meta}>
|
||||||
{character.ancestry} {character.class}
|
{character.ancestry} {character.class}
|
||||||
</div>
|
</div>
|
||||||
|
{focusSpell && (
|
||||||
|
<div className={styles.focusIndicator}>
|
||||||
|
● Focusing: {focusSpell}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className={styles.vitalsRow} onClick={(e) => e.stopPropagation()}>
|
<div className={styles.vitalsRow} onClick={(e) => e.stopPropagation()}>
|
||||||
<HpBar
|
<HpBar
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,32 @@
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.restRow {
|
||||||
|
margin-top: 1rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restBtn {
|
||||||
|
font-family: "Cinzel", Georgia, serif;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
padding: 0.4rem 1rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid rgba(var(--gold-rgb), 0.35);
|
||||||
|
border-radius: 3px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restBtn:hover {
|
||||||
|
background: rgba(var(--gold-rgb), 0.08);
|
||||||
|
border-color: rgba(var(--gold-rgb), 0.55);
|
||||||
|
color: var(--gold);
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.overlay {
|
.overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import type { Character, GameItem } from "../types";
|
import type { Character, GameItem, CharacterSpell, SpellCastResult } from "../types";
|
||||||
|
import { getCharacterSpells, restCharacter } from "../api";
|
||||||
import CharacterSheet from "./CharacterSheet";
|
import CharacterSheet from "./CharacterSheet";
|
||||||
|
import SpellList from "./SpellList";
|
||||||
|
import SpellCastResultModal from "./SpellCastResult";
|
||||||
import styles from "./CharacterDetail.module.css";
|
import styles from "./CharacterDetail.module.css";
|
||||||
|
|
||||||
interface CharacterDetailProps {
|
interface CharacterDetailProps {
|
||||||
|
|
@ -46,6 +49,19 @@ export default function CharacterDetail({
|
||||||
canEdit = true,
|
canEdit = true,
|
||||||
}: CharacterDetailProps) {
|
}: CharacterDetailProps) {
|
||||||
const [mode, setMode] = useState<"view" | "edit">("view");
|
const [mode, setMode] = useState<"view" | "edit">("view");
|
||||||
|
const [spells, setSpells] = useState<CharacterSpell[]>([]);
|
||||||
|
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 (
|
return (
|
||||||
<div className={styles.overlay} onClick={onClose}>
|
<div className={styles.overlay} onClick={onClose}>
|
||||||
|
|
@ -78,7 +94,37 @@ export default function CharacterDetail({
|
||||||
onRemoveTalent={onRemoveTalent}
|
onRemoveTalent={onRemoveTalent}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<SpellList
|
||||||
|
characterId={character.id}
|
||||||
|
characterClass={character.class}
|
||||||
|
spells={spells}
|
||||||
|
mode={mode}
|
||||||
|
canEdit={canEdit}
|
||||||
|
onSpellAdded={(spell) => 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) && (
|
||||||
|
<div className={styles.restRow}>
|
||||||
|
<button className={styles.restBtn} onClick={handleRest}>
|
||||||
|
Take Rest
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{castResult && (
|
||||||
|
<SpellCastResultModal
|
||||||
|
result={castResult.result}
|
||||||
|
spellName={castResult.spellName}
|
||||||
|
onClose={() => setCastResult(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,36 @@
|
||||||
text-transform: uppercase;
|
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 {
|
.critBanner {
|
||||||
font-family: "Cinzel", Georgia, serif;
|
font-family: "Cinzel", Georgia, serif;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import styles from "./RollEntry.module.css";
|
||||||
interface RollEntryProps {
|
interface RollEntryProps {
|
||||||
roll: RollResult;
|
roll: RollResult;
|
||||||
fresh?: boolean;
|
fresh?: boolean;
|
||||||
|
onUndo?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function timeAgo(dateStr: string): string {
|
function timeAgo(dateStr: string): string {
|
||||||
|
|
@ -18,7 +19,7 @@ function timeAgo(dateStr: string): string {
|
||||||
return `${hours}h ago`;
|
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 { rolls, advantage, disadvantage, dice_expression } = roll;
|
||||||
|
|
||||||
const isAdvantage = advantage && rolls.length === 2;
|
const isAdvantage = advantage && rolls.length === 2;
|
||||||
|
|
@ -77,6 +78,19 @@ export default function RollEntry({ roll, fresh }: RollEntryProps) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className={styles.total}>{roll.total}</div>
|
<div className={styles.total}>{roll.total}</div>
|
||||||
|
{roll.subtype === "spell_cast" && (
|
||||||
|
<div className={styles.undoRow}>
|
||||||
|
{roll.undone ? (
|
||||||
|
<span className={styles.reverted}>Reverted</span>
|
||||||
|
) : (
|
||||||
|
onUndo && (
|
||||||
|
<button className={styles.undoBtn} onClick={onUndo}>
|
||||||
|
Undo
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,12 @@ interface RollLogProps {
|
||||||
campaignId: number;
|
campaignId: number;
|
||||||
rolls: RollResult[];
|
rolls: RollResult[];
|
||||||
freshIds: Set<number>;
|
freshIds: Set<number>;
|
||||||
|
onUndoRoll?: (rollId: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type MobileState = "hidden" | "peek" | "open";
|
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 [collapsed, setCollapsed] = useState(false);
|
||||||
const [mobileState, setMobileState] = useState<MobileState>("peek");
|
const [mobileState, setMobileState] = useState<MobileState>("peek");
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
|
|
@ -96,7 +97,12 @@ export default function RollLog({ campaignId, rolls, freshIds }: RollLogProps) {
|
||||||
<div className={styles.entries}>
|
<div className={styles.entries}>
|
||||||
{rolls.length === 0 && <p className={styles.empty}>No rolls yet</p>}
|
{rolls.length === 0 && <p className={styles.empty}>No rolls yet</p>}
|
||||||
{rolls.map((roll) => (
|
{rolls.map((roll) => (
|
||||||
<RollEntry key={roll.id} roll={roll} fresh={freshIds.has(roll.id)} />
|
<RollEntry
|
||||||
|
key={roll.id}
|
||||||
|
roll={roll}
|
||||||
|
fresh={freshIds.has(roll.id)}
|
||||||
|
onUndo={onUndoRoll ? () => onUndoRoll(roll.id) : undefined}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
160
client/src/components/SpellCastResult.module.css
Normal file
160
client/src/components/SpellCastResult.module.css
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
56
client/src/components/SpellCastResult.tsx
Normal file
56
client/src/components/SpellCastResult.tsx
Normal file
|
|
@ -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<string, unknown> | null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.overlay} onClick={onClose}>
|
||||||
|
<div className={styles.modal} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className={styles.spellName}>{spellName}</div>
|
||||||
|
<div className={styles.roll}>
|
||||||
|
{result.roll} {result.modifier >= 0 ? "+" : ""}
|
||||||
|
{result.modifier} = {result.total}
|
||||||
|
<span className={styles.dc}> vs DC {result.dc}</span>
|
||||||
|
</div>
|
||||||
|
<div className={`${styles.resultLabel} ${label.cls}`}>{label.text}</div>
|
||||||
|
|
||||||
|
{result.result === "failure" && (
|
||||||
|
<p className={styles.note}>Spell exhausted until rest.</p>
|
||||||
|
)}
|
||||||
|
{result.result === "crit_success" && (
|
||||||
|
<p className={styles.note}>Double one numerical effect!</p>
|
||||||
|
)}
|
||||||
|
{result.result === "crit_fail" && mishap && (
|
||||||
|
<div className={styles.mishap}>
|
||||||
|
<div className={styles.mishapTitle}>⚠ Wizard Mishap</div>
|
||||||
|
<p className={styles.mishapDesc}>{String(mishap.description ?? "")}</p>
|
||||||
|
{mishap.damage ? (
|
||||||
|
<p className={styles.mishapEffect}>Took {String(mishap.damage)} damage</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{result.result === "crit_fail" && !mishap && (
|
||||||
|
<p className={styles.note}>Spell exhausted. Deity is displeased — penance required.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button className={styles.closeBtn} onClick={onClose}>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
255
client/src/components/SpellList.module.css
Normal file
255
client/src/components/SpellList.module.css
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
168
client/src/components/SpellList.tsx
Normal file
168
client/src/components/SpellList.tsx
Normal file
|
|
@ -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<Spell[]>([]);
|
||||||
|
const [showPicker, setShowPicker] = useState(false);
|
||||||
|
const [casting, setCasting] = useState<number | null>(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<number, CharacterSpell[]> = {};
|
||||||
|
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 (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<span className={styles.sectionTitle}>Spells</span>
|
||||||
|
{canEdit && mode === "edit" && (
|
||||||
|
<button className={styles.addBtn} onClick={() => setShowPicker((p) => !p)}>
|
||||||
|
{showPicker ? "Cancel" : "+ Add Spell"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showPicker && (
|
||||||
|
<div className={styles.picker}>
|
||||||
|
{availableToAdd.length === 0 && (
|
||||||
|
<p className={styles.empty}>All available spells known.</p>
|
||||||
|
)}
|
||||||
|
{availableToAdd.map((s) => (
|
||||||
|
<button key={s.id} className={styles.pickerItem} onClick={() => handleAdd(s)}>
|
||||||
|
<span className={styles.tierBadge}>T{s.tier}</span>
|
||||||
|
<span className={styles.pickerName}>{s.name}</span>
|
||||||
|
{s.is_focus ? <span className={styles.focusBadge}>Focus</span> : null}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{spells.length === 0 && !showPicker && (
|
||||||
|
<p className={styles.empty}>
|
||||||
|
No spells known.{canEdit && mode === "edit" ? " Add some above." : ""}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{Object.entries(byTier)
|
||||||
|
.sort(([a], [b]) => Number(a) - Number(b))
|
||||||
|
.map(([tier, tierSpells]) => (
|
||||||
|
<div key={tier} className={styles.tierGroup}>
|
||||||
|
<div className={styles.tierLabel}>Tier {tier}</div>
|
||||||
|
{tierSpells.map((s) => (
|
||||||
|
<div
|
||||||
|
key={s.id}
|
||||||
|
className={[
|
||||||
|
styles.spell,
|
||||||
|
s.exhausted ? styles.exhausted : "",
|
||||||
|
s.focus_active ? styles.focusing : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")}
|
||||||
|
>
|
||||||
|
<div className={styles.spellMain}>
|
||||||
|
<span className={styles.spellName}>{s.name}</span>
|
||||||
|
{s.focus_active ? (
|
||||||
|
<span className={styles.focusActive}>● Focusing</span>
|
||||||
|
) : null}
|
||||||
|
{s.exhausted ? (
|
||||||
|
<span className={styles.exhaustedLabel}>Exhausted</span>
|
||||||
|
) : null}
|
||||||
|
{s.locked_until ? (
|
||||||
|
<span className={styles.lockedLabel}>Locked</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className={styles.spellMeta}>
|
||||||
|
{s.duration} · {s.range}
|
||||||
|
</div>
|
||||||
|
<div className={styles.spellDesc}>{s.description}</div>
|
||||||
|
<div className={styles.spellActions}>
|
||||||
|
{canEdit && (
|
||||||
|
<button
|
||||||
|
className={styles.castBtn}
|
||||||
|
disabled={!!s.exhausted || !!s.locked_until || casting === s.spell_id}
|
||||||
|
onClick={() => handleCast(s)}
|
||||||
|
>
|
||||||
|
{casting === s.spell_id ? "Rolling\u2026" : "Cast"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canEdit && mode === "edit" && (
|
||||||
|
<button
|
||||||
|
className={styles.removeBtn}
|
||||||
|
onClick={() => handleRemove(s.spell_id)}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,7 @@ import {
|
||||||
getRolls,
|
getRolls,
|
||||||
getMyCampaignRole,
|
getMyCampaignRole,
|
||||||
generateInvite,
|
generateInvite,
|
||||||
|
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 } from "../types";
|
||||||
|
|
@ -60,6 +61,7 @@ export default function CampaignView() {
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
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());
|
||||||
|
|
||||||
function handleAtmosphereChange(next: AtmosphereState) {
|
function handleAtmosphereChange(next: AtmosphereState) {
|
||||||
setAtmosphere(next);
|
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) {
|
function onAtmosphereUpdate(data: AtmosphereState) {
|
||||||
setAtmosphere(data);
|
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:created", onCharacterCreated);
|
||||||
socket.on("character:updated", onCharacterUpdated);
|
socket.on("character:updated", onCharacterUpdated);
|
||||||
socket.on("character:deleted", onCharacterDeleted);
|
socket.on("character:deleted", onCharacterDeleted);
|
||||||
|
|
@ -272,6 +274,8 @@ export default function CampaignView() {
|
||||||
socket.on("talent:removed", onTalentRemoved);
|
socket.on("talent:removed", onTalentRemoved);
|
||||||
socket.on("roll:result", onRollResult);
|
socket.on("roll:result", onRollResult);
|
||||||
socket.on("atmosphere:update", onAtmosphereUpdate);
|
socket.on("atmosphere:update", onAtmosphereUpdate);
|
||||||
|
socket.on("roll:undone", onRollUndone);
|
||||||
|
socket.on("character:rested", onCharacterRested);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
socket.off("character:created", onCharacterCreated);
|
socket.off("character:created", onCharacterCreated);
|
||||||
|
|
@ -284,6 +288,8 @@ export default function CampaignView() {
|
||||||
socket.off("talent:removed", onTalentRemoved);
|
socket.off("talent:removed", onTalentRemoved);
|
||||||
socket.off("roll:result", onRollResult);
|
socket.off("roll:result", onRollResult);
|
||||||
socket.off("atmosphere:update", onAtmosphereUpdate);
|
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);
|
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() {
|
async function handleInvite() {
|
||||||
try {
|
try {
|
||||||
const { url } = await generateInvite(campaignId);
|
const { url } = await generateInvite(campaignId);
|
||||||
|
|
@ -415,6 +426,7 @@ export default function CampaignView() {
|
||||||
onUpdate={handleUpdate}
|
onUpdate={handleUpdate}
|
||||||
onClick={setSelectedId}
|
onClick={setSelectedId}
|
||||||
canEdit={role === "dm" || char.user_id === user?.userId}
|
canEdit={role === "dm" || char.user_id === user?.userId}
|
||||||
|
focusSpell={focusSpells.get(char.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -507,7 +519,7 @@ export default function CampaignView() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<RollLog campaignId={campaignId} rolls={rolls} freshIds={freshIds} />
|
<RollLog campaignId={campaignId} rolls={rolls} freshIds={freshIds} onUndoRoll={handleUndoRoll} />
|
||||||
<DiceTray roll={diceRoll} onAnimationComplete={handleDiceComplete} />
|
<DiceTray roll={diceRoll} onAnimationComplete={handleDiceComplete} />
|
||||||
<FogOverlay active={atmosphere.fog.active} intensity={atmosphere.fog.intensity} />
|
<FogOverlay active={atmosphere.fog.active} intensity={atmosphere.fog.intensity} />
|
||||||
<ThreeFireOverlay active={atmosphere.fire.active} intensity={atmosphere.fire.intensity} />
|
<ThreeFireOverlay active={atmosphere.fire.active} intensity={atmosphere.fire.intensity} />
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue