2025-05-28 15:36:51 -07:00

331 lines
9.6 KiB
TypeScript

import clsx from "clsx";
import { FC, HTMLAttributes, ReactNode } from "react";
import { useDispatch } from "react-redux";
import {
characterActions,
CharacterUtils,
CharClass,
ClassUtils,
Constants,
FormatUtils,
HelperUtils,
HtmlSelectOption,
} from "@dndbeyond/character-rules-engine";
import CloseIcon from "@dndbeyond/fontawesome-cache/svgs/solid/x.svg";
import { Button } from "~/components/Button";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { useModalManager } from "~/subApps/builder/contexts/ModalManager";
import { Select } from "~/tools/js/smartComponents/legacy";
import styles from "./styles.module.css";
export interface ClassHeaderProps extends HTMLAttributes<HTMLDivElement> {
charClass: CharClass;
isMulticlass: boolean;
levelsRemaining: number;
}
export const ClassHeader: FC<ClassHeaderProps> = ({
charClass,
isMulticlass,
levelsRemaining,
className,
...props
}) => {
const dispatch = useDispatch();
const { preferences, ruleData, classes, currentLevel, totalClassLevel } =
useCharacterEngine();
const { createModal } = useModalManager();
const portraitAvatarUrl = ClassUtils.getPortraitUrl(charClass);
const isStartingClass = ClassUtils.isStartingClass(charClass);
const level = ClassUtils.getLevel(charClass);
const name = ClassUtils.getName(charClass);
const subclassDefinition = ClassUtils.getSubclass(charClass);
const classId = ClassUtils.getActiveId(charClass);
const levelsDiff: number = currentLevel - totalClassLevel;
const isTypeMilestone: boolean =
preferences.progressionType ===
Constants.PreferenceProgressionTypeEnum.MILESTONE;
const isTypeXp: boolean =
preferences.progressionType === Constants.PreferenceProgressionTypeEnum.XP;
const hasLevelXpDiff: boolean = levelsDiff !== 0 && isTypeXp;
let levelOptions: Array<HtmlSelectOption> = [];
for (let i = 1; i <= level + levelsRemaining; i++) {
levelOptions.push({
label: "" + i,
value: i,
});
}
const handleLevelChangePromise = (
newLevel: string,
oldLevel: string,
accept: () => void,
reject: () => void
): void => {
const newLevelValue = HelperUtils.parseInputInt(newLevel, 0);
const oldLevelValue = HelperUtils.parseInputInt(oldLevel, 0);
const newTotalClassLevel: number = classes.reduce(
(acc: number, oldClass) =>
(acc += oldClass.id === charClass.id ? newLevelValue : oldClass.level),
0
);
const isLevelUp: boolean = newLevelValue > oldLevelValue;
const isLevelDown: boolean = newLevelValue < oldLevelValue;
// Modals to handle milestone
if (isLevelUp && isTypeMilestone) {
dispatch(characterActions.classLevelSetRequest(classId, newLevelValue));
accept();
}
if (isLevelDown && isTypeMilestone) {
createModal({
content: (
<div>
{renderModalIntro(currentLevel, newTotalClassLevel)}
<p>Are you sure you want to level down in the {name} class?</p>
<p>
Your hit points will be reduced by the fixed amount and class
feature choices you have made for the higher levels will be lost.
</p>
</div>
),
props: {
heading: "Level Down",
color: "secondary",
confirmButtonText: "Level Down",
size: "fit-content",
onConfirm: () => {
dispatch(
characterActions.classLevelSetRequest(classId, newLevelValue)
);
accept();
},
onClose: () => {
reject();
},
},
});
}
// Modals to handle progression
if (isLevelUp && isTypeXp) {
if (newTotalClassLevel <= currentLevel) {
dispatch(characterActions.classLevelSetRequest(classId, newLevelValue));
accept();
} else {
createModal({
content: (
<div>
{renderModalIntro(currentLevel, newTotalClassLevel)}
<p>Are you sure you want to level up in the {name} class?</p>
<p>
Your XP total will be increased to{" "}
{FormatUtils.renderLocaleNumber(
CharacterUtils.deriveCurrentLevelXp(
newTotalClassLevel,
ruleData
)
)}{" "}
to match your new level.
</p>
</div>
),
props: {
heading: "Level Up",
confirmButtonText: "Level Up",
size: "fit-content",
onConfirm: () => {
dispatch(
characterActions.classLevelSetRequest(
classId,
newLevelValue,
CharacterUtils.deriveCurrentLevelXp(
newTotalClassLevel,
ruleData
)
)
);
accept();
},
onClose: () => {
reject();
},
},
});
}
}
if (isLevelDown && isTypeXp) {
createModal({
content: (
<div>
{renderModalIntro(currentLevel, newTotalClassLevel)}
<p>Are you sure you want to level down in the {name} class?</p>
<p>
Your hit points will be reduced by the fixed amount and class
feature choices you have made for the higher levels will be lost.
</p>
<p>
Your XP total will be decreased to{" "}
{FormatUtils.renderLocaleNumber(
CharacterUtils.deriveCurrentLevelXp(
newTotalClassLevel,
ruleData
)
)}{" "}
to match your new level.
</p>
</div>
),
props: {
heading: "Level Down",
confirmButtonText: "Level Down",
color: "secondary",
size: "fit-content",
onConfirm: () => {
dispatch(
characterActions.classLevelSetRequest(
classId,
newLevelValue,
CharacterUtils.deriveCurrentLevelXp(
newTotalClassLevel,
ruleData
)
)
);
accept();
},
onClose: () => {
reject();
},
},
});
}
};
const handleRemoveClass = (): void => {
const newTotalClassLevel: number = classes.reduce(
(acc: number, oldClass) =>
(acc += oldClass.id === charClass.id ? 0 : oldClass.level),
0
);
createModal({
content: (
<div>
{renderModalIntro()}
<p>
Are you sure you want to remove all your levels in the {name} class?
</p>
<p>
Your hit points will be reduced by the fixed amount and class
feature choices you have made for the higher levels will be lost.
</p>
{isTypeXp && (
<p>Your XP total will be decreased to match your new level.</p>
)}
</div>
),
props: {
heading: "Remove Class",
variant: "remove",
size: "fit-content",
confirmButtonText: "Remove",
onConfirm: () => {
let newCharacterXp: number | null = isTypeXp
? CharacterUtils.deriveCurrentLevelXp(newTotalClassLevel, ruleData)
: null;
dispatch(
characterActions.classRemoveRequest(charClass.id, newCharacterXp)
);
},
onClose: () => {}, //Do nothing
},
});
};
const renderModalIntro = (
currentLevel: number | null = null,
newLevel: number | null = null
): React.ReactNode => {
return (
<div className={styles.modalIntro}>
{classPortraitName}
{currentLevel !== null && newLevel !== null && (
<div className={styles.modalLevels}>
<div className={styles.modalLevel}>
Current Level: {currentLevel}
</div>
<div className={styles.modalLevel}>New Level: {newLevel}</div>
</div>
)}
</div>
);
};
const classPortraitName: ReactNode = (
<>
<img
className={styles.classImg}
src={portraitAvatarUrl}
alt={`${name} Class avatar`}
/>
<div className={styles.nameGroup}>
{subclassDefinition && (
<div className={styles.subclassName}>{subclassDefinition.name}</div>
)}
<div className={styles.name} data-testid="class-name">
{name}
</div>
</div>
</>
);
return (
<div
className={clsx([styles.classHeader, styles.container, className])}
{...props}
>
{isMulticlass && isStartingClass && (
<div className={styles.startingClass}>Starting Class</div>
)}
{classPortraitName}
<div className={styles.container}>
<div className={styles.levelManager}>
<label
className={styles.levelLabel}
htmlFor={`class-level-${classId}`}
>
{hasLevelXpDiff && <span className={styles.todoIcon}>!</span>}
Level
</label>
<Select
id={`class-level-${classId}`}
value={level}
options={levelOptions}
initialOptionRemoved={true}
onChangePromise={handleLevelChangePromise}
/>
</div>
<Button
className={styles.removeClassButton}
size="x-small"
variant="text"
onClick={handleRemoveClass}
forceThemeMode="light"
>
<CloseIcon className={styles.icon} />
</Button>
</div>
</div>
);
};