``` ~/go/bin/sourcemapper -output ddb -jsurl https://media.dndbeyond.com/character-app/static/js/main.90aa78c5.js ```
331 lines
9.6 KiB
TypeScript
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>
|
|
);
|
|
};
|