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 {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ interface CharacterCardProps {
|
|||
onUpdate: (characterId: number, data: Partial<Character>) => 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 (
|
||||
<div
|
||||
|
|
@ -49,6 +51,11 @@ export default function CharacterCard({
|
|||
<div className={styles.meta}>
|
||||
{character.ancestry} {character.class}
|
||||
</div>
|
||||
{focusSpell && (
|
||||
<div className={styles.focusIndicator}>
|
||||
● Focusing: {focusSpell}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.vitalsRow} onClick={(e) => e.stopPropagation()}>
|
||||
<HpBar
|
||||
|
|
|
|||
|
|
@ -96,6 +96,32 @@
|
|||
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) {
|
||||
.overlay {
|
||||
position: fixed;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import { useState } from "react";
|
||||
import type { Character, GameItem } from "../types";
|
||||
import { useState, useEffect } from "react";
|
||||
import type { Character, GameItem, CharacterSpell, SpellCastResult } from "../types";
|
||||
import { getCharacterSpells, restCharacter } from "../api";
|
||||
import CharacterSheet from "./CharacterSheet";
|
||||
import SpellList from "./SpellList";
|
||||
import SpellCastResultModal from "./SpellCastResult";
|
||||
import styles from "./CharacterDetail.module.css";
|
||||
|
||||
interface CharacterDetailProps {
|
||||
|
|
@ -46,6 +49,19 @@ export default function CharacterDetail({
|
|||
canEdit = true,
|
||||
}: CharacterDetailProps) {
|
||||
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 (
|
||||
<div className={styles.overlay} onClick={onClose}>
|
||||
|
|
@ -78,7 +94,37 @@ export default function CharacterDetail({
|
|||
onRemoveTalent={onRemoveTalent}
|
||||
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>
|
||||
|
||||
{castResult && (
|
||||
<SpellCastResultModal
|
||||
result={castResult.result}
|
||||
spellName={castResult.spellName}
|
||||
onClose={() => setCastResult(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
|||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,11 +8,12 @@ interface RollLogProps {
|
|||
campaignId: number;
|
||||
rolls: RollResult[];
|
||||
freshIds: Set<number>;
|
||||
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<MobileState>("peek");
|
||||
const [input, setInput] = useState("");
|
||||
|
|
@ -96,7 +97,12 @@ export default function RollLog({ campaignId, rolls, freshIds }: RollLogProps) {
|
|||
<div className={styles.entries}>
|
||||
{rolls.length === 0 && <p className={styles.empty}>No rolls yet</p>}
|
||||
{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>
|
||||
|
|
|
|||
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,
|
||||
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<RollResult | null>(null);
|
||||
const [atmosphere, setAtmosphere] = useState<AtmosphereState>(defaultAtmosphere);
|
||||
const [focusSpells, setFocusSpells] = useState<Map<number, string>>(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)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -507,7 +519,7 @@ export default function CampaignView() {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
<RollLog campaignId={campaignId} rolls={rolls} freshIds={freshIds} />
|
||||
<RollLog campaignId={campaignId} rolls={rolls} freshIds={freshIds} onUndoRoll={handleUndoRoll} />
|
||||
<DiceTray roll={diceRoll} onAnimationComplete={handleDiceComplete} />
|
||||
<FogOverlay active={atmosphere.fog.active} intensity={atmosphere.fog.intensity} />
|
||||
<ThreeFireOverlay active={atmosphere.fire.active} intensity={atmosphere.fire.intensity} />
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue