Grabbed dndbeyond's source code

```
~/go/bin/sourcemapper -output ddb -jsurl https://media.dndbeyond.com/character-app/static/js/main.90aa78c5.js
```
This commit is contained in:
2025-05-28 11:50:03 -07:00
commit 8df9031d27
3592 changed files with 319051 additions and 0 deletions
@@ -0,0 +1,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;
};
@@ -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>
);
};
@@ -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>
);
};