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:
2025-05-28 11:50:03 -07:00
commit 8df9031d27
3592 changed files with 319051 additions and 0 deletions
@@ -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>
);
};