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,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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user