Grabbed dndbeyond's source code
``` ~/go/bin/sourcemapper -output ddb -jsurl https://media.dndbeyond.com/character-app/static/js/main.90aa78c5.js ```
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
import clsx from "clsx";
|
||||
import { HTMLAttributes, ReactNode, useEffect, useState } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { useNavigate, Link } from "react-router-dom";
|
||||
|
||||
import {
|
||||
characterEnvSelectors,
|
||||
Constants,
|
||||
} from "@dndbeyond/character-rules-engine";
|
||||
import ChevronRight from "@dndbeyond/fontawesome-cache/svgs/solid/chevron-right.svg";
|
||||
|
||||
import { Button } from "~/components/Button";
|
||||
import { Checkbox } from "~/components/Checkbox";
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
import { builderActions } from "~/tools/js/CharacterBuilder/actions";
|
||||
import { navigationConfig } from "~/tools/js/CharacterBuilder/config";
|
||||
import { NavigationUtils } from "~/tools/js/CharacterBuilder/utils";
|
||||
|
||||
import { BuilderMethod, RouteKey } from "../../constants";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLDivElement> {
|
||||
isEnabled?: boolean;
|
||||
}
|
||||
|
||||
export const BuilderTypeChoicePage: React.FC<Props> = ({
|
||||
isEnabled = true,
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
const params = new URLSearchParams(globalThis.location.search);
|
||||
const isMobileApp = params.get("platform");
|
||||
const [showHelpText, setShowHelpText] = useState(false);
|
||||
const dispatch = useDispatch();
|
||||
const navigate = useNavigate();
|
||||
const { characterId } = useCharacterEngine();
|
||||
const characterIsLoading = useSelector(
|
||||
characterEnvSelectors.getLoadingStatus
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (characterIsLoading === Constants.CharacterLoadingStatusEnum.LOADED) {
|
||||
navigate(NavigationUtils.getCharacterBuilderUrl(characterId), {
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
}, [characterIsLoading, characterId, navigate]);
|
||||
|
||||
const handleMethodSet = (method: string) => {
|
||||
if (method === BuilderMethod.STEP_BY_STEP) {
|
||||
dispatch(builderActions.stepBuildRequest(showHelpText));
|
||||
} else {
|
||||
dispatch(builderActions.builderMethodSet(method));
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyUp = (
|
||||
evt: React.KeyboardEvent<HTMLDivElement>,
|
||||
method: string
|
||||
): void => {
|
||||
if (evt.key === "Enter") {
|
||||
handleMethodSet(method);
|
||||
}
|
||||
};
|
||||
|
||||
const renderFooter = (text: string = "Start Building") => (
|
||||
<div className={clsx([styles.cardFooter, styles.font])}>
|
||||
<p>{text}</p>
|
||||
<ChevronRight className={styles.icon} />
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderCardContent = (
|
||||
title: string,
|
||||
description: string,
|
||||
extraNode?: ReactNode
|
||||
) => (
|
||||
<div className={styles.cardContent}>
|
||||
<h3 className={clsx([styles.cardTitle, styles.font])}>{title}</h3>
|
||||
<p className={clsx([styles.cardDescription, styles.font])}>
|
||||
{description}
|
||||
</p>
|
||||
{extraNode}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={clsx([styles.page, className])} {...props}>
|
||||
<h1 className={styles.header}>Character Creation Method</h1>
|
||||
<h2 className={clsx([styles.header])}>
|
||||
Choose how you would like to create your character
|
||||
</h2>
|
||||
<section className={styles.grid}>
|
||||
{/* STANDARD BUILD */}
|
||||
<div
|
||||
onClick={() => handleMethodSet(BuilderMethod.STEP_BY_STEP)}
|
||||
onKeyUp={(event) => onKeyUp(event, BuilderMethod.STEP_BY_STEP)}
|
||||
className={styles.card}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className={clsx([styles.img, styles.standardBuild])} />
|
||||
{renderCardContent(
|
||||
"Standard",
|
||||
"Create a character using a step-by-step approach",
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (isEnabled) setShowHelpText(!showHelpText);
|
||||
}}
|
||||
className={styles.helpCheckbox}
|
||||
>
|
||||
<Checkbox
|
||||
id="q-include-help"
|
||||
aria-labelledby="include-help-text"
|
||||
checked={showHelpText}
|
||||
disabled={!isEnabled}
|
||||
darkMode
|
||||
/>
|
||||
<label
|
||||
id="include-help-text"
|
||||
htmlFor="q-include-help"
|
||||
className={styles.helpText}
|
||||
>
|
||||
<strong>Beginner?</strong> Show help text
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
{renderFooter()}
|
||||
</div>
|
||||
{/* PREMADES */}
|
||||
{!isMobileApp && (
|
||||
<Button href="/characters/premade" className={styles.card}>
|
||||
<div className={clsx([styles.img, styles.premades])} />
|
||||
{renderCardContent(
|
||||
"Premade",
|
||||
"Browse a selection of ready-to-play, premade characters and claim one to your account."
|
||||
)}
|
||||
{renderFooter("Start Browsing")}
|
||||
</Button>
|
||||
)}
|
||||
{/* QUICK BUILD */}
|
||||
<Link
|
||||
className={styles.card}
|
||||
to={navigationConfig.getRouteDefPath(RouteKey.QUICK_BUILD)}
|
||||
onClick={() => handleMethodSet(BuilderMethod.QUICK)}
|
||||
>
|
||||
{renderCardContent(
|
||||
"Quick Build",
|
||||
"Choose a species and class to quickly create a level 1 character."
|
||||
)}
|
||||
{renderFooter()}
|
||||
</Link>
|
||||
{/* RANDOM BUILD */}
|
||||
<Link
|
||||
className={styles.card}
|
||||
to={navigationConfig.getRouteDefPath(RouteKey.RANDOMIZE_BUILD)}
|
||||
onClick={() => handleMethodSet(BuilderMethod.RANDOMIZE)}
|
||||
>
|
||||
{renderCardContent(
|
||||
"Random",
|
||||
"Roll up a randomized character! You can optionally set some parameters such as level, species, and class."
|
||||
)}
|
||||
{renderFooter()}
|
||||
</Link>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,330 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,410 @@
|
||||
import clsx from "clsx";
|
||||
import { FC, HTMLAttributes, useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { Accordion } from "~/components/Accordion";
|
||||
import { HtmlContent } from "~/components/HtmlContent";
|
||||
import { Link } from "~/components/Link";
|
||||
import { PreferenceProgressionTypeEnum as progressionType } from "~/constants";
|
||||
import { orderBy } from "~/helpers/sortUtils";
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
import { useSource } from "~/hooks/useSource";
|
||||
import { navigationConfig } from "~/tools/js/CharacterBuilder/config";
|
||||
|
||||
import { Button } from "../../../../components/Button";
|
||||
import { ConfirmClassModal } from "../../components/ConfirmClassModal";
|
||||
import { Listing } from "../../components/Listing";
|
||||
import { PortraitName } from "../../components/PortraitName";
|
||||
import { Search } from "../../components/Search";
|
||||
import { Spinner } from "../../components/Spinner";
|
||||
import { RouteKey } from "../../constants";
|
||||
import { useClassContext } from "../../contexts/Class";
|
||||
import { getMissingRequirements } from "../../helpers/getMissingRequirements";
|
||||
import {
|
||||
ClassDefinitionContract,
|
||||
ClassItems,
|
||||
ClassRequirements,
|
||||
ListingItem,
|
||||
MulticlassAvailability,
|
||||
} from "../../types";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
//showHeader and OnQuickSelect are only be used from QuickBuild.tsx and RandomBuild.tsx
|
||||
export interface ClassChooseProps extends HTMLAttributes<HTMLDivElement> {
|
||||
showHeader?: boolean;
|
||||
onQuickSelect?: (charClass: ClassDefinitionContract) => void;
|
||||
}
|
||||
|
||||
export interface SourceGroupMapping {
|
||||
name: string;
|
||||
id: number;
|
||||
items: ClassDefinitionContract[];
|
||||
sortOrder: number | undefined;
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The page which lists all available classes for the user to choose
|
||||
* from when building or updating a character. Can be found at
|
||||
* `/characters/:characterId/builder/class/choose`.
|
||||
*/
|
||||
export const ClassChoose: FC<ClassChooseProps> = ({
|
||||
showHeader = true,
|
||||
className,
|
||||
onQuickSelect,
|
||||
...props
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
classes: charClasses,
|
||||
preferences,
|
||||
prerequisiteData,
|
||||
startingClass,
|
||||
totalClassLevel,
|
||||
currentLevel,
|
||||
characterId,
|
||||
classUtils,
|
||||
helperUtils,
|
||||
prerequisiteUtils: {
|
||||
validatePrerequisiteGrouping: validateGrouping,
|
||||
getPrerequisiteGroupingFailures: getGroupingFailures,
|
||||
},
|
||||
} = useCharacterEngine();
|
||||
const { allSources, getSourceCategoryDescription, getSourceCategoryGroups } =
|
||||
useSource();
|
||||
const {
|
||||
filteredClasses,
|
||||
allClasses,
|
||||
handleSelectClass,
|
||||
query,
|
||||
setQuery,
|
||||
isLoading,
|
||||
isModalShowing,
|
||||
closeModal,
|
||||
} = useClassContext();
|
||||
|
||||
const [mappedSourceCategoryGroups, setMappedSourceCategoryGroups] = useState<
|
||||
SourceGroupMapping[]
|
||||
>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const sourceCategoryGroups = getSourceCategoryGroups(filteredClasses);
|
||||
setMappedSourceCategoryGroups(
|
||||
sourceCategoryGroups.map((group) => ({
|
||||
...group,
|
||||
isOpen: true,
|
||||
}))
|
||||
);
|
||||
}, [filteredClasses]);
|
||||
|
||||
// Determine if the character's XP will be adjusted when adding a new class
|
||||
const willXpBeAdjusted =
|
||||
charClasses.length > 0 &&
|
||||
preferences.progressionType === progressionType.XP &&
|
||||
totalClassLevel + 1 > currentLevel;
|
||||
|
||||
// Navigate back to the class manage page
|
||||
const handleNavigate = (): void =>
|
||||
navigate(
|
||||
navigationConfig
|
||||
.getRouteDefPath(RouteKey.CLASS_MANAGE)
|
||||
.replace(":characterId", characterId)
|
||||
);
|
||||
|
||||
// Return requirements for multiclassing based on existing classes
|
||||
const getMulticlassAvailability = (
|
||||
classes: ClassItems
|
||||
): Array<MulticlassAvailability> => {
|
||||
const { enforceMulticlassRules } = preferences;
|
||||
let canStartingClassMulticlass = false;
|
||||
let startingClassRequirements: ClassRequirements = [];
|
||||
|
||||
if (enforceMulticlassRules) {
|
||||
if (startingClass) {
|
||||
canStartingClassMulticlass = validateGrouping(
|
||||
classUtils.getPrerequisites(startingClass),
|
||||
prerequisiteData
|
||||
);
|
||||
startingClassRequirements = getGroupingFailures(
|
||||
classUtils.getPrerequisites(startingClass),
|
||||
prerequisiteData
|
||||
);
|
||||
}
|
||||
} else {
|
||||
canStartingClassMulticlass = true;
|
||||
}
|
||||
|
||||
return classes.map(
|
||||
({ id: classId, prerequisites }): MulticlassAvailability => {
|
||||
let canMulticlass = false;
|
||||
const missingRequirements = getGroupingFailures(
|
||||
prerequisites,
|
||||
prerequisiteData
|
||||
);
|
||||
|
||||
if (enforceMulticlassRules) {
|
||||
canMulticlass =
|
||||
canStartingClassMulticlass &&
|
||||
validateGrouping(prerequisites, prerequisiteData);
|
||||
} else {
|
||||
canMulticlass = true;
|
||||
}
|
||||
|
||||
return {
|
||||
classId,
|
||||
canStartingClassMulticlass,
|
||||
startingClassRequirements,
|
||||
canMulticlass,
|
||||
missingRequirements,
|
||||
};
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const getDisabledIds = (classes: ClassItems): Array<number> => {
|
||||
if (startingClass === null || !classes.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// const classDefinitions = classes.map((item) => item.entity);
|
||||
const multiclassAvailability = getMulticlassAvailability(classes);
|
||||
|
||||
// disable any current classes
|
||||
let currentClassIds = charClasses.map((charClass) =>
|
||||
classUtils.getId(charClass)
|
||||
);
|
||||
|
||||
// disable any multiclass options that don't meet required prerequisites
|
||||
let canMulticlassIds = multiclassAvailability
|
||||
.filter((classInfo) => !classInfo.canMulticlass)
|
||||
.map((classInfo) => classInfo.classId);
|
||||
|
||||
return [...currentClassIds, ...canMulticlassIds];
|
||||
};
|
||||
|
||||
// Transform the class items into a format that listing can use
|
||||
const transformClassItems = (data: ClassItems): Array<ListingItem> => {
|
||||
let classDefinitions = [...data];
|
||||
|
||||
// Get the multiclass availability for each class
|
||||
const availability = getMulticlassAvailability(classDefinitions);
|
||||
|
||||
return classDefinitions.map((classItem: ClassDefinitionContract) => {
|
||||
const { id, sources, name, portraitAvatarUrl } = classItem;
|
||||
// Find the class in the character's classes
|
||||
const existingClass = charClasses.find(
|
||||
(cls) => classUtils.getId(cls) === id
|
||||
);
|
||||
|
||||
// Find the multiclass info for the class
|
||||
const multiclassInfo = availability.find((cls) => cls.classId === id);
|
||||
let metaItems: Array<{ type: string; text: string }> = [];
|
||||
|
||||
// Add the source descriptions to the meta items
|
||||
if (sources)
|
||||
sources.forEach(({ sourceId }) => {
|
||||
let source = helperUtils.lookupDataOrFallback(allSources, sourceId);
|
||||
|
||||
// If a source has a description and is toggleable, add it to the meta items
|
||||
if (
|
||||
source?.description !== null &&
|
||||
source?.sourceCategory?.isToggleable
|
||||
)
|
||||
metaItems.push({
|
||||
type: "normal",
|
||||
text: source.description,
|
||||
});
|
||||
});
|
||||
|
||||
// If the character is already multiclassed
|
||||
if (charClasses && charClasses.length >= 1) {
|
||||
if (existingClass) {
|
||||
const { level, isStartingClass } = existingClass;
|
||||
const currentLevel = `Level ${level}`;
|
||||
const startingClass = isStartingClass ? " • Starting Class" : "";
|
||||
|
||||
// Add the class level to the meta items
|
||||
metaItems.push({
|
||||
type: "normal",
|
||||
text: `${currentLevel}${startingClass}`,
|
||||
});
|
||||
}
|
||||
// If the class is the starting class
|
||||
else if (multiclassInfo) {
|
||||
const {
|
||||
canMulticlass,
|
||||
canStartingClassMulticlass,
|
||||
missingRequirements,
|
||||
startingClassRequirements,
|
||||
} = multiclassInfo;
|
||||
// Add an error message to the meta items
|
||||
if (!canMulticlass || !canStartingClassMulticlass)
|
||||
metaItems.push({
|
||||
type: "error",
|
||||
text: !canStartingClassMulticlass
|
||||
? `Starting class does not meet multiclass prerequisites: ${getMissingRequirements(
|
||||
startingClassRequirements
|
||||
)}`
|
||||
: !canMulticlass
|
||||
? `Prerequisites not met: ${getMissingRequirements(
|
||||
missingRequirements
|
||||
)}`
|
||||
: "",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
metaItems,
|
||||
entityTypeId: -1,
|
||||
heading: name || "",
|
||||
image: portraitAvatarUrl || null,
|
||||
onClick: handleSelectClass,
|
||||
entity: classItem,
|
||||
type: "class",
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleOverrideClick = (override): void => {
|
||||
const updatedGroups = mappedSourceCategoryGroups.map((group) => ({
|
||||
...group,
|
||||
isOpen: override,
|
||||
}));
|
||||
|
||||
setMappedSourceCategoryGroups(updatedGroups);
|
||||
};
|
||||
|
||||
const handleSourceCategoryClick = (id: string, state: boolean): void => {
|
||||
const parsedId = parseInt(id);
|
||||
const updatedGroups = mappedSourceCategoryGroups.map((group) => ({
|
||||
...group,
|
||||
isOpen: group.id === parsedId ? state : group.isOpen,
|
||||
}));
|
||||
|
||||
setMappedSourceCategoryGroups(updatedGroups);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={clsx([styles.page, className])} {...props}>
|
||||
{showHeader && (
|
||||
<>
|
||||
<PortraitName />
|
||||
<hr className={styles.divider} />
|
||||
<h2 className={styles.title}>
|
||||
Choose a Class{startingClass && " to Multiclass"}
|
||||
</h2>
|
||||
{startingClass && (
|
||||
<>
|
||||
<p className={styles.text}>
|
||||
Learn more about multiclassing{" "}
|
||||
<Link
|
||||
href="/sources/dnd/free-rules/creating-a-character#Multiclassing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
here
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
<p className={styles.multiclassMessage}>
|
||||
Classes from the 2024 and 2014 rulebooks are not designed to be
|
||||
multiclassed together.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{charClasses.length > 0 && (
|
||||
<div className={styles.buttons}>
|
||||
{willXpBeAdjusted && (
|
||||
<p className={styles.error}>
|
||||
Your XP total will be adjusted when you add your new class.
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
className={styles.navigateButton}
|
||||
variant="builder"
|
||||
onClick={handleNavigate}
|
||||
>
|
||||
Cancel Class Add
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{isLoading ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<>
|
||||
<Search
|
||||
className={styles.search}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
|
||||
<div className={styles.collapseExpand}>
|
||||
<Button
|
||||
variant="text"
|
||||
size="x-small"
|
||||
className={styles.collapseExpandButton}
|
||||
onClick={() => handleOverrideClick(false)}
|
||||
forceThemeMode="light"
|
||||
>
|
||||
Collapse All
|
||||
</Button>
|
||||
<Button
|
||||
variant="text"
|
||||
size="x-small"
|
||||
className={styles.collapseExpandButton}
|
||||
onClick={() => handleOverrideClick(true)}
|
||||
forceThemeMode="light"
|
||||
>
|
||||
Expand All
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{mappedSourceCategoryGroups.length > 0 ? (
|
||||
mappedSourceCategoryGroups.map((category) => {
|
||||
const description = getSourceCategoryDescription(category.id);
|
||||
const metaItems = description
|
||||
? [
|
||||
<HtmlContent
|
||||
html={description}
|
||||
className={styles.accordionText}
|
||||
/>,
|
||||
]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<Accordion
|
||||
id={category.id.toString()}
|
||||
className={styles.accordion}
|
||||
summary={<h3 className={styles.heading}>{category.name}</h3>}
|
||||
summaryMetaItems={metaItems}
|
||||
variant="text"
|
||||
resetOpen={allClasses.length !== filteredClasses.length}
|
||||
key={category.id}
|
||||
handleIsOpen={handleSourceCategoryClick}
|
||||
override={category.isOpen}
|
||||
forceShow
|
||||
>
|
||||
<Listing
|
||||
items={orderBy(
|
||||
transformClassItems(category.items),
|
||||
"heading"
|
||||
)}
|
||||
disabledIds={getDisabledIds(category.items)}
|
||||
onQuickSelect={onQuickSelect}
|
||||
/>
|
||||
</Accordion>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<p className={styles.notFound}>No Results Found</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<ConfirmClassModal open={isModalShowing} onClose={closeModal} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
import clsx from "clsx";
|
||||
import { FC, HTMLAttributes, useState } from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
|
||||
import { Accordion } from "~/components/Accordion";
|
||||
import { Button } from "~/components/Button";
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
import { builderActions } from "~/tools/js/CharacterBuilder/actions";
|
||||
import ClassDisplaySimple from "~/tools/js/CharacterBuilder/components/ClassDisplaySimple";
|
||||
import SpeciesDisplaySimple from "~/tools/js/CharacterBuilder/components/SpeciesDisplaySimple";
|
||||
import { ClassDefinitionContract, RaceDefinitionContract } from "~/types";
|
||||
|
||||
import { PortraitName } from "../../components/PortraitName";
|
||||
import { ClassChoose } from "../ClassChoose";
|
||||
import { SpeciesChoose } from "../SpeciesChoose";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
export interface QuickBuildProps extends HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
export const QuickBuild: FC<QuickBuildProps> = ({ className, ...props }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { raceUtils, ruleData } = useCharacterEngine();
|
||||
|
||||
const [species, setSpecies] = useState<RaceDefinitionContract | null>(null);
|
||||
const [charClass, setCharClass] = useState<ClassDefinitionContract | null>(
|
||||
null
|
||||
);
|
||||
const [name, setName] = useState("");
|
||||
const [isClassesOpen, setIsClassesOpen] = useState(false);
|
||||
const [isSpeciesOpen, setIsSpeciesOpen] = useState(false);
|
||||
|
||||
const handleSubmit = (): void => {
|
||||
dispatch(
|
||||
builderActions.quickBuildRequest(
|
||||
species ? raceUtils.getEntityRaceId(species) : null,
|
||||
species ? raceUtils.getEntityRaceTypeId(species) : null,
|
||||
charClass ? charClass.id : null,
|
||||
name
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const handleSelectSpecies = (species: RaceDefinitionContract): void => {
|
||||
setSpecies(species);
|
||||
};
|
||||
const handleRemoveSpecies = (): void => {
|
||||
setSpecies(null);
|
||||
setIsSpeciesOpen(true);
|
||||
};
|
||||
const handleSelectClass = (charClass: ClassDefinitionContract): void => {
|
||||
setCharClass(charClass);
|
||||
};
|
||||
const handleRemoveClass = (): void => {
|
||||
setCharClass(null);
|
||||
setIsClassesOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={clsx([styles.page, className])} {...props}>
|
||||
<h1 className={styles.title}>Quick Build</h1>
|
||||
<p>
|
||||
To create a level 1 character with recommended starting options, choose
|
||||
a class and species below. You can provide a character name or leave it
|
||||
blank to randomize one.
|
||||
</p>
|
||||
<hr className={styles.divider} />
|
||||
{charClass ? (
|
||||
<ClassDisplaySimple
|
||||
onRequestChange={handleRemoveClass}
|
||||
charClass={charClass}
|
||||
/>
|
||||
) : (
|
||||
<Accordion
|
||||
className={styles.accordion}
|
||||
summary={<h3 className={styles.accordionHeading}>Choose Class</h3>}
|
||||
variant="text"
|
||||
resetOpen={isClassesOpen}
|
||||
>
|
||||
<ClassChoose showHeader={false} onQuickSelect={handleSelectClass} />
|
||||
</Accordion>
|
||||
)}
|
||||
{species ? (
|
||||
<SpeciesDisplaySimple
|
||||
onRequestAction={handleRemoveSpecies}
|
||||
headingText="Selected Species"
|
||||
species={species}
|
||||
ruleData={ruleData}
|
||||
/>
|
||||
) : (
|
||||
<Accordion
|
||||
className={styles.accordion}
|
||||
summary={<h3 className={styles.accordionHeading}>Choose Species</h3>}
|
||||
variant="text"
|
||||
resetOpen={isSpeciesOpen}
|
||||
>
|
||||
<SpeciesChoose
|
||||
showHeader={false}
|
||||
onQuickSelect={handleSelectSpecies}
|
||||
/>
|
||||
</Accordion>
|
||||
)}
|
||||
<PortraitName hidePortrait fullWidth handleNameUpdate={setName} />
|
||||
<Button
|
||||
className={styles.submitButton}
|
||||
onClick={handleSubmit}
|
||||
disabled={!species || !charClass}
|
||||
variant="builder"
|
||||
>
|
||||
Create Character
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,174 @@
|
||||
import clsx from "clsx";
|
||||
import { FC, HTMLAttributes, useState } from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
|
||||
import { Select } from "@dndbeyond/character-components/es";
|
||||
|
||||
import { Accordion } from "~/components/Accordion";
|
||||
import { Button } from "~/components/Button";
|
||||
import { Toggle } from "~/components/Toggle";
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
import { builderActions } from "~/tools/js/CharacterBuilder/actions";
|
||||
import ClassDisplaySimple from "~/tools/js/CharacterBuilder/components/ClassDisplaySimple";
|
||||
import SpeciesDisplaySimple from "~/tools/js/CharacterBuilder/components/SpeciesDisplaySimple";
|
||||
import { ClassDefinitionContract, RaceDefinitionContract } from "~/types";
|
||||
|
||||
import { PortraitName } from "../../components/PortraitName";
|
||||
import { ClassChoose } from "../ClassChoose";
|
||||
import { SpeciesChoose } from "../SpeciesChoose";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
export interface RandomBuildProps extends HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
export const RandomBuild: FC<RandomBuildProps> = ({ className, ...props }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { raceUtils, ruleData, helperUtils } = useCharacterEngine();
|
||||
|
||||
const [species, setSpecies] = useState<RaceDefinitionContract | null>(null);
|
||||
const [charClass, setCharClass] = useState<ClassDefinitionContract | null>(
|
||||
null
|
||||
);
|
||||
const [name, setName] = useState("");
|
||||
const [level, setLevel] = useState<number | null>(null);
|
||||
const [allowMulticlass, setAllowMulticlass] = useState(false);
|
||||
const [allowFeats, setAllowFeats] = useState(false);
|
||||
const [isClassesOpen, setIsClassesOpen] = useState(false);
|
||||
const [isSpeciesOpen, setIsSpeciesOpen] = useState(false);
|
||||
|
||||
const handleSubmit = (): void => {
|
||||
dispatch(
|
||||
builderActions.randomBuildRequest(
|
||||
level,
|
||||
species ? raceUtils.getEntityRaceId(species) : null,
|
||||
species ? raceUtils.getEntityRaceTypeId(species) : null,
|
||||
charClass ? charClass.id : null,
|
||||
allowMulticlass,
|
||||
allowFeats,
|
||||
name
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const handleSelectSpecies = (species: RaceDefinitionContract): void => {
|
||||
setSpecies(species);
|
||||
};
|
||||
const handleRemoveSpecies = (): void => {
|
||||
setSpecies(null);
|
||||
setIsSpeciesOpen(true);
|
||||
};
|
||||
const handleSelectClass = (charClass: ClassDefinitionContract): void => {
|
||||
setCharClass(charClass);
|
||||
};
|
||||
const handleRemoveClass = (): void => {
|
||||
setCharClass(null);
|
||||
setIsClassesOpen(true);
|
||||
};
|
||||
const handleLevelChange = (level: string): void => {
|
||||
setLevel(helperUtils.parseInputInt(level));
|
||||
};
|
||||
const handleToggleAllowMulticlass = (): void => {
|
||||
setAllowMulticlass(!allowMulticlass);
|
||||
};
|
||||
const handleToggleAllowFeats = (): void => {
|
||||
setAllowFeats(!allowFeats);
|
||||
};
|
||||
|
||||
const levels = Array.from(
|
||||
new Array(ruleData.maxCharacterLevel),
|
||||
(val, index) => index + 1
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={clsx([styles.page, className])} {...props}>
|
||||
<h1 className={styles.title}>Random Build</h1>
|
||||
<p>
|
||||
Create a randomized character. Make it completely random, or make
|
||||
choices in some categories and randomize the rest. Once you've made your
|
||||
selections, click <strong>Create Character</strong> to generate a
|
||||
character sheet and start playing!
|
||||
</p>
|
||||
<hr className={styles.divider} />
|
||||
<div className={styles.levelSelect}>
|
||||
<label className={styles.label} id="choose-level">
|
||||
Choose Level
|
||||
</label>
|
||||
<Select
|
||||
className={styles.select}
|
||||
placeholder={"--"}
|
||||
options={levels.map((level) => ({
|
||||
value: level,
|
||||
label: level,
|
||||
}))}
|
||||
onChange={handleLevelChange}
|
||||
aria-labelledby="choose-level"
|
||||
value={level}
|
||||
/>
|
||||
</div>
|
||||
{charClass ? (
|
||||
<ClassDisplaySimple
|
||||
onRequestChange={handleRemoveClass}
|
||||
charClass={charClass}
|
||||
/>
|
||||
) : (
|
||||
<Accordion
|
||||
className={styles.accordion}
|
||||
summary={<h3 className={styles.accordionHeading}>Choose Class</h3>}
|
||||
variant="text"
|
||||
resetOpen={isClassesOpen}
|
||||
>
|
||||
<ClassChoose showHeader={false} onQuickSelect={handleSelectClass} />
|
||||
</Accordion>
|
||||
)}
|
||||
{species ? (
|
||||
<SpeciesDisplaySimple
|
||||
onRequestAction={handleRemoveSpecies}
|
||||
headingText="Selected Species"
|
||||
species={species}
|
||||
ruleData={ruleData}
|
||||
/>
|
||||
) : (
|
||||
<Accordion
|
||||
className={styles.accordion}
|
||||
summary={<h3 className={styles.accordionHeading}>Choose Species</h3>}
|
||||
variant="text"
|
||||
resetOpen={isSpeciesOpen}
|
||||
>
|
||||
<SpeciesChoose
|
||||
showHeader={false}
|
||||
onQuickSelect={handleSelectSpecies}
|
||||
/>
|
||||
</Accordion>
|
||||
)}
|
||||
<div className={styles.toggle}>
|
||||
<label className={styles.label} id="allow-multiclass-label">
|
||||
Allow Multiclass
|
||||
</label>
|
||||
<Toggle
|
||||
onClick={handleToggleAllowMulticlass}
|
||||
checked={allowMulticlass}
|
||||
color="secondary"
|
||||
aria-labelledby="allow-multiclass-label"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.toggle}>
|
||||
<label className={styles.label} id="allow-feats-label">
|
||||
Allow Feats
|
||||
</label>
|
||||
<Toggle
|
||||
onClick={handleToggleAllowFeats}
|
||||
checked={allowFeats}
|
||||
color="secondary"
|
||||
aria-labelledby="allow-feats-label"
|
||||
/>
|
||||
</div>
|
||||
<PortraitName hidePortrait fullWidth handleNameUpdate={setName} />
|
||||
<Button
|
||||
className={styles.submitButton}
|
||||
onClick={handleSubmit}
|
||||
variant="builder"
|
||||
>
|
||||
Create Character
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,244 @@
|
||||
import clsx from "clsx";
|
||||
import { FC, HTMLAttributes, useEffect, useState } from "react";
|
||||
|
||||
import CircleInfo from "@dndbeyond/fontawesome-cache/svgs/regular/circle-info.svg";
|
||||
|
||||
import { Accordion } from "~/components/Accordion";
|
||||
import { HtmlContent } from "~/components/HtmlContent";
|
||||
import { Link } from "~/components/Link";
|
||||
import { Toggle } from "~/components/Toggle";
|
||||
import { Tooltip } from "~/components/Tooltip";
|
||||
import { orderBy } from "~/helpers/sortUtils";
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
import { useSource } from "~/hooks/useSource";
|
||||
import { Select } from "~/tools/js/smartComponents/legacy";
|
||||
import { RaceDefinitionContract } from "~/types";
|
||||
|
||||
import { Button } from "../../../../components/Button";
|
||||
import { ConfirmSpeciesModal } from "../../components/ConfirmSpeciesModal";
|
||||
import { Listing } from "../../components/Listing";
|
||||
import { PortraitName } from "../../components/PortraitName";
|
||||
import { Search } from "../../components/Search";
|
||||
import { SpeciesDisplay } from "../../components/SpeciesDisplay";
|
||||
import { Spinner } from "../../components/Spinner";
|
||||
import { useSpeciesContext } from "../../contexts/Species";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
//showHeader and OnQuickSelect are only be used from QuickBuild.tsx and RandomBuild.tsx
|
||||
export interface SpeciesChooseProps extends HTMLAttributes<HTMLDivElement> {
|
||||
showHeader?: boolean;
|
||||
onQuickSelect?: (species: RaceDefinitionContract) => void;
|
||||
}
|
||||
|
||||
export interface SourceGroupMapping {
|
||||
name: string;
|
||||
id: number;
|
||||
items: RaceDefinitionContract[];
|
||||
sortOrder: number | undefined;
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
export const SpeciesChoose: FC<SpeciesChooseProps> = ({
|
||||
showHeader = true,
|
||||
onQuickSelect,
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
const { race: currentSpecies } = useCharacterEngine();
|
||||
const {
|
||||
allSpecies,
|
||||
closeModal,
|
||||
filteredSpecies,
|
||||
isLegacyShowing,
|
||||
isModalShowing,
|
||||
setSource,
|
||||
source,
|
||||
sourceOptions,
|
||||
toggleLegacyContent,
|
||||
transformSpecies,
|
||||
query,
|
||||
setQuery,
|
||||
isLoading,
|
||||
getSpeciesInGroups,
|
||||
} = useSpeciesContext();
|
||||
const { getSourceCategoryGroups, getSourceCategoryDescription } = useSource();
|
||||
const [mappedSourceCategoryGroups, setMappedSourceCategoryGroups] = useState<
|
||||
SourceGroupMapping[]
|
||||
>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const sourceCategoryGroups = getSourceCategoryGroups(filteredSpecies);
|
||||
setMappedSourceCategoryGroups(
|
||||
sourceCategoryGroups.map((group) => ({
|
||||
...group,
|
||||
isOpen: true,
|
||||
}))
|
||||
);
|
||||
}, [filteredSpecies]);
|
||||
|
||||
const handleOverrideClick = (override): void => {
|
||||
const updatedGroups = mappedSourceCategoryGroups.map((group) => ({
|
||||
...group,
|
||||
isOpen: override,
|
||||
}));
|
||||
|
||||
setMappedSourceCategoryGroups(updatedGroups);
|
||||
};
|
||||
|
||||
const handleSourceCategoryClick = (id: string, state: boolean): void => {
|
||||
const parsedId = parseInt(id);
|
||||
const updatedGroups = mappedSourceCategoryGroups.map((group) => ({
|
||||
...group,
|
||||
isOpen: group.id === parsedId ? state : group.isOpen,
|
||||
}));
|
||||
|
||||
setMappedSourceCategoryGroups(updatedGroups);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={clsx([styles.speciesChoose, className])} {...props}>
|
||||
{showHeader && (
|
||||
<>
|
||||
<PortraitName />
|
||||
<hr className={styles.divider} />
|
||||
<h2 className={styles.title}>
|
||||
{currentSpecies ? "Change Origin: " : "Choose Origin: "}
|
||||
{"Species"}
|
||||
</h2>
|
||||
{currentSpecies && (
|
||||
<>
|
||||
<SpeciesDisplay
|
||||
headingText="Current Species"
|
||||
actionText="Keep Species"
|
||||
/>
|
||||
<h3 className={styles.title}>Select New Species</h3>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className={styles.filters}>
|
||||
<div>
|
||||
<div className={styles.label}>Filter Species Source(s)</div>
|
||||
<Select
|
||||
placeholder={"-- All Sources --"}
|
||||
options={sourceOptions}
|
||||
value={source}
|
||||
onChange={(value) => setSource(value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.legacy}>
|
||||
<Toggle
|
||||
className={styles.toggle}
|
||||
onClick={toggleLegacyContent}
|
||||
checked={isLegacyShowing}
|
||||
color="secondary"
|
||||
aria-labelledby="legacy-content-label"
|
||||
/>
|
||||
<label id="legacy-content-label">
|
||||
Show Legacy Content{" "}
|
||||
<>
|
||||
<CircleInfo
|
||||
data-tooltip-id="legacy-info"
|
||||
data-tooltip-place="bottom"
|
||||
className={styles.infoIcon}
|
||||
/>
|
||||
<Tooltip id="legacy-info" clickable>
|
||||
Legacy content doesn't reflect the latest rules and lore.{" "}
|
||||
<Link
|
||||
href="https://dndbeyond.com/legacy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Learn More
|
||||
</Link>
|
||||
</Tooltip>
|
||||
</>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<hr className={styles.divider} />
|
||||
<div className={clsx([styles.text, styles.marketplace])}>
|
||||
Looking for something not in the list below? Unlock all official options
|
||||
in the <Link href="/marketplace">Marketplace</Link>.
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<>
|
||||
<Search
|
||||
className={styles.search}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<div className={styles.collapseExpand}>
|
||||
<Button
|
||||
variant="text"
|
||||
size="x-small"
|
||||
className={styles.collapseExpandButton}
|
||||
onClick={() => handleOverrideClick(false)}
|
||||
forceThemeMode="light"
|
||||
>
|
||||
Collapse All
|
||||
</Button>
|
||||
<Button
|
||||
variant="text"
|
||||
size="x-small"
|
||||
className={styles.collapseExpandButton}
|
||||
onClick={() => handleOverrideClick(true)}
|
||||
forceThemeMode="light"
|
||||
>
|
||||
Expand All
|
||||
</Button>
|
||||
</div>
|
||||
{mappedSourceCategoryGroups.length > 0 ? (
|
||||
mappedSourceCategoryGroups.map((category) => {
|
||||
const name = category.name;
|
||||
const id = category.id;
|
||||
const description = getSourceCategoryDescription(category.id);
|
||||
|
||||
const currentSpeciesId = currentSpecies
|
||||
? [
|
||||
`${currentSpecies.entityRaceId}-${currentSpecies.entityRaceTypeId}`,
|
||||
]
|
||||
: [];
|
||||
|
||||
const groupedSpecies = getSpeciesInGroups(
|
||||
transformSpecies(category.items)
|
||||
);
|
||||
|
||||
const metaItems = description
|
||||
? [<HtmlContent html={description} className={styles.text} />]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<Accordion
|
||||
id={category.id.toString()}
|
||||
className={styles.accordion}
|
||||
summary={<h3 className={styles.heading}>{name}</h3>}
|
||||
summaryMetaItems={metaItems}
|
||||
variant="text"
|
||||
resetOpen={allSpecies.length !== filteredSpecies.length}
|
||||
key={id}
|
||||
forceShow
|
||||
override={category.isOpen}
|
||||
handleIsOpen={handleSourceCategoryClick}
|
||||
>
|
||||
<Listing
|
||||
items={orderBy(groupedSpecies, "heading")}
|
||||
disabledIds={currentSpeciesId}
|
||||
onQuickSelect={onQuickSelect}
|
||||
/>
|
||||
</Accordion>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<p className={styles.notFound}>No Results Found</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<ConfirmSpeciesModal open={isModalShowing} onClose={closeModal} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user