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,126 @@
|
||||
import { orderBy } from "lodash";
|
||||
import { FC, Fragment, useMemo } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import ArrowRightIcon from "@dndbeyond/fontawesome-cache/svgs/solid/arrow-right.svg";
|
||||
|
||||
import { Accordion } from "~/components/Accordion";
|
||||
import { Button } from "~/components/Button";
|
||||
import { CollapsibleContent } from "~/components/CollapsibleContent";
|
||||
import { ConfirmModal, ConfirmModalProps } from "~/components/ConfirmModal";
|
||||
import { HtmlContent } from "~/components/HtmlContent";
|
||||
import { Reference } from "~/components/Reference";
|
||||
import { isNotNullOrUndefined } from "~/helpers/validation";
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
import { builderSelectors } from "~/tools/js/CharacterBuilder/selectors";
|
||||
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
export interface ConfirmClassModalProps
|
||||
extends Omit<ConfirmModalProps, "onConfirm"> {}
|
||||
|
||||
export const ConfirmClassModal: FC<ConfirmClassModalProps> = ({
|
||||
className,
|
||||
onClose,
|
||||
...props
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const confirmClass = useSelector(builderSelectors.getConfirmClass);
|
||||
const {
|
||||
ruleData,
|
||||
characterActions,
|
||||
ruleDataUtils: RuleDataUtils,
|
||||
formatUtils: FormatUtils,
|
||||
} = useCharacterEngine();
|
||||
|
||||
const classFeatures = useMemo(() => {
|
||||
return orderBy(confirmClass?.classFeatures, [
|
||||
(classFeature) => classFeature.requiredLevel,
|
||||
(classFeature) => classFeature.displayOrder,
|
||||
(classFeature) => classFeature.name,
|
||||
]);
|
||||
}, [confirmClass]);
|
||||
|
||||
const onConfirm = () => {
|
||||
if (confirmClass) {
|
||||
dispatch(characterActions.classAddRequest(confirmClass, 1));
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
return confirmClass ? (
|
||||
<ConfirmModal
|
||||
onClose={onClose}
|
||||
heading="Confirm Add Class"
|
||||
onConfirm={onConfirm}
|
||||
confirmButtonText="Add Class"
|
||||
useMobileFullScreen
|
||||
{...props}
|
||||
>
|
||||
<>
|
||||
<div className={styles.confirmClassHeader}>
|
||||
<div>
|
||||
<h3 className={styles.name}>{confirmClass.name}</h3>
|
||||
{confirmClass?.sources
|
||||
?.map((sourceMapping) =>
|
||||
RuleDataUtils.getSourceDataInfo(
|
||||
sourceMapping.sourceId,
|
||||
ruleData
|
||||
)
|
||||
)
|
||||
.filter(isNotNullOrUndefined)
|
||||
.map((source, idx) => (
|
||||
<Fragment key={uuidv4()}>
|
||||
{idx > 0 ? " / " : " "}
|
||||
<Reference name={source.description} />
|
||||
</Fragment>
|
||||
))}
|
||||
{confirmClass.description && (
|
||||
<HtmlContent html={confirmClass.description} withoutTooltips />
|
||||
)}
|
||||
</div>
|
||||
{confirmClass.portraitAvatarUrl && (
|
||||
<img
|
||||
className={styles.image}
|
||||
src={confirmClass.portraitAvatarUrl}
|
||||
alt={confirmClass.name || "Class image"}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{confirmClass.moreDetailsUrl && (
|
||||
<div className={styles.detailsLink}>
|
||||
<Button
|
||||
href={confirmClass.moreDetailsUrl}
|
||||
variant="builder-text"
|
||||
size="x-small"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{confirmClass.name} Details Page <ArrowRightIcon />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.classFeatures}>
|
||||
{classFeatures.map((feature) => (
|
||||
<Accordion
|
||||
summary={feature.name}
|
||||
summaryMetaItems={
|
||||
feature.requiredLevel
|
||||
? [`${FormatUtils.ordinalize(feature.requiredLevel)} level`]
|
||||
: undefined
|
||||
}
|
||||
key={uuidv4()}
|
||||
variant="paper"
|
||||
forceShow
|
||||
>
|
||||
<CollapsibleContent className={styles.description}>
|
||||
{feature.description}
|
||||
</CollapsibleContent>
|
||||
</Accordion>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
</ConfirmModal>
|
||||
) : null;
|
||||
};
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
import { FC, HTMLAttributes } from "react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import { BuilderHelperTextInfoContract } from "@dndbeyond/character-rules-engine";
|
||||
|
||||
import { Accordion } from "~/components/Accordion";
|
||||
import { CollapsibleContent } from "~/components/CollapsibleContent";
|
||||
import { HelperTextAccordion } from "~/components/HelperTextAccordion";
|
||||
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
export interface ConfirmSpeciesContentProps
|
||||
extends HTMLAttributes<HTMLDivElement> {
|
||||
heading: string;
|
||||
helpItems: BuilderHelperTextInfoContract[];
|
||||
speciesTraits: { name: string; description: string | null }[];
|
||||
}
|
||||
|
||||
export const ConfirmSpeciesContent: FC<ConfirmSpeciesContentProps> = ({
|
||||
heading,
|
||||
helpItems,
|
||||
speciesTraits,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<div {...props}>
|
||||
<h3>{heading}</h3>
|
||||
{helpItems.length > 0 && (
|
||||
<HelperTextAccordion builderHelperText={helpItems} />
|
||||
)}
|
||||
{speciesTraits.length > 0 && (
|
||||
<div>
|
||||
{speciesTraits.map(({ name, description }) => {
|
||||
return (
|
||||
<Accordion
|
||||
summary={name}
|
||||
key={uuidv4()}
|
||||
variant="paper"
|
||||
forceShow
|
||||
>
|
||||
<CollapsibleContent className={styles.description}>
|
||||
{description}
|
||||
</CollapsibleContent>
|
||||
</Accordion>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import clsx from "clsx";
|
||||
import { FC, HTMLAttributes, ReactNode } from "react";
|
||||
|
||||
import { HtmlContent } from "~/components/HtmlContent";
|
||||
import { LegacyBadge } from "~/components/LegacyBadge";
|
||||
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
export interface ConfirmSpeciesHeaderProps
|
||||
extends HTMLAttributes<HTMLDivElement> {
|
||||
heading: string;
|
||||
content: string;
|
||||
imageUrl: string;
|
||||
isLegacy?: boolean;
|
||||
sources?: ReactNode;
|
||||
}
|
||||
|
||||
export const ConfirmSpeciesHeader: FC<ConfirmSpeciesHeaderProps> = ({
|
||||
className,
|
||||
heading,
|
||||
content,
|
||||
imageUrl,
|
||||
isLegacy,
|
||||
sources,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<div className={clsx([styles.confirmSpeciesHeader, className])} {...props}>
|
||||
<div>
|
||||
<h3 className={styles.fullName}>
|
||||
{heading}
|
||||
{isLegacy && <LegacyBadge id={heading || ""} />}
|
||||
</h3>
|
||||
{sources}
|
||||
<HtmlContent html={content} withoutTooltips />
|
||||
</div>
|
||||
<img className={styles.image} src={imageUrl} alt={heading} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,201 @@
|
||||
import clsx from "clsx";
|
||||
import React, { FC } from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
|
||||
import ArrowRightIcon from "@dndbeyond/fontawesome-cache/svgs/solid/arrow-right.svg";
|
||||
|
||||
import { Button } from "~/components/Button";
|
||||
import { ConfirmModal, ConfirmModalProps } from "~/components/ConfirmModal";
|
||||
import { Reference } from "~/components/Reference";
|
||||
import { SummaryList } from "~/components/SummaryList";
|
||||
import { AppContextTypeEnum, DisplayConfigurationTypeEnum } from "~/constants";
|
||||
import { isNotNullOrUndefined } from "~/helpers/validation";
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
import { Feat, RacialTraitContract as RacialTrait } from "~/types";
|
||||
|
||||
import { useSpeciesContext } from "../../contexts/Species";
|
||||
import { ConfirmSpeciesContent } from "./ConfirmSpeciesContent";
|
||||
import { ConfirmSpeciesHeader } from "./ConfirmSpeciesHeader";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
export interface ConfirmSpeciesModalProps
|
||||
extends Omit<ConfirmModalProps, "onConfirm"> {
|
||||
feats?: Feat[];
|
||||
}
|
||||
|
||||
export const ConfirmSpeciesModal: FC<ConfirmSpeciesModalProps> = ({
|
||||
className,
|
||||
feats,
|
||||
onClose,
|
||||
...props
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const {
|
||||
characterActions: { raceChoose },
|
||||
definitionUtils: { hack__generateDefinitionKey: generateDefinitionKey },
|
||||
featUtils: { getId: getFeatId, getName: getFeatName },
|
||||
raceUtils: {
|
||||
deriveCalledOutRacialTraits,
|
||||
deriveOrderedRacialTraits,
|
||||
deriveVisibleRacialTraits: deriveVisibleTraits,
|
||||
getDefinitionRacialTraits,
|
||||
getEntityRaceId,
|
||||
getEntityRaceTypeId,
|
||||
getFeatIds,
|
||||
getMoreDetailsUrl,
|
||||
},
|
||||
racialTraitUtils: {
|
||||
filterRacialTraitsByDisplayConfigurationType: filterRacialTraits,
|
||||
getName: getRacialTraitName,
|
||||
getDescription: getRacialTraitDescription,
|
||||
simulateRacialTraitFromContract,
|
||||
},
|
||||
ruleData,
|
||||
ruleDataUtils: {
|
||||
getBuilderHelperTextByDefinitionKeys: getHelperText,
|
||||
getSourceDataInfo,
|
||||
},
|
||||
race: species,
|
||||
} = useCharacterEngine();
|
||||
const { defaultSpeciesImageUrl: defaultImageUrl, selectedSpecies } =
|
||||
useSpeciesContext();
|
||||
|
||||
const { description, fullName, isLegacy, portraitAvatarUrl, sources } =
|
||||
selectedSpecies || {};
|
||||
|
||||
const previewUrl = portraitAvatarUrl || defaultImageUrl;
|
||||
|
||||
let missingFeatDependencies: Feat[] = [];
|
||||
|
||||
if (feats && species && selectedSpecies) {
|
||||
let speciesFeatIds = getFeatIds(species);
|
||||
let confirmSpeciesFeatIds = getFeatIds(selectedSpecies);
|
||||
feats.forEach((feat) => {
|
||||
if (
|
||||
speciesFeatIds.includes(getFeatId(feat)) &&
|
||||
!confirmSpeciesFeatIds.includes(getFeatId(feat))
|
||||
) {
|
||||
missingFeatDependencies.push(feat);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Get listing url for species
|
||||
const moreDetailsUrl = selectedSpecies
|
||||
? getMoreDetailsUrl(selectedSpecies)
|
||||
: "";
|
||||
|
||||
// Get species traits
|
||||
const speciesTraits =
|
||||
selectedSpecies && getDefinitionRacialTraits(selectedSpecies);
|
||||
|
||||
const simulatedSpeciesTraits = speciesTraits?.map((speciesTrait) =>
|
||||
simulateRacialTraitFromContract(speciesTrait)
|
||||
);
|
||||
|
||||
const calledOutSpeciesTraits =
|
||||
simulatedSpeciesTraits &&
|
||||
deriveCalledOutRacialTraits(simulatedSpeciesTraits, ruleData);
|
||||
|
||||
const onConfirm = () => {
|
||||
if (selectedSpecies) dispatch(raceChoose(selectedSpecies));
|
||||
onClose();
|
||||
};
|
||||
|
||||
const definitionKey =
|
||||
(selectedSpecies &&
|
||||
generateDefinitionKey(
|
||||
getEntityRaceTypeId(selectedSpecies),
|
||||
getEntityRaceId(selectedSpecies)
|
||||
)) ||
|
||||
"";
|
||||
|
||||
const builderText = getHelperText(
|
||||
[definitionKey],
|
||||
ruleData,
|
||||
DisplayConfigurationTypeEnum.RACIAL_TRAIT
|
||||
);
|
||||
|
||||
const orderedSpeciesTraits: RacialTrait[] = speciesTraits
|
||||
? deriveOrderedRacialTraits(speciesTraits)
|
||||
: [];
|
||||
|
||||
let visibleSpeciesTraits: RacialTrait[] = deriveVisibleTraits(
|
||||
orderedSpeciesTraits,
|
||||
AppContextTypeEnum.BUILDER
|
||||
);
|
||||
|
||||
visibleSpeciesTraits = filterRacialTraits(visibleSpeciesTraits, [
|
||||
DisplayConfigurationTypeEnum.RACIAL_TRAIT,
|
||||
]);
|
||||
|
||||
const sourceList = sources
|
||||
?.map((sourceMapping) =>
|
||||
getSourceDataInfo(sourceMapping.sourceId, ruleData)
|
||||
)
|
||||
.filter(isNotNullOrUndefined)
|
||||
.map((source, idx) => (
|
||||
<React.Fragment key={`${source}-${idx}`}>
|
||||
{idx > 0 ? " / " : " "}
|
||||
<Reference name={source.description} />
|
||||
</React.Fragment>
|
||||
));
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
className={clsx([styles.confirmSpeciesModal, className])}
|
||||
onClose={onClose}
|
||||
heading={`${species ? "Confirm Change" : "Confirm"} Species`}
|
||||
onConfirm={onConfirm}
|
||||
useMobileFullScreen
|
||||
{...props}
|
||||
>
|
||||
{missingFeatDependencies.length > 0 && (
|
||||
<div className="builder-message builder-message-warning">
|
||||
By changing your species, you will have invalid feat selection(s):
|
||||
{" " +
|
||||
missingFeatDependencies.map((feat) => getFeatName(feat)).join(", ")}
|
||||
</div>
|
||||
)}
|
||||
{selectedSpecies && (
|
||||
<div className={styles.content}>
|
||||
<ConfirmSpeciesHeader
|
||||
heading={fullName || ""}
|
||||
content={description || ""}
|
||||
imageUrl={previewUrl}
|
||||
isLegacy={isLegacy}
|
||||
sources={sourceList}
|
||||
/>
|
||||
{calledOutSpeciesTraits && calledOutSpeciesTraits.length > 0 && (
|
||||
<SummaryList
|
||||
list={calledOutSpeciesTraits}
|
||||
title="Species Traits"
|
||||
className={styles.summaryList}
|
||||
/>
|
||||
)}
|
||||
{moreDetailsUrl && (
|
||||
<div className={styles.detailsLink}>
|
||||
<Button
|
||||
href={moreDetailsUrl}
|
||||
variant="builder-text"
|
||||
size="x-small"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{fullName} Details Page <ArrowRightIcon />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<ConfirmSpeciesContent
|
||||
heading={`${fullName} Traits`}
|
||||
helpItems={builderText}
|
||||
speciesTraits={visibleSpeciesTraits.map((trait) => ({
|
||||
name: getRacialTraitName(trait),
|
||||
description: getRacialTraitDescription(trait),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</ConfirmModal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
import clsx from "clsx";
|
||||
import React, {
|
||||
cloneElement,
|
||||
FC,
|
||||
HTMLAttributes,
|
||||
ReactElement,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import { Button } from "~/components/Button";
|
||||
import { ConfirmModal } from "~/components/ConfirmModal";
|
||||
import { Textarea } from "~/components/Textarea";
|
||||
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
interface EditorWithDialogProps extends HTMLAttributes<HTMLDivElement> {
|
||||
heading: ReactElement;
|
||||
placeholder: string;
|
||||
content: string;
|
||||
editButtonLabel: string;
|
||||
addButtonLabel: string;
|
||||
onSave?: (content: string) => void;
|
||||
extraNode?: React.ReactNode;
|
||||
saveOnBlur?: boolean;
|
||||
}
|
||||
|
||||
export const EditorWithDialog: FC<EditorWithDialogProps> = ({
|
||||
addButtonLabel,
|
||||
className,
|
||||
editButtonLabel,
|
||||
content = "",
|
||||
extraNode,
|
||||
heading,
|
||||
onSave,
|
||||
placeholder,
|
||||
saveOnBlur = true,
|
||||
...props
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const textareaInput = useRef<HTMLDivElement>(null);
|
||||
const id = uuidv4();
|
||||
const headingNode = cloneElement(heading, { className: styles.heading });
|
||||
|
||||
const handleInputBlur = (content: string): void => {
|
||||
if (saveOnBlur) {
|
||||
if (onSave) onSave(content);
|
||||
}
|
||||
};
|
||||
|
||||
const handleShowModal = () => {
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const modalHeading: string = content.trim().length
|
||||
? editButtonLabel
|
||||
: addButtonLabel;
|
||||
const buttonLabel: string = modalHeading;
|
||||
|
||||
return (
|
||||
<div className={clsx([styles.container, className])} {...props}>
|
||||
<header className={styles.header}>
|
||||
{headingNode}
|
||||
<Button onClick={handleShowModal} size="xx-small" color="builder-green">
|
||||
{buttonLabel}
|
||||
</Button>
|
||||
</header>
|
||||
{content.trim().length > 0 && (
|
||||
<div className={styles.preview} data-testid="textarea-editor-preview">
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
<ConfirmModal
|
||||
heading={modalHeading}
|
||||
open={isOpen}
|
||||
onClose={handleCloseModal}
|
||||
onConfirm={handleCloseModal}
|
||||
data-testid="textarea-editor-modal"
|
||||
modal
|
||||
variant="confirm-only"
|
||||
confirmButtonText={"Save and Close"}
|
||||
color="builder-green"
|
||||
useMobileFullScreen
|
||||
>
|
||||
<div className={styles.content}>
|
||||
<label className={styles.title} htmlFor={id}>
|
||||
{heading}
|
||||
</label>
|
||||
<Textarea
|
||||
className={styles.textarea}
|
||||
ref={textareaInput}
|
||||
id={id}
|
||||
placeholder={placeholder}
|
||||
value={content}
|
||||
onInputKeyUp={() => {}}
|
||||
onInputBlur={handleInputBlur}
|
||||
/>
|
||||
{extraNode && <div className={styles.extra}>{extraNode}</div>}
|
||||
</div>
|
||||
</ConfirmModal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import { FC } from "react";
|
||||
|
||||
import { Button } from "~/components/Button";
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
import { useEquipmentMethods } from "~/subApps/builder/hooks/useEquipmentMethods";
|
||||
import { Item } from "~/types";
|
||||
|
||||
export interface EquipmentActionsProps {
|
||||
item: Item;
|
||||
equipLabel: string;
|
||||
unequipLabel: string;
|
||||
}
|
||||
|
||||
export const EquipmentActions: FC<EquipmentActionsProps> = ({
|
||||
item,
|
||||
equipLabel,
|
||||
unequipLabel,
|
||||
}) => {
|
||||
const { itemUtils } = useCharacterEngine();
|
||||
const { onToggleEquip } = useEquipmentMethods();
|
||||
const isEquipped = itemUtils.isEquipped(item);
|
||||
const isStackable = itemUtils.isStackable(item);
|
||||
const canEquip = itemUtils.canEquip(item);
|
||||
const quantity = itemUtils.getQuantity(item);
|
||||
|
||||
const handleToggleEquip = (e) => onToggleEquip(e, item);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isStackable && (
|
||||
<div className="equipment-list-item-callout-quantity">
|
||||
<span className="equipment-list-item-callout-quantity-extra">
|
||||
Qty
|
||||
</span>
|
||||
<span className="equipment-list-item-callout-quantity-value">
|
||||
{quantity}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{canEquip && (
|
||||
<div className="equipment-list-item-callout-action">
|
||||
<Button
|
||||
onClick={handleToggleEquip}
|
||||
size="xx-small"
|
||||
color="builder-green"
|
||||
variant={isEquipped ? "solid" : "outline"}
|
||||
>
|
||||
{isEquipped ? unequipLabel : equipLabel}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,253 @@
|
||||
import clsx from "clsx";
|
||||
import { FC, HTMLAttributes, useMemo, useState } from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
|
||||
import {
|
||||
characterActions,
|
||||
ClassUtils,
|
||||
Constants,
|
||||
DiceUtils,
|
||||
FormatUtils,
|
||||
HelperUtils,
|
||||
RuleDataUtils,
|
||||
} from "@dndbeyond/character-rules-engine";
|
||||
|
||||
import { ConfirmModal, ConfirmModalProps } from "~/components/ConfirmModal";
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
import {
|
||||
HP_BASE_MAX_VALUE,
|
||||
HP_BONUS_VALUE,
|
||||
HP_OVERRIDE_MAX_VALUE,
|
||||
} from "~/subApps/sheet/constants";
|
||||
import { FormInputField } from "~/tools/js/Shared/components/common/FormInputField";
|
||||
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
export interface HpManageModalProps
|
||||
extends Omit<ConfirmModalProps, "onConfirm"> {}
|
||||
|
||||
export const HpManageModal: FC<HpManageModalProps> = ({
|
||||
onClose,
|
||||
...props
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const { hpInfo, preferences, ruleData } = useCharacterEngine();
|
||||
|
||||
const [baseHp, setBaseHp] = useState(hpInfo.baseHp);
|
||||
const [bonusHp, setBonusHp] = useState(hpInfo.bonusHp);
|
||||
const [overrideHp, setOverrideHp] = useState(hpInfo.overrideHp);
|
||||
|
||||
const reset = () => {
|
||||
setBaseHp(hpInfo.baseHp);
|
||||
setBonusHp(hpInfo.bonusHp);
|
||||
setOverrideHp(hpInfo.overrideHp);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
reset();
|
||||
};
|
||||
|
||||
const onConfirm = () => {
|
||||
if (baseHp !== hpInfo.baseHp) {
|
||||
dispatch(characterActions.baseHitPointsSet(baseHp));
|
||||
}
|
||||
|
||||
if (bonusHp !== hpInfo.bonusHp) {
|
||||
dispatch(characterActions.bonusHitPointsSet(bonusHp));
|
||||
}
|
||||
|
||||
if (overrideHp !== hpInfo.overrideHp) {
|
||||
dispatch(characterActions.overrideHitPointsSet(overrideHp));
|
||||
}
|
||||
|
||||
onClose();
|
||||
reset();
|
||||
};
|
||||
|
||||
const transformBaseHp = (value: string): number => {
|
||||
let parsedNumber = HelperUtils.parseInputInt(
|
||||
value,
|
||||
RuleDataUtils.getMinimumHpTotal(ruleData)
|
||||
);
|
||||
return HelperUtils.clampInt(
|
||||
parsedNumber,
|
||||
RuleDataUtils.getMinimumHpTotal(ruleData),
|
||||
HP_BASE_MAX_VALUE
|
||||
);
|
||||
};
|
||||
|
||||
const transformBonusHp = (value: string): number | null => {
|
||||
let parsedNumber = HelperUtils.parseInputInt(value);
|
||||
if (parsedNumber === null) {
|
||||
return parsedNumber;
|
||||
}
|
||||
return HelperUtils.clampInt(
|
||||
parsedNumber,
|
||||
HP_BONUS_VALUE.MIN,
|
||||
HP_BONUS_VALUE.MAX
|
||||
);
|
||||
};
|
||||
|
||||
const transformOverrideHp = (value: string): number | null => {
|
||||
let parsedNumber = HelperUtils.parseInputInt(value, null);
|
||||
if (parsedNumber === null) {
|
||||
return parsedNumber;
|
||||
}
|
||||
return HelperUtils.clampInt(
|
||||
parsedNumber,
|
||||
RuleDataUtils.getMinimumHpTotal(ruleData)
|
||||
);
|
||||
};
|
||||
|
||||
const handleOverrideHpUpdate = (value: number | null): void => {
|
||||
const newOverride =
|
||||
value === null
|
||||
? null
|
||||
: HelperUtils.clampInt(
|
||||
value,
|
||||
RuleDataUtils.getMinimumHpTotal(ruleData),
|
||||
HP_OVERRIDE_MAX_VALUE
|
||||
);
|
||||
|
||||
setOverrideHp(newOverride);
|
||||
};
|
||||
|
||||
const totalHp = useMemo(() => {
|
||||
let totalHp: number =
|
||||
baseHp + hpInfo.totalHitPointSources + (bonusHp === null ? 0 : bonusHp);
|
||||
if (overrideHp !== null) {
|
||||
totalHp = overrideHp;
|
||||
}
|
||||
|
||||
return Math.max(RuleDataUtils.getMinimumHpTotal(ruleData), totalHp);
|
||||
}, [baseHp, bonusHp, overrideHp, hpInfo, ruleData]);
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
onClose={handleClose}
|
||||
heading="Manage Hit Points"
|
||||
onConfirm={onConfirm}
|
||||
confirmButtonText="Apply"
|
||||
useMobileFullScreen
|
||||
{...props}
|
||||
>
|
||||
{/* Total Max Hit Points */}
|
||||
<div className={styles.hpManageModal}>
|
||||
<p className={styles.total}>
|
||||
<span className={styles.totalLabel}>Maximum Hit Points</span>
|
||||
<span className={styles.totalValue}>{totalHp}</span>
|
||||
</p>
|
||||
|
||||
{/* Hit Point Controls */}
|
||||
<div className={styles.controls}>
|
||||
{preferences.hitPointType ===
|
||||
Constants.PreferenceHitPointTypeEnum.FIXED ? (
|
||||
<p className={styles.baseHp}>
|
||||
<span className={clsx([styles.baseHpLabel])}>Fixed HP</span>
|
||||
<span className={styles.baseHpValue}>{baseHp}</span>
|
||||
</p>
|
||||
) : (
|
||||
<FormInputField
|
||||
label="Rolled HP"
|
||||
initialValue={baseHp}
|
||||
type="number"
|
||||
onBlur={(value) => setBaseHp(value as number)}
|
||||
transformValueOnBlur={transformBaseHp}
|
||||
/>
|
||||
)}
|
||||
<FormInputField
|
||||
label="HP Modifier"
|
||||
initialValue={bonusHp}
|
||||
type="number"
|
||||
placeholder={"--"}
|
||||
onBlur={(value) => setBonusHp(value as number)}
|
||||
transformValueOnBlur={transformBonusHp}
|
||||
/>
|
||||
<FormInputField
|
||||
inputAttributes={
|
||||
{
|
||||
min: RuleDataUtils.getMinimumHpTotal(ruleData),
|
||||
max: HP_OVERRIDE_MAX_VALUE,
|
||||
} as HTMLAttributes<HTMLInputElement>
|
||||
}
|
||||
label="Override HP"
|
||||
initialValue={overrideHp}
|
||||
type="number"
|
||||
placeholder={"--"}
|
||||
onBlur={handleOverrideHpUpdate}
|
||||
transformValueOnBlur={transformOverrideHp}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Hit Point Bonus Sources */}
|
||||
{hpInfo.hitPointSources.length > 0 && (
|
||||
<div className={styles.hpSources}>
|
||||
<h3>Hit Point Bonuses</h3>
|
||||
{hpInfo.hitPointSources.map((hitPointSource, idx) => (
|
||||
<p key={`${idx}:${hitPointSource.source}`}>
|
||||
{FormatUtils.renderSignedNumber(hitPointSource.amount)} from{" "}
|
||||
{hitPointSource.source}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hit Dice & Potential Values */}
|
||||
<div className={styles.info}>
|
||||
<div className={styles.infoGroup}>
|
||||
<h3>Hit Dice</h3>
|
||||
{hpInfo.classesHitDice.map((classHitDice) => (
|
||||
<div key={ClassUtils.getId(classHitDice.charClass)}>
|
||||
<span className={styles.infoLabel}>
|
||||
{ClassUtils.getName(classHitDice.charClass)}:
|
||||
</span>
|
||||
<span>{DiceUtils.renderDie(classHitDice.dice)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.infoGroup}>
|
||||
<h3>Potential Values</h3>
|
||||
<div>
|
||||
<span className={styles.infoLabel}>Total Fixed Value HP:</span>
|
||||
<span>{hpInfo.totalFixedValueHp}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className={styles.infoLabel}>Total Average HP:</span>
|
||||
<span>{hpInfo.totalAverageHp}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className={styles.infoLabel}>Total Possible HP:</span>
|
||||
<span>{hpInfo.possibleMaxHitPoints}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* HP Help */}
|
||||
<div className={styles.help}>
|
||||
<h3>Max Hit Points</h3>
|
||||
<p>
|
||||
Your hit point maximum is determined by the number you roll for your
|
||||
hit dice each level or a fixed value determined by your hit dice and
|
||||
your Constitution modifier
|
||||
</p>
|
||||
|
||||
<h3>Bonus Hit Points</h3>
|
||||
<p>
|
||||
Use this field to record any miscellaneous bonus hit points you want
|
||||
to add to your normal hit point maximum. These hit points are
|
||||
different from temporary hit points, which you can add on your
|
||||
character sheet during play.
|
||||
</p>
|
||||
|
||||
<h3>Override Hit Points</h3>
|
||||
<p>
|
||||
Use this field to override your typical hit point maximum. The
|
||||
number you enter here will display as your hit point maximum on your
|
||||
character sheet.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</ConfirmModal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import { orderBy } from "lodash";
|
||||
import { FC, HTMLAttributes, useMemo, useState } from "react";
|
||||
|
||||
import { DiceContract, DiceUtils } from "@dndbeyond/character-rules-engine";
|
||||
|
||||
import { Button } from "~/components/Button";
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
|
||||
import { HpManageModal } from "../HpManageModal";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
export interface HpSummaryProps extends HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
export const HpSummary: FC<HpSummaryProps> = ({ className, ...props }) => {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const { hpInfo } = useCharacterEngine();
|
||||
|
||||
const openModal = () => {
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setIsModalOpen(false);
|
||||
};
|
||||
|
||||
const hitDiceStrings = useMemo(() => {
|
||||
const hitDice = hpInfo.classesHitDice.map(
|
||||
(classHitDice) => classHitDice.dice
|
||||
);
|
||||
const combinedHitDice: Array<DiceContract> = [];
|
||||
hitDice.forEach((hitDie) => {
|
||||
const existingDieIdx = combinedHitDice.findIndex(
|
||||
(hitDice) => hitDice.diceValue === hitDie.diceValue
|
||||
);
|
||||
if (existingDieIdx > -1) {
|
||||
let existingDie = combinedHitDice[existingDieIdx];
|
||||
combinedHitDice[existingDieIdx] = {
|
||||
...combinedHitDice[existingDieIdx],
|
||||
diceCount:
|
||||
(existingDie.diceCount !== null ? existingDie.diceCount : 0) +
|
||||
(hitDie.diceCount !== null ? hitDie.diceCount : 0),
|
||||
};
|
||||
} else {
|
||||
combinedHitDice.push(hitDie);
|
||||
}
|
||||
});
|
||||
|
||||
return orderBy(combinedHitDice, ["diceValue"]).map((hitDie) =>
|
||||
DiceUtils.renderDie(hitDie)
|
||||
);
|
||||
}, [hpInfo]);
|
||||
|
||||
return (
|
||||
<div {...props}>
|
||||
<div className={styles.hpSummaryContent}>
|
||||
<div>
|
||||
<p>
|
||||
<span className={styles.label}>Max Hit Points:</span>
|
||||
<span data-testid="hit-point-max">{hpInfo.totalHp}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span className={styles.label}>Hit Dice:</span>
|
||||
<span data-testid="hit-point-dice">
|
||||
{hitDiceStrings.join(" + ")}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
className={styles.button}
|
||||
variant="builder"
|
||||
size="x-small"
|
||||
onClick={openModal}
|
||||
>
|
||||
Manage HP
|
||||
</Button>
|
||||
</div>
|
||||
<HpManageModal open={isModalOpen} onClose={closeModal} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import clsx from "clsx";
|
||||
import { FC, HTMLAttributes } from "react";
|
||||
|
||||
import { GroupedListingItem, ListingItem } from "../../types";
|
||||
import { ListingGroup } from "./ListingGroup";
|
||||
import { ListingItemButton } from "./ListingItemButton/ListingItemButton";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
export interface ListingProps extends HTMLAttributes<HTMLDivElement> {
|
||||
items: Array<ListingItem | GroupedListingItem>;
|
||||
disabledIds?: Array<number | string>;
|
||||
onQuickSelect?: (entity: ListingItem["entity"]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A generic listing component that displays a list of items. Items cannot be
|
||||
* internal because it is used for both races/species and classes. Keeping the
|
||||
* data fetch outside this component allows for more flexibility.
|
||||
*/
|
||||
|
||||
export const Listing: FC<ListingProps> = ({
|
||||
className,
|
||||
items,
|
||||
disabledIds,
|
||||
onQuickSelect,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<div className={clsx([styles.listing, className])} {...props}>
|
||||
{items.length > 0 ? (
|
||||
items.map((item) => {
|
||||
const isDisabled = disabledIds && disabledIds.includes(item.id);
|
||||
|
||||
return item.type === "group" ? (
|
||||
<ListingGroup
|
||||
item={item as GroupedListingItem}
|
||||
key={item.id + "group"}
|
||||
disabledIds={disabledIds}
|
||||
onQuickSelect={onQuickSelect}
|
||||
/>
|
||||
) : (
|
||||
<ListingItemButton
|
||||
item={item as ListingItem}
|
||||
isDisabled={isDisabled}
|
||||
key={item.id}
|
||||
onQuickSelect={onQuickSelect}
|
||||
/>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<p className={styles.notFound}>No Listings Found</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
import clsx from "clsx";
|
||||
import { FC, HTMLAttributes, MouseEvent, useState } from "react";
|
||||
|
||||
import ChevronDown from "@dndbeyond/fontawesome-cache/svgs/solid/chevron-down.svg";
|
||||
|
||||
import { LegacyBadge } from "~/components/LegacyBadge";
|
||||
import { GroupedListingItem } from "~/subApps/builder/types";
|
||||
|
||||
import { ListingItemButton } from "../ListingItemButton";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
export interface ListingGroupProps extends HTMLAttributes<HTMLDetailsElement> {
|
||||
item: GroupedListingItem;
|
||||
disabledIds?: Array<number | string>;
|
||||
onQuickSelect?: (entity: GroupedListingItem["entity"]) => void;
|
||||
}
|
||||
|
||||
export const ListingGroup: FC<ListingGroupProps> = ({
|
||||
className,
|
||||
item,
|
||||
disabledIds,
|
||||
onQuickSelect,
|
||||
...props
|
||||
}) => {
|
||||
const {
|
||||
entityTypeId,
|
||||
error,
|
||||
heading,
|
||||
id,
|
||||
image,
|
||||
isLegacy,
|
||||
metaItems,
|
||||
text,
|
||||
type,
|
||||
onClick,
|
||||
...itemProps
|
||||
} = item;
|
||||
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
|
||||
const handleToggle = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setIsOpen(!isOpen);
|
||||
};
|
||||
|
||||
return (
|
||||
<details className={styles.details} key={id} open={isOpen} {...props}>
|
||||
<summary
|
||||
className={clsx([styles.summary, className])}
|
||||
onClick={handleToggle}
|
||||
>
|
||||
{image && (
|
||||
<img src={image} alt={`${heading} avatar`} className={styles.image} />
|
||||
)}
|
||||
<div>
|
||||
<header className={styles.header}>
|
||||
<h3 className={styles.heading}>
|
||||
{heading}{" "}
|
||||
<span
|
||||
className={styles.count}
|
||||
>{`(${itemProps.listItems.length})`}</span>
|
||||
</h3>
|
||||
{isLegacy && <LegacyBadge className={styles.tag} />}
|
||||
</header>
|
||||
{text && <p className={styles.text}>{text}</p>}
|
||||
{metaItems && metaItems.length > 0 && (
|
||||
<div className={styles.metaItems}>
|
||||
{metaItems.map((metaItem, idx) => (
|
||||
<span className={styles[metaItem.type]} key={idx}>
|
||||
{metaItem.text}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown className={styles.icon} />
|
||||
</summary>
|
||||
<div className={styles.content}>
|
||||
{itemProps.listItems.map((item) => (
|
||||
<ListingItemButton
|
||||
item={item}
|
||||
isDisabled={disabledIds && disabledIds.includes(item.id)}
|
||||
key={item.id}
|
||||
onQuickSelect={onQuickSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
import clsx from "clsx";
|
||||
import { FC, HTMLAttributes, MouseEvent } from "react";
|
||||
|
||||
import ChevronRight from "@dndbeyond/fontawesome-cache/svgs/solid/chevron-right.svg";
|
||||
|
||||
import { LegacyBadge } from "~/components/LegacyBadge";
|
||||
import { ListingItem } from "~/subApps/builder/types";
|
||||
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
export interface ListingItemProps extends HTMLAttributes<HTMLButtonElement> {
|
||||
item: ListingItem;
|
||||
isDisabled?: boolean;
|
||||
onQuickSelect?: (entity: ListingItem["entity"]) => void;
|
||||
}
|
||||
|
||||
export const ListingItemButton: FC<ListingItemProps> = ({
|
||||
className,
|
||||
item,
|
||||
isDisabled,
|
||||
onQuickSelect,
|
||||
...props
|
||||
}) => {
|
||||
const {
|
||||
entityTypeId,
|
||||
error,
|
||||
heading,
|
||||
id,
|
||||
image,
|
||||
isLegacy,
|
||||
metaItems,
|
||||
text,
|
||||
type,
|
||||
onClick,
|
||||
...itemProps
|
||||
} = item;
|
||||
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
//QuickSelect is from Quick and RandomBuild modes only
|
||||
if (onQuickSelect && !isDisabled) {
|
||||
onQuickSelect(item.entity);
|
||||
} else if (onClick && !isDisabled) {
|
||||
onClick(id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className={clsx([styles.button, className])}
|
||||
onClick={handleClick}
|
||||
aria-disabled={isDisabled}
|
||||
key={id}
|
||||
{...props}
|
||||
>
|
||||
{image && (
|
||||
<img src={image} alt={`${heading} avatar`} className={styles.image} />
|
||||
)}
|
||||
<div>
|
||||
<header className={styles.header}>
|
||||
<h3 className={styles.heading}>{heading}</h3>
|
||||
{isLegacy && <LegacyBadge className={styles.tag} />}
|
||||
</header>
|
||||
{text && <p className={styles.text}>{text}</p>}
|
||||
{metaItems && metaItems.length > 0 && (
|
||||
<div className={styles.metaItems}>
|
||||
{metaItems.map((metaItem, idx) => (
|
||||
<span className={styles[metaItem.type]} key={idx}>
|
||||
{metaItem.text}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ChevronRight className={styles.icon} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,209 @@
|
||||
import clsx from "clsx";
|
||||
import { FC, HTMLAttributes, useEffect, useState } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
|
||||
import { CharacterPortraitContract } from "@dndbeyond/character-rules-engine";
|
||||
import ShuffleIcon from "@dndbeyond/fontawesome-cache/svgs/solid/shuffle.svg";
|
||||
import { LabelChip } from "@dndbeyond/ttui/components/LabelChip";
|
||||
|
||||
import { Button } from "~/components/Button";
|
||||
import { ConfirmModal } from "~/components/ConfirmModal";
|
||||
import {
|
||||
CharacterNameLimitMsg,
|
||||
InputLimits,
|
||||
DefaultCharacterName,
|
||||
} from "~/constants";
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
import { builderActions } from "~/tools/js/CharacterBuilder/actions";
|
||||
import { builderSelectors } from "~/tools/js/CharacterBuilder/selectors";
|
||||
import { FormInputField } from "~/tools/js/Shared/components/common/FormInputField";
|
||||
import { PortraitManager } from "~/tools/js/Shared/containers/panes/DecoratePane";
|
||||
import Tooltip from "~/tools/js/commonComponents/Tooltip";
|
||||
import { CharacterAvatarPortrait } from "~/tools/js/smartComponents/CharacterAvatar";
|
||||
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLDivElement> {
|
||||
hidePortrait?: boolean;
|
||||
handleNameUpdate?: (newValue: string) => void;
|
||||
fullWidth?: boolean;
|
||||
useDefaultCharacterName?: boolean;
|
||||
}
|
||||
|
||||
export const PortraitName: FC<Props> = ({
|
||||
fullWidth,
|
||||
hidePortrait,
|
||||
handleNameUpdate,
|
||||
useDefaultCharacterName,
|
||||
...props
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const { characterActions, decorationUtils, decorationInfo, characterName } =
|
||||
useCharacterEngine();
|
||||
const [localValue, setLocalValue] = useState(characterName);
|
||||
|
||||
const suggestedNames = useSelector(builderSelectors.getSuggestedNames);
|
||||
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedPortrait, setSelectedPortrait] =
|
||||
useState<CharacterPortraitContract | null>(null);
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const [currentSuggestedNames, setCurrentSuggestedNames] = useState<
|
||||
Array<string>
|
||||
>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentSuggestedNames(suggestedNames);
|
||||
}, [suggestedNames]);
|
||||
|
||||
const handleNameFieldSet = (value: string | null): void => {
|
||||
let updatedName = value ?? "";
|
||||
|
||||
// If the flag useDefaultCharacterName is true and value is falsy, set the name to the DefaultCharacterName
|
||||
if (useDefaultCharacterName) updatedName = value || DefaultCharacterName;
|
||||
|
||||
setLocalValue(updatedName);
|
||||
if (handleNameUpdate) {
|
||||
handleNameUpdate(updatedName);
|
||||
} else {
|
||||
dispatch(characterActions.nameSet(updatedName));
|
||||
}
|
||||
};
|
||||
|
||||
const handleShowSuggestions = (): void => {
|
||||
if (currentSuggestedNames.length === 0) {
|
||||
dispatch(builderActions.suggestedNamesRequest());
|
||||
}
|
||||
|
||||
setShowSuggestions(!showSuggestions);
|
||||
};
|
||||
|
||||
const handleSuggestMoreClick = (): void => {
|
||||
dispatch(builderActions.suggestedNamesRequest());
|
||||
};
|
||||
|
||||
const onConfirmPortrait = (): void => {
|
||||
if (selectedPortrait?.avatarId && selectedPortrait?.avatarUrl) {
|
||||
dispatch(
|
||||
characterActions.portraitSet(
|
||||
selectedPortrait.avatarId,
|
||||
selectedPortrait.avatarUrl
|
||||
)
|
||||
);
|
||||
}
|
||||
closeModal();
|
||||
};
|
||||
|
||||
const handleSelectPortrait = (portrait: CharacterPortraitContract): void => {
|
||||
setSelectedPortrait(portrait);
|
||||
};
|
||||
|
||||
const handlePortraitClick = (): void => {
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const closeModal = (): void => {
|
||||
setIsModalOpen(false);
|
||||
setSelectedPortrait(null);
|
||||
};
|
||||
|
||||
const avatarUrl = decorationUtils.getAvatarInfo(decorationInfo).avatarUrl;
|
||||
|
||||
return (
|
||||
<div {...props}>
|
||||
<div
|
||||
className={clsx([styles.nameContainer, fullWidth && styles.fullwidth])}
|
||||
>
|
||||
{!hidePortrait && (
|
||||
<>
|
||||
<div onClick={handlePortraitClick}>
|
||||
<CharacterAvatarPortrait
|
||||
className={clsx([styles.avatar, !avatarUrl && styles.empty])}
|
||||
avatarUrl={avatarUrl}
|
||||
data-testid="character-portrait"
|
||||
/>
|
||||
</div>
|
||||
<ConfirmModal
|
||||
open={isModalOpen}
|
||||
onClose={closeModal}
|
||||
onConfirm={onConfirmPortrait}
|
||||
heading="Manage Portrait"
|
||||
className={styles.portraitManagerModal}
|
||||
confirmButtonText="Apply"
|
||||
useMobileFullScreen
|
||||
>
|
||||
<PortraitManager handleSelectPortrait={handleSelectPortrait} />
|
||||
</ConfirmModal>
|
||||
</>
|
||||
)}
|
||||
<div className={styles.inputContainer}>
|
||||
<FormInputField
|
||||
label="Character Name"
|
||||
onBlur={handleNameFieldSet}
|
||||
initialValue={localValue}
|
||||
inputAttributes={
|
||||
{
|
||||
spellCheck: false,
|
||||
autoComplete: "off",
|
||||
} as HTMLAttributes<HTMLInputElement>
|
||||
}
|
||||
maxLength={InputLimits.characterNameMaxLength}
|
||||
maxLengthErrorMsg={CharacterNameLimitMsg}
|
||||
/>
|
||||
<Button
|
||||
className={styles.suggestionButton}
|
||||
onClick={handleShowSuggestions}
|
||||
size="xx-small"
|
||||
variant="text"
|
||||
forceThemeMode="light"
|
||||
>
|
||||
{showSuggestions ? "Hide" : "Show"} Suggestions
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{showSuggestions && (
|
||||
<div>
|
||||
<div className={styles.suggestionsContainer}>
|
||||
<div className={styles.suggestionsLabel}>Suggestions:</div>
|
||||
<div>
|
||||
<Tooltip title="Shuffle Suggestions">
|
||||
<Button
|
||||
variant="text"
|
||||
size="xx-small"
|
||||
onClick={handleSuggestMoreClick}
|
||||
className={styles.shuffleButton}
|
||||
>
|
||||
<ShuffleIcon className={styles.shuffleIcon} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className={styles.suggestions}>
|
||||
{currentSuggestedNames.map((name: string, index: number) => (
|
||||
<LabelChip
|
||||
key={index}
|
||||
onClick={() => handleNameFieldSet(name)}
|
||||
className={styles.suggestion}
|
||||
data-testid="suggestion-chip"
|
||||
>
|
||||
{name}
|
||||
</LabelChip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.credit}>
|
||||
Names by
|
||||
<Button
|
||||
className={styles.creditLink}
|
||||
href="http://www.fantasynamegenerators.com/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
variant="text"
|
||||
>
|
||||
<span className={styles.creditText}>Fantasy Name Generators</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import clsx from "clsx";
|
||||
import { ChangeEvent, FC, HTMLAttributes } from "react";
|
||||
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
export interface SearchProps extends HTMLAttributes<HTMLInputElement> {
|
||||
placeholder?: string;
|
||||
value: string;
|
||||
onChange: (e: ChangeEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
export const Search: FC<SearchProps> = ({
|
||||
className,
|
||||
placeholder = "Search...",
|
||||
value,
|
||||
onChange,
|
||||
...props
|
||||
}) => (
|
||||
<input
|
||||
type="search"
|
||||
className={clsx([styles.search, className])}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
spellCheck={false}
|
||||
autoComplete={"off"}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
{
|
||||
/* {showAddAmountControls ? this.renderAmountControls() : null} */
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import clsx from "clsx";
|
||||
import { FC, HTMLAttributes } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { Button } from "~/components/Button";
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
import { navigationConfig } from "~/tools/js/CharacterBuilder/config";
|
||||
|
||||
import { RouteKey } from "../../constants";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
export interface SpeciesDisplayProps extends HTMLAttributes<HTMLDivElement> {
|
||||
onRequestAction?: () => void;
|
||||
actionText?: string;
|
||||
headingText?: string;
|
||||
}
|
||||
|
||||
export const SpeciesDisplay: FC<SpeciesDisplayProps> = ({
|
||||
actionText,
|
||||
className,
|
||||
headingText,
|
||||
...props
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const { characterId, race, ruleData, ruleDataUtils } = useCharacterEngine();
|
||||
|
||||
const { baseName, fullName, portraitAvatarUrl, subRaceShortName } =
|
||||
race || {};
|
||||
const portrait =
|
||||
portraitAvatarUrl || ruleDataUtils.getDefaultRaceImageUrl(ruleData);
|
||||
|
||||
// Navigate back to the class manage page
|
||||
const handleNavigate = (): void =>
|
||||
navigate(
|
||||
navigationConfig
|
||||
.getRouteDefPath(RouteKey.RACE_MANAGE)
|
||||
.replace(":characterId", characterId)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={clsx([styles.speciesDisplay, className])} {...props}>
|
||||
<h3 className={styles.heading}>{headingText || `Selected Species`}</h3>
|
||||
<div className={styles.content}>
|
||||
{portrait && (
|
||||
<img
|
||||
className={styles.portrait}
|
||||
src={portrait}
|
||||
alt={fullName || baseName || ""}
|
||||
width={50}
|
||||
height={50}
|
||||
/>
|
||||
)}
|
||||
<p className={styles.species}>
|
||||
{subRaceShortName && (
|
||||
<span className={styles.subspecies}>{subRaceShortName}</span>
|
||||
)}
|
||||
{baseName}
|
||||
</p>
|
||||
<Button
|
||||
className={styles.button}
|
||||
onClick={handleNavigate}
|
||||
size="xx-small"
|
||||
variant="builder"
|
||||
>
|
||||
{actionText || `Change Species`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import clsx from "clsx";
|
||||
import { FC, HTMLAttributes, ReactNode } from "react";
|
||||
|
||||
import { AnimatedLoadingRingSvg } from "~/tools/js/smartComponents/Svg";
|
||||
|
||||
interface SpinnerProps extends HTMLAttributes<HTMLDivElement> {
|
||||
label?: ReactNode;
|
||||
}
|
||||
|
||||
export const Spinner: FC<SpinnerProps> = ({
|
||||
label = "Loading",
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<div className={clsx(["ddbc-loading-placeholder", className])} {...props}>
|
||||
<AnimatedLoadingRingSvg className="ddbc-loading-placeholder__icon" />
|
||||
<span className="ddbc-loading-placeholder__label">{label}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
export const NavigationType = {
|
||||
HelpPage: "HelpPage",
|
||||
Page: "StandardPage",
|
||||
Section: "Section",
|
||||
External: "External",
|
||||
};
|
||||
|
||||
export const RouteKey = {
|
||||
QUICK_BUILD: "ROUTE:QUICK_BUILD",
|
||||
RANDOMIZE_BUILD: "ROUTE:RANDOMIZE_BUILD",
|
||||
|
||||
HOME_HELP: "ROUTE:HOME_HELP",
|
||||
HOME_INTRODUCTION: "ROUTE:HOME_INTRODUCTION",
|
||||
HOME_BASIC_INFO: "ROUTE:HOME_BASIC_INFO",
|
||||
|
||||
RACE_HELP: "ROUTE:RACE_HELP",
|
||||
RACE_CHOOSE: "ROUTE:RACE_CHOOSE",
|
||||
RACE_MANAGE: "ROUTE:RACE_MANAGE",
|
||||
|
||||
CLASS_HELP: "ROUTE:CLASS_HELP",
|
||||
CLASS_CHOOSE: "ROUTE:CLASS_CHOOSE",
|
||||
CLASS_MANAGE: "ROUTE:CLASS_MANAGE",
|
||||
|
||||
ABILITY_SCORES_HELP: "ROUTE:ABILITY_SCORES_HELP",
|
||||
ABILITY_SCORES_MANAGE: "ROUTE:ABILITY_SCORES_MANAGE",
|
||||
|
||||
DESCRIPTION_HELP: "ROUTE:DESCRIPTION_HELP",
|
||||
DESCRIPTION_MANAGE: "ROUTE:DESCRIPTION_MANAGE",
|
||||
|
||||
EQUIPMENT_HELP: "ROUTE:EQUIPMENT_HELP",
|
||||
EQUIPMENT_MANAGE: "ROUTE:EQUIPMENT_MANAGE",
|
||||
|
||||
WHATS_NEXT: "ROUTE:WHATS_NEXT",
|
||||
};
|
||||
|
||||
export const SectionKey = {
|
||||
HOME: "SECTION:HOME",
|
||||
RACE: "SECTION:RACE",
|
||||
CLASS: "SECTION:CLASS",
|
||||
ABILITY_SCORES: "SECTION:ABILITY_SCORES",
|
||||
DESCRIPTION: "SECTION:DESCRIPTION",
|
||||
EQUIPMENT: "SECTION:EQUIPMENT",
|
||||
QUICK_BUILD: "SECTION:QUICK_BUILD",
|
||||
RANDOMIZE: "SECTION:RANDOMIZE",
|
||||
WHATS_NEXT: "SECTION:WHATS_NEXT",
|
||||
};
|
||||
|
||||
export const BuilderMethod = {
|
||||
QUICK: "QUICK",
|
||||
STEP_BY_STEP: "STEP_BY_STEP",
|
||||
RANDOMIZE: "RANDOMIZE",
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import { createContext, FC, useContext, useState } from "react";
|
||||
|
||||
import { BuilderMethod } from "../../types";
|
||||
|
||||
export interface BuilderContextType {
|
||||
builderMethod: BuilderMethod | null;
|
||||
isCharacterLoading: boolean;
|
||||
isCharacterLoaded: boolean;
|
||||
pdfUrl: string | null;
|
||||
setBuilderMethod: (method: BuilderMethod) => void;
|
||||
suggestedNames: string[];
|
||||
}
|
||||
|
||||
export const BuilderContext = createContext<BuilderContextType>({
|
||||
builderMethod: null,
|
||||
isCharacterLoading: true,
|
||||
isCharacterLoaded: false,
|
||||
pdfUrl: null,
|
||||
setBuilderMethod: () => {},
|
||||
suggestedNames: [],
|
||||
});
|
||||
|
||||
export const BuilderProvider: FC = ({ children }) => {
|
||||
const [builderMethod, setBuilderMethodState] = useState<BuilderMethod | null>(
|
||||
null
|
||||
);
|
||||
const [isCharacterLoading, setIsCharacterLoading] = useState(true);
|
||||
const [isCharacterLoaded, setIsCharacterLoaded] = useState(false);
|
||||
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
|
||||
const [suggestedNames, setSuggestedNames] = useState<string[]>([]);
|
||||
|
||||
const setBuilderMethod = async (method: BuilderMethod) => {
|
||||
// Handle setting the builder method via fetch
|
||||
};
|
||||
|
||||
// const response = yield call(ApiRequests.getCharacterRuleData, {
|
||||
// params: { v: appConfig.version },
|
||||
// });
|
||||
// const data: UnpackMakeApiResponseData<
|
||||
// typeof ApiRequests.getCharacterRuleData
|
||||
// > | null = ApiAdapterUtils.getResponseData(response);
|
||||
|
||||
// if (data !== null) {
|
||||
// yield put(ruleDataActions.dataSet(data));
|
||||
// }
|
||||
// yield put(builderActions.builderMethodSetCommit(action.payload));
|
||||
|
||||
return (
|
||||
<BuilderContext.Provider
|
||||
value={{
|
||||
builderMethod,
|
||||
isCharacterLoading,
|
||||
isCharacterLoaded,
|
||||
pdfUrl,
|
||||
setBuilderMethod,
|
||||
suggestedNames,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</BuilderContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useBuilderContext = () => {
|
||||
return useContext(BuilderContext);
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
import { createContext, FC, useContext, useEffect, useState } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
|
||||
import { ApiAdapterUtils } from "@dndbeyond/character-rules-engine";
|
||||
|
||||
import { builderActions } from "~/tools/js/CharacterBuilder/actions";
|
||||
import { apiCreatorSelectors } from "~/tools/js/Shared/selectors";
|
||||
import { ClassDefinitionContract as ClassDef } from "~/types";
|
||||
|
||||
/**
|
||||
* A context to provide data to the application without causing a re-render or
|
||||
* unnecessary fetches and transforms. This context is used in places where
|
||||
* character-specific data is not needed.
|
||||
*/
|
||||
export interface ClassContextType {
|
||||
query: string;
|
||||
setQuery: (query: string) => void;
|
||||
filteredClasses: Array<ClassDef>;
|
||||
allClasses: Array<ClassDef>;
|
||||
handleSelectClass: (id: number) => void;
|
||||
isLoading: boolean;
|
||||
isModalShowing: boolean;
|
||||
closeModal: () => void;
|
||||
}
|
||||
|
||||
export const ClassContext = createContext<ClassContextType>(null!);
|
||||
|
||||
export const ClassProvider: FC = ({ children }) => {
|
||||
const dispatch = useDispatch();
|
||||
const [query, setQuery] = useState("");
|
||||
const [allClasses, setAllClasses] = useState<Array<ClassDef>>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isModalShowing, setIsModalShowing] = useState(false);
|
||||
const closeModal = () => setIsModalShowing(false);
|
||||
|
||||
const loadClasses = useSelector(apiCreatorSelectors.makeLoadAvailableClasses);
|
||||
|
||||
const handleSelectClass = (id: number) => {
|
||||
const entity = allClasses.find((c) => c.id === id);
|
||||
if (entity) {
|
||||
dispatch(builderActions.confirmClassSet(entity));
|
||||
setIsModalShowing(true);
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle searching through items
|
||||
const filteredClasses = query
|
||||
? allClasses.filter((c) =>
|
||||
c.name?.toLowerCase().includes(query.toLowerCase())
|
||||
)
|
||||
: allClasses;
|
||||
|
||||
useEffect(() => {
|
||||
const getAllData = async () => {
|
||||
// Load all classes
|
||||
const response = await loadClasses();
|
||||
const classes = ApiAdapterUtils.getResponseData(response) || [];
|
||||
|
||||
setAllClasses(classes);
|
||||
|
||||
if (classes.length > 0) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
getAllData();
|
||||
}, [loadClasses]);
|
||||
|
||||
return (
|
||||
<ClassContext.Provider
|
||||
value={{
|
||||
query,
|
||||
setQuery,
|
||||
filteredClasses,
|
||||
allClasses,
|
||||
handleSelectClass,
|
||||
isLoading,
|
||||
isModalShowing,
|
||||
closeModal,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ClassContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useClassContext = () => {
|
||||
return useContext(ClassContext);
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import { createContext, FC, useContext, useState } from "react";
|
||||
|
||||
import { ConfirmModal, ConfirmModalProps } from "~/components/ConfirmModal";
|
||||
|
||||
export interface ModalData {
|
||||
content: React.ReactNode;
|
||||
props: Omit<ConfirmModalProps, "open">;
|
||||
}
|
||||
export interface ModalManagerContextType {
|
||||
createModal: (modalData: ModalData) => void;
|
||||
}
|
||||
|
||||
export const ModalManagerContext = createContext<ModalManagerContextType>(
|
||||
null!
|
||||
);
|
||||
|
||||
export const ModalManagerProvider: FC = ({ children }) => {
|
||||
const [modalData, setModalData] = useState<ModalData | null>(null);
|
||||
|
||||
const createModal = (modalData: ModalData) => {
|
||||
setModalData(modalData);
|
||||
};
|
||||
|
||||
const removeModal = () => {
|
||||
setModalData(null);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
removeModal();
|
||||
if (modalData) {
|
||||
modalData.props.onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
removeModal();
|
||||
if (modalData) {
|
||||
modalData.props.onConfirm();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalManagerContext.Provider value={{ createModal }}>
|
||||
{children}
|
||||
<ConfirmModal
|
||||
{...modalData?.props}
|
||||
onConfirm={handleConfirm}
|
||||
onClose={handleClose}
|
||||
open={!!modalData}
|
||||
>
|
||||
{modalData?.content}
|
||||
</ConfirmModal>
|
||||
</ModalManagerContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useModalManager = () => {
|
||||
return useContext(ModalManagerContext);
|
||||
};
|
||||
@@ -0,0 +1,302 @@
|
||||
import { groupBy, keyBy, uniqBy } from "lodash";
|
||||
import { createContext, FC, useContext, useEffect, useState } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
import { useSource } from "~/hooks/useSource";
|
||||
import { apiCreatorSelectors } from "~/tools/js/Shared/selectors";
|
||||
import { TypeScriptUtils } from "~/tools/js/Shared/utils";
|
||||
import {
|
||||
HtmlSelectOption,
|
||||
HtmlSelectOptionGroup,
|
||||
RaceDefinitionContract as RaceDef,
|
||||
RaceDefinitionContract,
|
||||
SimpleSourcedDefinitionContract,
|
||||
} from "~/types";
|
||||
|
||||
import { GroupedListingItem, ListingItem } from "../../types";
|
||||
|
||||
export interface SpeciesContextType {
|
||||
closeModal: () => void;
|
||||
defaultSpeciesImageUrl: string;
|
||||
isLegacyShowing: boolean;
|
||||
isModalShowing: boolean;
|
||||
query: string;
|
||||
selectedSpecies?: RaceDef;
|
||||
setQuery: (query: string) => void;
|
||||
source: string;
|
||||
sourceOptions: (HtmlSelectOptionGroup | HtmlSelectOption)[];
|
||||
filteredSpecies: RaceDef[];
|
||||
allSpecies: RaceDef[];
|
||||
toggleLegacyContent: () => void;
|
||||
transformSpecies: (data: RaceDef[]) => ListingItem[];
|
||||
setSource: (source: string) => void;
|
||||
isLoading: boolean;
|
||||
getSpeciesInGroups: (
|
||||
species: ListingItem[]
|
||||
) => (ListingItem | GroupedListingItem)[];
|
||||
}
|
||||
|
||||
export const SpeciesContext = createContext<SpeciesContextType>(null!);
|
||||
|
||||
export const SpeciesProvider: FC = ({ children }) => {
|
||||
const {
|
||||
apiAdapterUtils: { getResponseData },
|
||||
raceUtils: { getSubRaceShortName, getBaseName, getIsHomebrew, getSources },
|
||||
ruleData,
|
||||
ruleDataUtils: { getDefaultRaceImageUrl, getRaceGroups },
|
||||
characterUtils: { isPrimarySource },
|
||||
} = useCharacterEngine();
|
||||
const { getSourceDescription, getGroupedOptionsBySourceCategory } =
|
||||
useSource();
|
||||
|
||||
const [allSpecies, setAllSpecies] = useState<Array<RaceDef>>([]);
|
||||
const [isLegacyShowing, setIsLegacyShowing] = useState(true);
|
||||
const [isModalShowing, setIsModalShowing] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [selectedSpecies, setSelectedSpecies] = useState<RaceDef>();
|
||||
const [source, setSource] = useState("");
|
||||
const [sourceOptions, setSourceOptions] = useState<
|
||||
(HtmlSelectOptionGroup | HtmlSelectOption)[]
|
||||
>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const loadSpecies = useSelector(apiCreatorSelectors.makeLoadAvailableRaces);
|
||||
|
||||
const defaultSpeciesImageUrl = getDefaultRaceImageUrl(ruleData) ?? "";
|
||||
|
||||
const handleFilteredSpecies = () => {
|
||||
let filtered = allSpecies;
|
||||
//Check legacy toggle
|
||||
if (!isLegacyShowing) {
|
||||
filtered = filtered.filter((s) => !s.isLegacy);
|
||||
}
|
||||
|
||||
// Check sources
|
||||
if (source !== "") {
|
||||
if (source === "HOMEBREW") {
|
||||
filtered = filtered.filter((s) => s.isHomebrew);
|
||||
} else {
|
||||
filtered = filtered.filter((s) =>
|
||||
s.sources?.some(({ sourceId }) => source === sourceId.toString())
|
||||
);
|
||||
}
|
||||
}
|
||||
// Check querys
|
||||
if (query !== "") {
|
||||
filtered = filtered.filter((s) => {
|
||||
return s.fullName?.toLowerCase().includes(query.toLowerCase());
|
||||
});
|
||||
}
|
||||
return filtered;
|
||||
};
|
||||
// Handle searching through items
|
||||
const filteredSpecies = handleFilteredSpecies();
|
||||
|
||||
const onSelectSpecies = (id: string) => {
|
||||
const entity = allSpecies.find(
|
||||
(s) => `${s.entityRaceId}-${s.entityRaceTypeId}` === id
|
||||
);
|
||||
if (entity) {
|
||||
setSelectedSpecies(entity);
|
||||
setIsModalShowing(true);
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
};
|
||||
|
||||
const closeModal = () => setIsModalShowing(false);
|
||||
|
||||
const toggleLegacyContent = () => {
|
||||
setIsLegacyShowing(!isLegacyShowing);
|
||||
};
|
||||
|
||||
//Transform Species data into ListingItems
|
||||
const transformSpecies = (data: RaceDef[]): ListingItem[] => {
|
||||
return data.map((species) => {
|
||||
const shortName = getSubRaceShortName(species);
|
||||
const baseName = getBaseName(species);
|
||||
const heading = shortName ? `${shortName} ${baseName}` : baseName || "";
|
||||
const text = getIsHomebrew(species)
|
||||
? "Homebrew"
|
||||
: getSourceDescription(species?.sources?.[0]?.sourceId || 0) || "";
|
||||
|
||||
return {
|
||||
type: "species",
|
||||
id: `${species.entityRaceId}-${species.entityRaceTypeId}`,
|
||||
entityTypeId: species.entityRaceTypeId,
|
||||
heading,
|
||||
text,
|
||||
image: species.portraitAvatarUrl || defaultSpeciesImageUrl,
|
||||
entity: species,
|
||||
isLegacy: species.isLegacy,
|
||||
onClick: onSelectSpecies,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// get the ListingItems for species, both grouped and individual
|
||||
const getSpeciesInGroups = (
|
||||
species: ListingItem[]
|
||||
): (ListingItem | GroupedListingItem)[] => {
|
||||
let groupedSpecies: ListingItem[] = [];
|
||||
let unGroupedSpecies: ListingItem[] = [];
|
||||
|
||||
//species that have a groupId go into the groupedSpecies array and species that don't have a groupId go into the unGroupedSpecies array
|
||||
species.forEach((s) => {
|
||||
const groupIds = (s.entity as RaceDefinitionContract)?.groupIds;
|
||||
if (groupIds && groupIds.length > 0) {
|
||||
groupIds.forEach((groupId) => {
|
||||
groupedSpecies.push({
|
||||
...s,
|
||||
groupId,
|
||||
});
|
||||
});
|
||||
} else {
|
||||
unGroupedSpecies.push(s);
|
||||
}
|
||||
});
|
||||
|
||||
//Create a lookup of grouped species by groupId
|
||||
const groupedSpeciesLookup = groupBy(groupedSpecies, "groupId");
|
||||
//Create a lookup from all Species Groups from rules data
|
||||
const groupDataLookup = keyBy(getRaceGroups(ruleData), "id");
|
||||
//This array will hold the final list of grouped species
|
||||
const groups: GroupedListingItem[] = [];
|
||||
|
||||
//Iterate through the grouped species lookup to create final groupings
|
||||
Object.keys(groupedSpeciesLookup).forEach((groupId) => {
|
||||
const groupItems = groupedSpeciesLookup[groupId];
|
||||
//If the group only has one item, it is not a group - put it in the unGroupedSpecies array, otherwise create the Grouped Listing Item for the group
|
||||
if (groupItems.length === 1) {
|
||||
unGroupedSpecies.push(groupItems[0]);
|
||||
} else {
|
||||
const groupInfo = groupDataLookup[parseInt(groupId)];
|
||||
groups.push({
|
||||
id: groupId,
|
||||
type: "group",
|
||||
image: groupInfo.avatarUrl ?? "",
|
||||
heading: groupInfo.name ?? "",
|
||||
groupId: parseInt(groupId),
|
||||
listItems: groupItems,
|
||||
onClick: undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return [...groups, ...unGroupedSpecies];
|
||||
};
|
||||
|
||||
const getSpecies = async () => {
|
||||
const species = getResponseData(await loadSpecies()) || [];
|
||||
|
||||
setAllSpecies(species);
|
||||
if (species.length > 0) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Set the source options when the species are loaded
|
||||
useEffect(() => {
|
||||
let shouldContaineHomebrewCategory = false;
|
||||
let sourceShouldReset = true;
|
||||
|
||||
const sourcesData: Array<SimpleSourcedDefinitionContract> = allSpecies
|
||||
.map((species) => {
|
||||
//Flag homebrew category
|
||||
if (species.isHomebrew) {
|
||||
if (!shouldContaineHomebrewCategory) {
|
||||
shouldContaineHomebrewCategory = true;
|
||||
}
|
||||
|
||||
//don't reset source state if we have a homebrew category and the current source is homebrew
|
||||
if (shouldContaineHomebrewCategory && source === "HOMEBREW") {
|
||||
sourceShouldReset = false;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const primarySourceId =
|
||||
getSources(species).filter(isPrimarySource)[0]?.sourceId;
|
||||
|
||||
//If there is no primary source and the above filter hasn't already returned null for isHombrew, return null
|
||||
if (!primarySourceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
//don't reset source state if the current source is the sourceId of a species
|
||||
if (primarySourceId.toString() === source) {
|
||||
sourceShouldReset = false;
|
||||
}
|
||||
|
||||
return {
|
||||
sources: getSources(species),
|
||||
name: getSourceDescription(primarySourceId) || "Unknown",
|
||||
id: primarySourceId,
|
||||
};
|
||||
})
|
||||
.filter(TypeScriptUtils.isNotNullOrUndefined);
|
||||
|
||||
const uniqueSources = uniqBy(sourcesData, "id");
|
||||
const groupedOptions = getGroupedOptionsBySourceCategory(uniqueSources);
|
||||
|
||||
const homebrewOptions: HtmlSelectOption[] = [
|
||||
{
|
||||
label: "Homebrew",
|
||||
value: "HOMEBREW",
|
||||
},
|
||||
{
|
||||
label: "---------",
|
||||
disabled: true,
|
||||
value: "-----",
|
||||
},
|
||||
];
|
||||
const sourceOptions: (HtmlSelectOptionGroup | HtmlSelectOption)[] = [];
|
||||
if (shouldContaineHomebrewCategory) {
|
||||
sourceOptions.push(...homebrewOptions);
|
||||
}
|
||||
|
||||
sourceOptions.push(...groupedOptions);
|
||||
|
||||
setSourceOptions(sourceOptions);
|
||||
|
||||
//reset source state if the source isn't valid for the list of current species so that the list is updated
|
||||
if (sourceShouldReset) {
|
||||
setSource("");
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [allSpecies, source]);
|
||||
|
||||
useEffect(() => {
|
||||
getSpecies();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [loadSpecies]);
|
||||
|
||||
return (
|
||||
<SpeciesContext.Provider
|
||||
value={{
|
||||
closeModal,
|
||||
defaultSpeciesImageUrl,
|
||||
isLegacyShowing,
|
||||
isModalShowing,
|
||||
query,
|
||||
selectedSpecies,
|
||||
setQuery,
|
||||
sourceOptions,
|
||||
filteredSpecies,
|
||||
allSpecies,
|
||||
toggleLegacyContent,
|
||||
transformSpecies,
|
||||
source,
|
||||
setSource,
|
||||
isLoading,
|
||||
getSpeciesInGroups,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</SpeciesContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useSpeciesContext = () => {
|
||||
return useContext(SpeciesContext);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { PrerequisiteFailure } from "../types";
|
||||
|
||||
// Format a group of PrerequisiteFailures into a human-readable string
|
||||
const formatReq = (group: Array<PrerequisiteFailure>) =>
|
||||
group.map(({ data }) => {
|
||||
const key = data.statKey?.toUpperCase() || "";
|
||||
const value = data.requiredValue;
|
||||
const amount = data.currentAmount === null ? "None" : data.currentAmount;
|
||||
|
||||
return `${key} ${value} (${amount})`;
|
||||
});
|
||||
|
||||
// Loop through groups of PrerequisiteFailures and format them into a human-readable string
|
||||
export const getMissingRequirements = (
|
||||
groups: Array<Array<PrerequisiteFailure>>
|
||||
) => groups.map((group) => formatReq(group).join(", ")).join(" or ");
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useDispatch } from "react-redux";
|
||||
|
||||
import { ContainerTypeEnum } from "~/constants";
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
import { useUnpropagatedClick } from "~/hooks/useUnpropagatedClick";
|
||||
import { useInventoryManager } from "~/tools/js/Shared/managers/InventoryManagerContext";
|
||||
import { Item } from "~/types";
|
||||
|
||||
import { useModalManager } from "../contexts/ModalManager";
|
||||
|
||||
export const useEquipmentMethods = () => {
|
||||
const dispatch = useDispatch();
|
||||
const {
|
||||
containerLookup,
|
||||
containerUtils,
|
||||
definitionUtils,
|
||||
helperUtils,
|
||||
itemUtils,
|
||||
infusionUtils,
|
||||
serviceDataActions,
|
||||
} = useCharacterEngine();
|
||||
const { inventoryManager } = useInventoryManager();
|
||||
const { createModal } = useModalManager();
|
||||
|
||||
const launchContainerRemovalModal = (item: Item) =>
|
||||
createModal({
|
||||
content: (
|
||||
<div className="equipment-manage__warning">
|
||||
<p>
|
||||
Removing the <strong>{itemUtils.getName(item)}</strong> will also
|
||||
remove all of its contents.
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
props: {
|
||||
heading: `Remove ${itemUtils.getName(item)}`,
|
||||
onConfirm: () => {
|
||||
inventoryManager.handleRemove({ item });
|
||||
},
|
||||
onClose: () => {}, //Do nothing
|
||||
variant: "remove",
|
||||
size: "fit-content",
|
||||
confirmButtonText: "Remove",
|
||||
},
|
||||
});
|
||||
|
||||
const onRemove = (item) => {
|
||||
const isContainer = itemUtils.isContainer(item);
|
||||
if (isContainer) {
|
||||
launchContainerRemovalModal(item);
|
||||
} else {
|
||||
inventoryManager.handleRemove({ item });
|
||||
}
|
||||
};
|
||||
|
||||
const onRemoveInfusion = (item) => {
|
||||
const infusion = itemUtils.getInfusion(item);
|
||||
if (infusion) {
|
||||
const infusionId = infusionUtils.getId(infusion);
|
||||
if (infusionId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (itemUtils.isContainer(item)) {
|
||||
const container = helperUtils.lookupDataOrFallback(
|
||||
containerLookup,
|
||||
definitionUtils.hack__generateDefinitionKey(
|
||||
ContainerTypeEnum.ITEM,
|
||||
itemUtils.getMappingId(item)
|
||||
)
|
||||
);
|
||||
if (container) {
|
||||
if (containerUtils.hasInfusions(container)) {
|
||||
// Pop up the infusion modal
|
||||
createModal({
|
||||
content: (
|
||||
<div className="equipment-manage__warning">
|
||||
<p>
|
||||
<strong>Notice:</strong> In accordance with Artificer
|
||||
guidance the Waste Management Guild of Sharn (the City of
|
||||
Towers) requires the removal of all Infusions prior to
|
||||
disposal of any items.
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
props: {
|
||||
heading: `Remove ${itemUtils.getName(item)}: Infusions?`,
|
||||
onConfirm: () => {
|
||||
dispatch(
|
||||
serviceDataActions.infusionMappingsDestroy(
|
||||
containerUtils.getInfusedItemMappingIds(container)
|
||||
)
|
||||
);
|
||||
launchContainerRemovalModal(item);
|
||||
},
|
||||
onClose: () => {}, //Do nothing
|
||||
variant: "remove",
|
||||
size: "fit-content",
|
||||
confirmButtonText: "Remove",
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// pop up the container modal
|
||||
launchContainerRemovalModal(item);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
dispatch(
|
||||
serviceDataActions.infusionMappingDestroy(
|
||||
infusionId,
|
||||
infusionUtils.getInventoryMappingId(infusion)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onUnequip = (item) => inventoryManager.handleUnequip({ item });
|
||||
|
||||
const onEquip = (item) => inventoryManager.handleEquip({ item });
|
||||
|
||||
const onAttune = (item) => inventoryManager.handleAttune({ item });
|
||||
|
||||
const onUnattune = (item) => inventoryManager.handleUnattune({ item });
|
||||
|
||||
const onQuantitySet = (item, quantity) =>
|
||||
inventoryManager.handleQuantitySet({ item, quantity });
|
||||
|
||||
const onToggleEquip = useUnpropagatedClick((e, item: Item) => {
|
||||
const isEquipped = itemUtils.isEquipped(item);
|
||||
if (isEquipped) {
|
||||
if (onUnequip) onUnequip(item);
|
||||
} else {
|
||||
if (onEquip) onEquip(item);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
onAttune,
|
||||
onEquip,
|
||||
onQuantitySet,
|
||||
onRemove,
|
||||
onRemoveInfusion,
|
||||
onToggleEquip,
|
||||
onUnattune,
|
||||
onUnequip,
|
||||
};
|
||||
};
|
||||
@@ -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