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>
);
};
+52
View File
@@ -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>
);
};
@@ -0,0 +1,42 @@
import React from "react";
import CircleExclamation from "@dndbeyond/fontawesome-cache/svgs/solid/circle-exclamation.svg";
import SpinnerThird from "@dndbeyond/fontawesome-cache/svgs/solid/spinner-third.svg";
import styles from "./styles.module.css";
export interface ApiStatusIndicatorProps {
isLoading: boolean;
error?: Error;
loadingMessage: string;
errorMessage: string;
}
export const ApiStatusIndicator: React.FC<ApiStatusIndicatorProps> = ({
isLoading,
error,
loadingMessage,
errorMessage,
}) => {
if (!isLoading && !error) {
return null;
}
return (
<div className={styles.indicator}>
{isLoading && (
<>
<SpinnerThird className={styles.spinner} />
<span>{loadingMessage}</span>
</>
)}
{error && (
<>
<CircleExclamation className={styles.error} />
<span>{errorMessage}</span>
</>
)}
</div>
);
};
export default ApiStatusIndicator;
@@ -0,0 +1,50 @@
import { useState } from "react";
import { Button } from "@dndbeyond/ttui/components/Button";
import { CharacterData } from "~/types";
import { logUnlockCharacterUnlocked } from "../../../../../helpers/analytics";
import { UnlockConfirmation } from "../../CharacterGrid/UnlockConfirmation";
import { ConfirmationModal } from "../../ConfirmationModal";
interface ActivateButtonProps {
character: CharacterData;
activateCharacter: (character: CharacterData) => void;
maxCharacterSlotsAllowed: number | null;
}
export const ActivateButton: React.FC<ActivateButtonProps> = ({
character,
activateCharacter,
maxCharacterSlotsAllowed,
}) => {
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<ConfirmationModal
isOpen={isOpen}
onClose={() => {
setIsOpen(false);
}}
onConfirm={() => {
activateCharacter(character);
logUnlockCharacterUnlocked();
setIsOpen(false);
}}
title="Activate these characters?"
confirmText="Activate"
message={
<UnlockConfirmation
charactersToUnlock={[character]}
maxCharacterSlotsAllowed={maxCharacterSlotsAllowed}
/>
}
/>
<Button size="small" variant="text" onClick={() => setIsOpen(true)}>
Activate
</Button>
</div>
);
};
@@ -0,0 +1,239 @@
import clsx from "clsx";
import { FC } from "react";
import Lock from "@dndbeyond/fontawesome-cache/svgs/regular/lock.svg";
import Check from "@dndbeyond/fontawesome-cache/svgs/sharp-regular/check.svg";
import { Button } from "@dndbeyond/ttui/components/Button";
import useApiCall from "~/hooks/useApiCall";
import {
logCharacterCampaignClicked,
logUnlockCharacterLocked,
logUnlockCharacterUnlocked,
} from "../../../../helpers/analytics";
import {
copyCharacter as copyCharacterApi,
deleteCharacter as deleteCharacterApi,
leaveCampaign as leaveCampaignApi,
activateCharacter as activateCharacterApi,
} from "../../../../helpers/characterServiceApi";
import {
getAvatarUrl,
getBackdropUrl,
getCampaignDetailsLink,
getCampaignName,
getCoverImageUrl,
getDetailsLink,
getIsAssigned,
getName,
getSecondaryInfo,
getStatus,
getStatusSlug,
isInCampaign,
} from "../../../../state/selectors/characterUtils";
import { CharacterData, CharacterStatusEnum } from "../../../../types";
import { ApiStatusIndicator } from "../ApiStatusIndicator";
import "./CharacterCard.scss";
import { CharacterCardLinks } from "./CharacterCardLinks";
import { LeaveCampaignButton } from "./LeaveCampaignButton";
import styles from "./styles.module.css";
export interface CharacterCardProps {
character: CharacterData;
reloadListing: () => void;
lockCharacter: (character: CharacterData) => void;
unlockCharacter: (character: CharacterData) => void;
canUnlockMore: boolean;
hasLockedCharacters: boolean;
overCharacterLimit: boolean;
maxCharacterSlotsAllowed: number | null;
currentCharacterCount: number;
}
export const CharacterCard: FC<CharacterCardProps> = ({
character,
reloadListing,
lockCharacter,
unlockCharacter,
canUnlockMore,
hasLockedCharacters,
overCharacterLimit,
maxCharacterSlotsAllowed,
currentCharacterCount,
}) => {
const [copyCharacter, isCopyingCharacter, errorCopyingCharacter] = useApiCall(
copyCharacterApi,
reloadListing
);
const [deleteCharacter, isDeletingCharacter, errorDeletingCharacter] =
useApiCall(deleteCharacterApi, reloadListing);
const [leaveCampaign, isLeavingCampaign, errorLeavingCampaign] = useApiCall(
leaveCampaignApi,
reloadListing
);
const [activateCharacter, isActivatingCharacter, errorActivatingCharacter] =
useApiCall(activateCharacterApi, reloadListing);
const isLocked = getStatus(character) === CharacterStatusEnum.Locked;
const handleToggleLock = () => {
if (isLocked) {
unlockCharacter(character);
logUnlockCharacterUnlocked();
} else {
lockCharacter(character);
logUnlockCharacterLocked();
}
};
return (
<li className="ddb-campaigns-character-card-wrapper j-characters-listing__item">
<div
className={clsx([`status-${getStatusSlug(character)}`, styles.card])}
>
<div className="ddb-campaigns-character-card-header">
{getBackdropUrl(character) ? (
<div
className="ddb-campaigns-character-card-header-cover-image ddb-campaigns-character-card-header-cover-image-user-backdrop"
style={{
backgroundImage: `url(${getBackdropUrl(character)})`,
}}
/>
) : (
<div
className="ddb-campaigns-character-card-header-cover-image"
style={{
backgroundImage: `url(${getCoverImageUrl(character)})`,
}}
/>
)}
<div className="ddb-campaigns-character-card-header-upper">
{!hasLockedCharacters &&
getStatus(character) === CharacterStatusEnum.Active && (
// eslint-disable-next-line jsx-a11y/anchor-has-content
<a
href={getDetailsLink(character)}
className="ddb-campaigns-character-card-header-upper-details-link"
// There already is a view link - this is just making the top area clickable so it shouldn't be tabbed to or announced
tabIndex={-1}
aria-hidden={true}
/>
)}
<div className="ddb-campaigns-character-card-header-upper-portrait">
{getAvatarUrl(character) ? (
<div
className="image user-selected-avatar"
style={{
backgroundImage: `url(${getAvatarUrl(character)})`,
}}
/>
) : (
<div className="image default-character-avatar" />
)}
</div>
<div className="ddb-campaigns-character-card-header-upper-character-info">
<h2 className={styles.name}>{getName(character)}</h2>
<div className="ddb-campaigns-character-card-header-upper-character-info-secondary">
{getSecondaryInfo(character)}
</div>
</div>
</div>
</div>
{hasLockedCharacters && (
<Button
className={clsx([styles.cornerButton, isLocked && styles.locked])}
color="primary"
aria-label={isLocked ? "Click to unlock" : "Click to lock"}
onClick={handleToggleLock}
disabled={isLocked && !canUnlockMore}
>
{isLocked ? (
<Lock className={styles.lockIcon} />
) : (
<Check className={styles.checkIcon} />
)}
</Button>
)}
{isInCampaign(character) && (
<div className="ddb-campaigns-character-card-campaign-links">
<div className="ddb-campaigns-character-card-campaign-links-campaign">
<strong>Campaign:</strong>{" "}
{!hasLockedCharacters ? (
<a
className="ddb-campaigns-character-card-campaign-links-campaign-link"
href={getCampaignDetailsLink(character)}
onClick={() => logCharacterCampaignClicked()}
>
{getCampaignName(character)}
</a>
) : (
<span>{getCampaignName(character)}</span>
)}
{!getIsAssigned(character) && " (Unassigned)"}
</div>
<div className="ddb-campaigns-character-card-campaign-links-actions">
{!hasLockedCharacters && (
<LeaveCampaignButton
character={character}
leaveCampaign={() => leaveCampaign(character)}
/>
)}
</div>
</div>
)}
<div className="ddb-campaigns-character-card-footer">
<div
className={[
"ddb-campaigns-character-card-footer-links",
hasLockedCharacters && "is-unlocking",
]
.filter(Boolean)
.join(" ")}
>
<CharacterCardLinks
character={character}
hasLockedCharacters={hasLockedCharacters}
overCharacterLimit={overCharacterLimit}
copyCharacter={() => copyCharacter(character)}
deleteCharacter={() => deleteCharacter(character)}
lockCharacter={lockCharacter}
unlockCharacter={unlockCharacter}
canUnlockMore={canUnlockMore}
maxCharacterSlotsAllowed={maxCharacterSlotsAllowed}
activateCharacter={() => activateCharacter(character)}
currentCharacterCount={currentCharacterCount}
/>
</div>
<ApiStatusIndicator
isLoading={isCopyingCharacter}
error={errorCopyingCharacter}
loadingMessage={`Copying ${getName(character)}`}
errorMessage={`Error copying ${getName(character)}`}
/>
<ApiStatusIndicator
isLoading={isDeletingCharacter}
error={errorDeletingCharacter}
loadingMessage={`Deleting ${getName(character)}`}
errorMessage={`Error deleting ${getName(character)}`}
/>
<ApiStatusIndicator
isLoading={isActivatingCharacter}
error={errorActivatingCharacter}
loadingMessage={`Activating ${getName(character)}`}
errorMessage={`Error activating ${getName(character)}`}
/>
<ApiStatusIndicator
isLoading={isLeavingCampaign}
error={errorLeavingCampaign}
loadingMessage={`Leaving ${getCampaignName(character)}`}
errorMessage={`Error leaving ${getCampaignName(character)}`}
/>
</div>
</div>
</li>
);
};
@@ -0,0 +1,116 @@
import { Button } from "@dndbeyond/ttui/components/Button";
import { CharacterStatusEnum } from "~/types";
import {
logCharacterEditClicked,
logCharacterViewClicked,
logUnlockCharacterLocked,
logUnlockCharacterUnlocked,
} from "../../../../../helpers/analytics";
import {
getDetailsLink,
getEditLink,
getStatus,
} from "../../../../../state/selectors/characterUtils";
import { ActivateButton } from "../ActivateButton";
import type { CharacterCardProps } from "../CharacterCard";
import { CopyButton } from "../CopyButton";
import { DeleteButton } from "../DeleteButton";
import styles from "./styles.module.css";
interface CharacterCardLinksProps
extends Omit<CharacterCardProps, "reloadListing"> {
copyCharacter: () => void;
deleteCharacter: () => void;
activateCharacter: () => void;
}
export const CharacterCardLinks: React.FC<CharacterCardLinksProps> = ({
character,
hasLockedCharacters,
overCharacterLimit,
copyCharacter,
deleteCharacter,
lockCharacter,
unlockCharacter,
canUnlockMore,
maxCharacterSlotsAllowed,
activateCharacter,
currentCharacterCount,
}) => {
const isLocked = getStatus(character) === CharacterStatusEnum.Locked;
const showActivateButton =
maxCharacterSlotsAllowed === null ||
currentCharacterCount < maxCharacterSlotsAllowed;
const handleToggleLock = () => {
if (isLocked) {
unlockCharacter(character);
logUnlockCharacterUnlocked();
} else {
lockCharacter(character);
logUnlockCharacterLocked();
}
};
if (hasLockedCharacters)
return (
<>
<Button
size="small"
variant={isLocked ? "outline" : "solid"}
onClick={handleToggleLock}
disabled={isLocked && !canUnlockMore}
title={
isLocked && !canUnlockMore
? "Max characters selected to unlock. Lock other characters to unlock this character."
: undefined
}
>
{isLocked ? "Select" : "Deselect"}
</Button>
<p className={styles.text}>
{isLocked
? "This Character will be deactivated."
: "This Character will be activated."}
</p>
</>
);
return (
<>
{!isLocked && (
<>
<Button
size="small"
variant="text"
href={getDetailsLink(character)}
onClick={() => logCharacterViewClicked()}
>
View
</Button>
<Button
size="small"
variant="text"
href={getEditLink(character)}
onClick={() => logCharacterEditClicked()}
>
Edit
</Button>
{!overCharacterLimit && (
<CopyButton character={character} copyCharacter={copyCharacter} />
)}
</>
)}
{isLocked && showActivateButton && (
<ActivateButton
character={character}
activateCharacter={activateCharacter}
maxCharacterSlotsAllowed={maxCharacterSlotsAllowed}
/>
)}
<DeleteButton character={character} deleteCharacter={deleteCharacter} />
</>
);
};
@@ -0,0 +1,54 @@
import { useState } from "react";
import { Button } from "@dndbeyond/ttui/components/Button";
import {
logCharacterCopyCancelled,
logCharacterCopyClicked,
logCharacterCopyConfirmed,
} from "../../../../../helpers/analytics";
import { getName } from "../../../../../state/selectors/characterUtils";
import { CharacterData } from "../../../../../types";
import { ConfirmationModal } from "../../ConfirmationModal";
interface CopyProps {
character: CharacterData;
copyCharacter: () => void;
}
export const CopyButton: React.FC<CopyProps> = ({
character,
copyCharacter,
}) => {
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<ConfirmationModal
onClose={() => {
setIsOpen(false);
logCharacterCopyCancelled();
}}
isOpen={isOpen}
onConfirm={() => {
copyCharacter();
logCharacterCopyConfirmed();
setIsOpen(false);
}}
message={`Are you sure you want to copy ${getName(character)}?`}
title="Copy this character?"
confirmText="Copy"
/>
<Button
variant="text"
size="small"
onClick={() => {
logCharacterCopyClicked();
setIsOpen(true);
}}
>
Copy
</Button>
</div>
);
};
@@ -0,0 +1,58 @@
import { useState } from "react";
import { Button } from "@dndbeyond/ttui/components/Button";
import {
logCharacterDeleteCancelled,
logCharacterDeleteClicked,
logCharacterDeleteConfirmed,
} from "../../../../../helpers/analytics";
import { getName } from "../../../../../state/selectors/characterUtils";
import { CharacterData } from "../../../../../types";
import { ConfirmationModal } from "../../ConfirmationModal";
interface DeleteButtonProps {
character: CharacterData;
deleteCharacter: () => void;
}
export const DeleteButton: React.FC<DeleteButtonProps> = ({
character,
deleteCharacter,
}) => {
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<ConfirmationModal
isOpen={isOpen}
onClose={() => {
logCharacterDeleteCancelled();
setIsOpen(false);
}}
onConfirm={() => {
deleteCharacter();
logCharacterDeleteConfirmed();
setIsOpen(false);
}}
message={`To delete ${getName(
character
)}, type the word DELETE into the field below.`}
title="Delete this character?"
typeValueToConfirm="delete"
confirmText="Delete"
/>
<Button
size="small"
variant="text"
color="error"
onClick={() => {
logCharacterDeleteClicked();
setIsOpen(true);
}}
>
Delete
</Button>
</div>
);
};
@@ -0,0 +1,57 @@
import { useState } from "react";
import {
logCharacterLeaveCampaignCancelled,
logCharacterLeaveCampaignClicked,
logCharacterLeaveCampaignConfirmed,
} from "../../../../../helpers/analytics";
import {
getCampaignName,
getName,
} from "../../../../../state/selectors/characterUtils";
import { CharacterData } from "../../../../../types";
import { ConfirmationModal } from "../../ConfirmationModal";
import styles from "./styles.module.css";
interface LeaveCampaignProps {
character: CharacterData;
leaveCampaign: () => void;
}
export const LeaveCampaignButton: React.FC<LeaveCampaignProps> = ({
character,
leaveCampaign,
}) => {
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<ConfirmationModal
isOpen={isOpen}
onClose={() => {
logCharacterLeaveCampaignCancelled();
setIsOpen(false);
}}
onConfirm={() => {
leaveCampaign();
logCharacterLeaveCampaignConfirmed();
setIsOpen(false);
}}
message={`Are you sure you want ${getName(
character
)} to leave ${getCampaignName(character)}?`}
title="Leave this Campaign?"
confirmText="Leave"
/>
<button
className={styles.button}
onClick={() => {
logCharacterLeaveCampaignClicked();
setIsOpen(true);
}}
>
Leave Campaign
</button>
</div>
);
};
@@ -0,0 +1,409 @@
import React, { useCallback, useEffect, useState } from "react";
import ArrowDown from "@dndbeyond/fontawesome-cache/svgs/regular/arrow-down-to-line.svg";
import { Button } from "@dndbeyond/ttui/components/Button";
import { MaxCharactersDialog } from "~/components/MaxCharactersDialog";
import config from "~/config";
import { UserPreferenceProvider } from "~/tools/js/smartComponents/UserPreference";
import { logUnlockSubscribeClicked } from "../../../../helpers/analytics";
import { byProp, createSortValue } from "../../../../helpers/sortUtils";
import useLocalStorage from "../../../../hooks/useLocalStorage";
import useSubscriptionTier, {
FREE_TIER,
HERO_TIER,
} from "../../../../hooks/useSubscriptionTier";
import useUserId from "../../../../hooks/useUserId";
import {
getId,
getSearchableTerms,
getStatus,
SortByPropMap,
} from "../../../../state/selectors/characterUtils";
import {
CharacterData,
CharacterStatusEnum,
SortOrderEnum,
SortTypeEnum,
} from "../../../../types";
import { ApiStatusIndicator } from "../ApiStatusIndicator";
import { CharacterCard } from "../CharacterCard";
import { SubscriptionBanner } from "../SubscriptionBanner";
import { SearchSort } from "./SearchSort";
import { SecondaryHeader } from "./SecondaryHeader";
import styles from "./styles.module.css";
const overrideStatusForUnlock = ({
character,
characterIdsToUnlock,
hasLockedCharacters,
}) => {
if (!hasLockedCharacters) {
return getStatus(character);
}
return characterIdsToUnlock.includes(getId(character))
? CharacterStatusEnum.Active
: CharacterStatusEnum.Locked;
};
export interface SortState {
sortBy: SortTypeEnum;
sortOrder: SortOrderEnum;
}
export interface CharacterGridProps {
className?: string;
isLoading: boolean;
characters: Array<CharacterData>;
loadingError?: Error | null;
reload: () => void;
hasLockedCharacters: boolean;
maxCharacterSlotsAllowed: number | null;
hasMaxCharacters: boolean;
}
export const CharacterGrid: React.FC<CharacterGridProps> = ({
className = "",
isLoading,
characters,
loadingError,
reload,
hasLockedCharacters,
maxCharacterSlotsAllowed,
hasMaxCharacters,
}) => {
const userId = useUserId();
const subscriptionTier = useSubscriptionTier();
const [sortPreference, setSortPreference] = useLocalStorage<string>(
`MY_CHARACTERS_SORT_PREFERENCE:${userId}`,
createSortValue(SortTypeEnum.Created, SortOrderEnum.Ascending)
);
const [search, setSearch] = useState("");
const [sort, setSort] = useState<SortState>({
sortBy: SortTypeEnum.Created,
sortOrder: SortOrderEnum.Ascending,
});
const [isMaxCharacterMessageOpen, setIsMaxCharacterMessageOpen] =
useState(false);
const [characterIdsToUnlock, setCharacterIdsToUnlock] = useState<
Array<number>
>([]);
const canUnlockMore: boolean = maxCharacterSlotsAllowed
? characterIdsToUnlock.length < maxCharacterSlotsAllowed
: true;
useEffect(() => {
// Don't try to set the sort if there is no preference or if the listing is currently loading results
if (!sortPreference || isLoading) {
return;
}
const [sortPreferenceBy, sortPreferenceOrder] = sortPreference.split("-");
// If they are false then use the default
if (!sortPreferenceBy || !sortPreferenceOrder) {
return;
}
// Prevent an infinite loop of reloading once the sort preference has actually been applied
if (
sortPreferenceBy === sort.sortBy &&
sortPreferenceOrder === sort.sortOrder
) {
return;
}
setSort({
sortBy: sortPreferenceBy as SortTypeEnum,
sortOrder: sortPreferenceOrder as SortOrderEnum,
});
}, [isLoading, sortPreference, sort]);
useEffect(() => {
if (hasLockedCharacters && characterIdsToUnlock.length === 0) {
setCharacterIdsToUnlock(
characters
.filter(
(character) => getStatus(character) === CharacterStatusEnum.Active
)
.map(getId)
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasLockedCharacters, characters]);
const unlockCharacter = useCallback(
(character: CharacterData) => {
if (!characterIdsToUnlock.includes(getId(character))) {
setCharacterIdsToUnlock([...characterIdsToUnlock, getId(character)]);
}
},
[characterIdsToUnlock]
);
const lockCharacter = useCallback(
(character: CharacterData) => {
if (characterIdsToUnlock.includes(getId(character))) {
setCharacterIdsToUnlock(
characterIdsToUnlock.filter((id) => id !== getId(character))
);
}
},
[characterIdsToUnlock]
);
let filteredCharacters = characters.filter((character) => {
return getSearchableTerms(character)
.join(" ")
.toLowerCase()
.includes(search.toLowerCase());
});
const sortByPropSelector = SortByPropMap[sort.sortBy];
if (sortByPropSelector) {
filteredCharacters = filteredCharacters.sort(byProp(sortByPropSelector));
if (sort.sortOrder === SortOrderEnum.Descending) {
filteredCharacters.reverse();
}
}
let lockedCharacters: Array<CharacterData> = [];
if (!hasLockedCharacters && canUnlockMore) {
lockedCharacters = filteredCharacters.filter(
(character) => getStatus(character) === CharacterStatusEnum.Locked
);
filteredCharacters = filteredCharacters.filter(
(character) => getStatus(character) === CharacterStatusEnum.Active
);
}
const characterCount: number = characters.filter(
(character) => getStatus(character) === CharacterStatusEnum.Active
).length;
const overCharacterLimit: boolean =
maxCharacterSlotsAllowed !== null &&
characterCount >= maxCharacterSlotsAllowed;
const createCharacterHref = !hasMaxCharacters
? `${config.basePathname}/builder`
: undefined;
const createCharacterOnClick = hasMaxCharacters
? () => setIsMaxCharacterMessageOpen(true)
: undefined;
return (
<UserPreferenceProvider>
<div
className={[className, "ddb-characters-listing"]
.filter(Boolean)
.join(" ")}
>
<div className="ddb-characters-listing-header">
{/*
The CTA component will be shown depending on some conditions:
- If the user doesn't have a subscription, and has 6 characters, show the Hero CTA
- If the user has a Hero subscription, show the Master CTA
Remember that the Images for the CTA's are served by Waterdeep (then, the images could be shown as broken)
*/}
{subscriptionTier === FREE_TIER &&
characterCount === maxCharacterSlotsAllowed && (
<SubscriptionBanner
iconType="hero"
text="Your party is full. Unlock unlimited character creation."
buttonLabel="Subscribe Now!"
onClick={() =>
(window.location.href = `${config.ddbBaseUrl}/store/subscribe#plans`)
}
/>
)}
{subscriptionTier.includes(HERO_TIER) &&
characterCount === maxCharacterSlotsAllowed && (
<SubscriptionBanner
iconType="master"
text="Unlock your full potential and EXCLUSIVE perks."
buttonLabel="Upgrade Now!"
onClick={() =>
(window.location.href = `${config.ddbBaseUrl}/store/subscribe#plans`)
}
/>
)}
<header className={styles.header}>
<h1 className={styles.h1}>My Characters</h1>
<Button
className={styles.button}
href={createCharacterHref}
onClick={createCharacterOnClick}
>
Create A Character
</Button>
</header>
<div className={styles.subheader}>
<p className={styles.slots}>
Slots:
<span className={styles.count}>
{characterCount}/
{/* TODO: Come back and use the subscription role to figure this out? This number won't be the same as our DB max int */}
{maxCharacterSlotsAllowed === null ||
maxCharacterSlotsAllowed === 2147483647
? "Unlimited"
: `${maxCharacterSlotsAllowed} Used`}
</span>
</p>
<div className={styles.pdfLinkWrapper}>
<a
href="https://media.dndbeyond.com/compendium-images/free-rules/ph/character-sheet.pdf"
target="_blank"
className={styles.pdfLink}
>
<ArrowDown className={styles.pdfLinkSvg}></ArrowDown>
Download a blank character sheet
</a>
</div>
</div>
<div className="ddb-characters-listing-header-secondary">
<SecondaryHeader
hasLockedCharacters={hasLockedCharacters}
maxCharacterSlotsAllowed={maxCharacterSlotsAllowed}
characterCount={characterCount}
characters={characters}
characterIdsToUnlock={characterIdsToUnlock}
subscriptionTier={subscriptionTier}
/>
</div>
</div>
<SearchSort
search={search}
onSearch={setSearch}
sort={sort}
onSort={setSort}
sortPreference={sortPreference}
onSortPreference={setSortPreference}
/>
<div className="ddb-characters-listing-body j-characters-listing__content">
<div className="listing-container listing-container-ul RPGCharacter-listing">
<div className="listing-body">
<ul className="listing listing-rpgcharacter rpgcharacter-listing">
{filteredCharacters.map((character) => (
<CharacterCard
key={`listing-item_${getId(character)}`}
character={{
...character,
status: overrideStatusForUnlock({
character,
characterIdsToUnlock,
hasLockedCharacters,
}),
}}
hasLockedCharacters={hasLockedCharacters}
overCharacterLimit={overCharacterLimit}
reloadListing={reload}
lockCharacter={lockCharacter}
unlockCharacter={unlockCharacter}
canUnlockMore={canUnlockMore}
maxCharacterSlotsAllowed={maxCharacterSlotsAllowed}
currentCharacterCount={characterCount}
/>
))}
</ul>
{filteredCharacters.length === 0 && (
<>
{!!search ? (
<>
<p className={styles.noResultsTitle}>
It looks like we failed our investigation check.
</p>
<p className={styles.noResultsText}>
Cast guidance on us by refining your search, and
we&apos;ll try again!
</p>
</>
) : (
<>
<p className={styles.noResultsTitle}>
Looks like you haven&apos;t created a character yet.
</p>
<p className={styles.noResultsText}>
Start your adventure by creating a character!
</p>
</>
)}
</>
)}
</div>
{lockedCharacters.length > 0 && (
<>
<h2 className={styles.deactivatedHeading}>
Deactivated Characters
</h2>
<p className={styles.deactivatedText}>
Activate characters by freeing up a slot, or add slots with a
<Button
className={styles.deactivatedButton}
variant="text"
size="small"
href={`${config.ddbBaseUrl}/subscribe`}
onClick={() => logUnlockSubscribeClicked()}
>
D&amp;D Beyond subscription.
</Button>
</p>
<div className="listing-body">
<ul className="listing listing-rpgcharacter rpgcharacter-listing">
{lockedCharacters.map((character) => (
<CharacterCard
key={`listing-item_${getId(character)}`}
character={{
...character,
status: overrideStatusForUnlock({
character,
characterIdsToUnlock,
hasLockedCharacters,
}),
}} // TODO can this logic go in the utils?
hasLockedCharacters={hasLockedCharacters}
overCharacterLimit={overCharacterLimit}
reloadListing={reload}
lockCharacter={lockCharacter}
unlockCharacter={unlockCharacter}
canUnlockMore={canUnlockMore}
maxCharacterSlotsAllowed={maxCharacterSlotsAllowed}
currentCharacterCount={characterCount}
/>
))}
</ul>
</div>
</>
)}
</div>
</div>
<div className="ddb-characters-listing__loading-indicator-wrapper">
<ApiStatusIndicator
isLoading={isLoading}
error={loadingError || undefined}
loadingMessage={"Loading Characters"}
errorMessage={
loadingError ? loadingError.message : "Error Loading Characters"
}
></ApiStatusIndicator>
</div>
<div className="ddbcl-my-characters-listing__version">
My Characters Version: v{config.version}
</div>
<MaxCharactersDialog
open={isMaxCharacterMessageOpen}
onClose={() => setIsMaxCharacterMessageOpen(false)}
/>
</div>
</UserPreferenceProvider>
);
};
@@ -0,0 +1,81 @@
import { useState } from "react";
import { Button } from "@dndbeyond/ttui/components/Button";
import {
logUnlockFinishUnlockingCancelled,
logUnlockFinishUnlockingClicked,
logUnlockFinishUnlockingConfirmed,
} from "../../../../../helpers/analytics";
import { CharacterData } from "../../../../../types";
import { ConfirmationModal } from "../../ConfirmationModal";
import { UnlockConfirmation } from "../UnlockConfirmation";
import styles from "./styles.module.css";
interface FinalizeUnlockProps {
charactersToUnlock: Array<CharacterData>;
maxCharacterSlotsAllowed: number | null;
finalizeUnlock: () => void;
disabled: boolean;
}
export const FinalizeUnlock: React.FC<FinalizeUnlockProps> = ({
charactersToUnlock,
finalizeUnlock,
disabled,
maxCharacterSlotsAllowed,
}) => {
const [isOpen, setIsOpen] = useState(false);
const charactersToUnlockCount = charactersToUnlock.length;
const handleClose = () => {
logUnlockFinishUnlockingCancelled(
charactersToUnlockCount,
maxCharacterSlotsAllowed
);
setIsOpen(false);
};
const handleConfirm = () => {
finalizeUnlock();
logUnlockFinishUnlockingConfirmed(
charactersToUnlockCount,
maxCharacterSlotsAllowed
);
setIsOpen(false);
};
const handleClick = () => {
setIsOpen(true);
logUnlockFinishUnlockingClicked(
charactersToUnlockCount,
maxCharacterSlotsAllowed
);
};
return (
<>
<ConfirmationModal
onClose={handleClose}
isOpen={isOpen}
onConfirm={handleConfirm}
message={
<UnlockConfirmation
charactersToUnlock={charactersToUnlock}
maxCharacterSlotsAllowed={maxCharacterSlotsAllowed}
message={
<p className={styles.text}>
Other characters will remain deactivated until you free up a
slot or add slots with a D&D Beyond subscription.
</p>
}
/>
}
title="Activate these characters?"
/>
<Button size="large" disabled={disabled} onClick={handleClick}>
Continue
</Button>
</>
);
};
@@ -0,0 +1,160 @@
import {
ChangeEvent,
Dispatch,
FC,
SetStateAction,
useRef,
useState,
} from "react";
import Gear from "@dndbeyond/fontawesome-cache/svgs/regular/gear.svg";
import CircleXMark from "@dndbeyond/fontawesome-cache/svgs/solid/circle-xmark.svg";
import MagnifyingGlass from "@dndbeyond/fontawesome-cache/svgs/solid/magnifying-glass.svg";
import { Button } from "@dndbeyond/ttui/components/Button";
import { Select } from "@dndbeyond/ttui/components/Select";
import PreferenceUpdateLocation from "~/tools/js/Shared/constants/PreferenceUpdateLocation";
import CharacterSettingsModal from "~/tools/js/smartComponents/CharacterSettingsModal";
import { SortOrderEnum, SortTypeEnum } from "~/types";
import { logListingSortChanged } from "../../../../../helpers/analytics";
import { createSortValue } from "../../../../../helpers/sortUtils";
import { SortState } from "../CharacterGrid";
import styles from "./styles.module.css";
const sortOptions = [
{
value: createSortValue(SortTypeEnum.Created, SortOrderEnum.Descending),
label: "Created: Newest",
},
{
value: createSortValue(SortTypeEnum.Created, SortOrderEnum.Ascending),
label: "Created: Oldest",
},
{
value: createSortValue(SortTypeEnum.Name, SortOrderEnum.Ascending),
label: "Name: A to Z",
},
{
value: createSortValue(SortTypeEnum.Name, SortOrderEnum.Descending),
label: "Name: Z to A",
},
{
value: createSortValue(SortTypeEnum.Level, SortOrderEnum.Ascending),
label: "Level: Low to High",
},
{
value: createSortValue(SortTypeEnum.Level, SortOrderEnum.Descending),
label: "Level: High to Low",
},
{
value: createSortValue(SortTypeEnum.Modified, SortOrderEnum.Descending),
label: "Modified: Latest",
},
{
value: createSortValue(SortTypeEnum.Modified, SortOrderEnum.Ascending),
label: "Modified: Oldest",
},
];
interface SearchSortProps {
search: string;
onSearch: Dispatch<SetStateAction<string>>;
sort: SortState;
onSort: Dispatch<SetStateAction<SortState>>;
sortPreference: string;
onSortPreference: Dispatch<SetStateAction<string>>;
}
export const SearchSort: FC<SearchSortProps> = ({
search,
onSearch,
sort,
onSort,
sortPreference,
onSortPreference,
}) => {
const [showSettingsModal, setShowSettingsModal] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(null);
const handleSearchClick = () => {
if (searchInputRef.current) {
searchInputRef.current.focus();
}
};
const handleSort = (e: ChangeEvent<HTMLInputElement>) => {
const { value } = e.target;
const [sortBy, sortOrder] = value.split("-");
onSort({
sortBy: sortBy as SortTypeEnum,
sortOrder: sortOrder as SortOrderEnum,
});
onSortPreference(value);
logListingSortChanged(value);
};
const getSortValue =
sortOptions.find(
(opt) => opt.value === createSortValue(sort.sortBy, sort.sortOrder)
) || sortOptions[0];
return (
<div className={styles.searchSort}>
<div className={styles.search} onClick={handleSearchClick}>
<MagnifyingGlass />
<label className={styles.searchLabel} htmlFor="search">
Search
</label>
<input
className={styles.searchInput}
type="search"
id="search"
placeholder={"Search by Name, Level, Class, Species, or Campaign"}
value={search}
onChange={(e) => onSearch(e.target.value)}
ref={searchInputRef}
/>
{search && (
<button
className={styles.clearButton}
onClick={(e) => onSearch("")}
aria-label="Clear search terms"
>
<CircleXMark className={styles.clearIcon} />
</button>
)}
</div>
<Select
className={styles.sort}
placeholder="Select a movement"
name="sort"
label="Sort By"
value={getSortValue.label}
options={sortOptions}
onChange={handleSort}
/>
<>
<Button
className={styles.settingsButton}
variant="outline"
size="small"
onClick={() => setShowSettingsModal(true)}
>
<Gear />
<span className={styles.settingsButtonText}>Settings</span>
</Button>
<CharacterSettingsModal
open={showSettingsModal}
updateLocation={PreferenceUpdateLocation.CharacterListing}
handleClose={() => setShowSettingsModal(false)}
/>
</>
</div>
);
};
@@ -0,0 +1,127 @@
import { toWords } from "number-to-words";
import { FC, useEffect, useRef } from "react";
import SpinnerThird from "@dndbeyond/fontawesome-cache/svgs/regular/spinner-third.svg";
import useApiCall from "~/hooks/useApiCall";
import config from "../../../../../config";
import { logUnlockSubscribeClicked } from "../../../../../helpers/analytics";
import { unlockCharacters } from "../../../../../helpers/characterServiceApi";
import { getId } from "../../../../../state/selectors/characterUtils";
import { CharacterGridProps } from "../CharacterGrid";
import { FinalizeUnlock } from "../FinalizeUnlock";
import styles from "./styles.module.css";
interface SecondaryHeaderProps
extends Pick<
CharacterGridProps,
"maxCharacterSlotsAllowed" | "characters" | "hasLockedCharacters"
> {
characterCount: number;
subscriptionTier: string;
characterIdsToUnlock: Array<number>;
}
export const SecondaryHeader: FC<SecondaryHeaderProps> = ({
hasLockedCharacters,
maxCharacterSlotsAllowed,
characterCount,
subscriptionTier,
characters,
characterIdsToUnlock,
}) => {
const dialogRef = useRef<HTMLDialogElement>(null);
const [
finalizeUnlock,
isFinalizingUnlock,
// Unlock state depends on context state passed by Waterdeep on the root element so we have to do a full page reload after a successful unlock.
] = useApiCall(unlockCharacters, () => window.location.reload());
useEffect(() => {
if (isFinalizingUnlock) {
// Hacky workaround since showModal() isn't
// supported by TypeScript <4.8.3
(dialogRef.current as any)?.showModal();
} else {
// Hacky workaround since close() isn't
// supported by TypeScript <4.8.3
(dialogRef.current as any)?.close();
}
}, [isFinalizingUnlock]);
if (hasLockedCharacters) {
return (
<>
{/* Loading Dialog */}
<dialog
className={styles.dialog}
ref={dialogRef}
onClick={(evt) => {
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
}}
>
<SpinnerThird className={styles.spinner} />
</dialog>
{/* Max Characters Alert */}
<div className={styles.alert}>
<p className={styles.alertTitle}>Character Slots Exceeded</p>
Your current D&amp;D Beyond Membership is:{" "}
<strong>
{subscriptionTier.toUpperCase()} ({maxCharacterSlotsAllowed} slots).{" "}
</strong>
To continue, select up to{" "}
<strong>
{maxCharacterSlotsAllowed
? `${toWords(
maxCharacterSlotsAllowed
)} (${maxCharacterSlotsAllowed}) `
: "unlimited "}
</strong>
characters to activate. Your other characters will be deactivated
until you restore them by deleting an active character to free up a
slot, or by adding more slots with a{` `}
<a
href={`${config.ddbBaseUrl}/subscribe`}
onClick={() => logUnlockSubscribeClicked()}
>
D&amp;D Beyond Subscription
</a>
</div>
{/* Bottom Unlock Bar */}
<div className={styles.bottomBar}>
<div className={styles.bottomContainer}>
<p className={styles.bottomText}>
Selected:{" "}
<strong>
{characterIdsToUnlock.length} / {maxCharacterSlotsAllowed}
</strong>
</p>
<FinalizeUnlock
charactersToUnlock={characters.filter((character) =>
characterIdsToUnlock.includes(getId(character))
)}
finalizeUnlock={() => finalizeUnlock(characterIdsToUnlock)}
disabled={isFinalizingUnlock}
maxCharacterSlotsAllowed={maxCharacterSlotsAllowed}
/>
</div>
</div>
</>
);
}
if (characterCount === 0) {
return (
<div className="ddb-characters-listing-count-active">
Character slots allow players to create characters. Once created, all of
your characters will appear in the list below.
</div>
);
}
return null;
};
@@ -0,0 +1,40 @@
import { LabelChip } from "@dndbeyond/ttui/components/LabelChip";
import { getName } from "../../../../../state/selectors/characterUtils";
import { CharacterData } from "../../../../../types";
import styles from "./styles.module.css";
export interface UnlockConfirmationProps {
charactersToUnlock: Array<CharacterData>;
maxCharacterSlotsAllowed: number | null;
message?: React.ReactNode;
}
export const UnlockConfirmation: React.FC<UnlockConfirmationProps> = ({
charactersToUnlock,
maxCharacterSlotsAllowed,
message = null,
}) => {
// TODO: Character Slots update copy
if (charactersToUnlock.length === 0)
return (
<p className={styles.unlockText}>
Are you sure you want to unlock no characters? All of your characters
will be permanently locked until a new subscription is applied to your
account, but you will be able to create and access up to{" "}
{maxCharacterSlotsAllowed} new characters. Locked characters do not
count towards your character limit.
</p>
);
return (
<div className={styles.pills}>
{charactersToUnlock.map(getName).map((characterName) => (
<LabelChip className={styles.pill} key={characterName}>
{characterName}
</LabelChip>
))}
{message && message}
</div>
);
};
@@ -0,0 +1,121 @@
import * as React from "react";
import { useEffect, useRef, useState } from "react";
import TriangleExclamation from "@dndbeyond/fontawesome-cache/svgs/regular/triangle-exclamation.svg";
import { Button } from "@dndbeyond/ttui/components/Button";
import styles from "./styles.module.css";
export interface ConfirmationModalProps {
onClose: () => void;
onConfirm: () => void;
typeValueToConfirm?: string;
isOpen: boolean;
message: React.ReactNode;
title: string;
confirmText?: string;
}
export const ConfirmationModal = ({
onClose,
onConfirm,
typeValueToConfirm,
isOpen,
message,
title,
confirmText = "Confirm",
}: ConfirmationModalProps) => {
const [confirmEnabled, setConfirmEnabled] = useState(!typeValueToConfirm);
const [inputValue, setInputValue] = useState("");
const dialogRef = useRef<HTMLDialogElement>(null);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { value } = e.target;
const isMatch =
value?.toLowerCase().trim() === typeValueToConfirm?.toLowerCase();
setInputValue(value);
if (isMatch) {
setConfirmEnabled(true);
} else if (confirmEnabled) {
setConfirmEnabled(false);
}
};
const handleConfirm = (e) => {
e.preventDefault();
if (confirmEnabled) onConfirm();
};
const handleClickBackdrop = (e) => {
const rect = dialogRef.current?.getBoundingClientRect();
const isClickInDialog =
rect &&
rect.top <= e.clientY &&
e.clientY <= rect.top + rect.height &&
rect.left <= e.clientX &&
e.clientX <= rect.left + rect.width;
if (!isClickInDialog) onClose();
};
useEffect(() => {
if (isOpen) {
// Hacky workaround since showModal() isn't
// supported by TypeScript <4.8.3
(dialogRef.current as any)?.showModal();
} else {
// Hacky workaround since close() isn't
// supported by TypeScript <4.8.3
(dialogRef.current as any)?.close();
}
}, [onClose, isOpen]);
return (
<dialog
className={styles.confirmationDialog}
ref={dialogRef}
onClick={handleClickBackdrop}
aria-labelledby="confirm-dialog"
>
<div className={styles.container}>
<header className={styles.header}>
<div id="confirm-dialog" className={styles.title}>
{title}
</div>
{!typeValueToConfirm ? (
<div className={styles.text}>{message}</div>
) : (
<div className={styles.alert}>
<TriangleExclamation className={styles.alertIcon} />
<span className={styles.alertText}>{message}</span>
</div>
)}
</header>
{typeValueToConfirm && (
<input
type="text"
className={styles.input}
onChange={handleChange}
value={inputValue}
autoFocus
/>
)}
<footer className={styles.footer}>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button
autoFocus={confirmEnabled}
disabled={!confirmEnabled}
onClick={handleConfirm}
>
{confirmText}
</Button>
</footer>
</div>
</dialog>
);
};
@@ -0,0 +1,65 @@
/**
* @depricated is this shared?
*/
import config from "~/config";
import "./Header.scss";
const BASE_PATHNAME = config.basePathname;
export function Header({ hasMaxCharacters }: { hasMaxCharacters: boolean }) {
return (
<header className="page-header">
<div className="page-header__backdrop"></div>
<div className="page-header__extras">
<div className="page-header__extra page-header__extra--content1">
<section className="atf" role="complementary">
{/* <!-- BREADCRUMBS --> */}
<nav className="b-breadcrumb b-breadcrumb-a">
{/* <!-- WRAPPER --> */}
<ul
className="b-breadcrumb-wrapper"
data-itemscope=""
data-itemtype="http://schema.org/BreadcrumbList"
>
{/* <!-- ITEM --> */}
<li
className="b-breadcrumb-item"
data-itemprop="itemListElement"
data-itemscope=""
data-itemtype="http://schema.org/ListItem"
>
<a href="/" rel="up" data-itemprop="item">
<span data-itemprop="name">Home</span>
</a>
<meta data-itemprop="position" content="1" />
</li>
{/* <!-- ITEM --> */}
<li
className="b-breadcrumb-item"
data-itemprop="itemListElement"
data-itemscope=""
data-itemtype="http://schema.org/ListItem"
>
<a href={BASE_PATHNAME} rel="up" data-itemprop="item">
<span data-itemprop="name">My Characters</span>
</a>
<meta data-itemprop="position" content="2" />
</li>
</ul>
</nav>
</section>
</div>
<div className="page-header__spacer"></div>
</div>
<div className="page-header__primary">
<div className="page-heading">
<div className="page-heading__content">
<h1 className="page-title">My Characters</h1>
</div>
</div>
</div>
</header>
);
}
@@ -0,0 +1,136 @@
import { useLayoutEffect, useEffect, useState } from "react";
import { useHeadContext } from "~/contexts/Head";
import { SessionNames } from "../../../../constants";
import { initAnalytics } from "../../../../helpers/analytics";
import { getFeatureFlagsFromCharacterService } from "../../../../helpers/characterServiceApi";
import useLocalStorage from "../../../../hooks/useLocalStorage";
import useUserId from "../../../../hooks/useUserId";
import useUserName from "../../../../hooks/useUserName";
import {
generateAppData,
getStatus,
} from "../../../../state/selectors/characterUtils";
import { CharacterStatusEnum, QueryResponseData } from "../../../../types";
import { CharacterGrid } from "../CharacterGrid";
import { Header } from "../Header";
import { PlayerAppBanner } from "../PlayerAppBanner";
import "./MyCharacters.scss";
interface Props {
characterQuery: {
isLoading: boolean;
data: QueryResponseData | undefined;
refetch: () => void;
error: Error | null;
};
}
export const MyCharacters = ({ characterQuery }: Props) => {
useEffect(() => {
initAnalytics(SessionNames.DDB);
}, []);
const userId = useUserId();
const userName = useUserName();
const { setTitle } = useHeadContext();
const [storeUrls, setStoreUrls] = useState({
appleStoreLink: "",
googlePlayStoreLink: "",
});
useLayoutEffect(() => {
document
.getElementsByTagName("body")[0]
.classList.add(
"site",
"site-dndbeyond",
"site-www",
"body-mycontent",
"body-mycontent-characterindex"
);
return () => {
document
.getElementsByTagName("body")[0]
.classList.remove(
"site",
"site-dndbeyond",
"site-www",
"body-mycontent",
"body-mycontent-characterindex"
);
};
}, []);
useEffect(() => {
async function getFlags() {
try {
const flagResponse = await getFeatureFlagsFromCharacterService([
"players-app-apple-store-link",
"players-app-google-play-store-link",
]);
const { data } = await flagResponse.json();
setStoreUrls({
appleStoreLink: data["players-app-apple-store-link"],
googlePlayStoreLink: data["players-app-google-play-store-link"],
});
} catch (e) {
/* no-op */
}
}
getFlags();
}, []);
useEffect(() => {
setTitle("My Characters");
}, [setTitle]);
const [playerAppBannerDismissed, setPlayerAppBannerDismissed] =
useLocalStorage(
`MY_CHARACTERS_PLAYER_APP_BANNER_DISMISSED:${userId}`,
false
);
const showPlayerAppBanner: boolean = !playerAppBannerDismissed;
const { isLoading, data, refetch, error } = characterQuery;
const { characters, hasLockedCharacters, maxCharacterSlotsAllowed } =
generateAppData(userName, data);
// Check for max active characters
const hasMaxCharacters =
characters?.filter(
(character) => getStatus(character) === CharacterStatusEnum.Active
).length >= (maxCharacterSlotsAllowed ?? Infinity);
return (
<div className="my-characters-wrapper">
<Header hasMaxCharacters={hasMaxCharacters} />
<div className="content">
{showPlayerAppBanner && (
<PlayerAppBanner
appleStoreLink={storeUrls.appleStoreLink}
googlePlayStoreLink={storeUrls.googlePlayStoreLink}
onDismiss={() => setPlayerAppBannerDismissed(true)}
/>
)}
<CharacterGrid
className="ddbcl-my-characters-listing"
hasLockedCharacters={hasLockedCharacters}
maxCharacterSlotsAllowed={maxCharacterSlotsAllowed}
characters={characters}
isLoading={isLoading}
hasMaxCharacters={hasMaxCharacters}
reload={refetch}
loadingError={error}
/>
{showPlayerAppBanner && (
<PlayerAppBanner
appleStoreLink={storeUrls.appleStoreLink}
googlePlayStoreLink={storeUrls.googlePlayStoreLink}
onDismiss={() => setPlayerAppBannerDismissed(true)}
/>
)}
</div>
</div>
);
};
@@ -0,0 +1,90 @@
import clsx from "clsx";
import React from "react";
import XMark from "@dndbeyond/fontawesome-cache/svgs/regular/xmark.svg";
import { Button } from "@dndbeyond/ttui/components/Button";
import {
logPlayerAppBannerClickedCta,
logPlayerAppBannerDismissed,
} from "../../../../helpers/analytics";
import appleStoreBadge from "../../../../images/apple-store-badge.svg?url";
import cellphones from "../../../../images/cellphones.png";
import googlePlayBadge from "../../../../images/google-play-badge.png";
import styles from "./styles.module.css";
export interface PlayerAppBannerProps {
appleStoreLink: string | "DISABLED";
className?: string;
googlePlayStoreLink: string | "DISABLED";
onDismiss: () => void;
}
export const PlayerAppBanner: React.FC<PlayerAppBannerProps> = ({
appleStoreLink,
className = "",
googlePlayStoreLink,
onDismiss,
}) => {
const handleClose = () => {
logPlayerAppBannerDismissed();
onDismiss();
};
return (
<aside
className={clsx(["ddbcl-player-app-banner", styles.banner, className])}
>
<p className={styles.text}>Play Anywhere, Anytime</p>
<img
className={styles.image}
src={cellphones}
alt="3 cell phones with Dungeons & Dragons"
width={200}
/>
<div className={styles.buttons}>
{appleStoreLink && appleStoreLink !== "DISABLED" && (
<a
className={styles.storeLink}
href={appleStoreLink}
target="_blank"
rel="noreferrer"
onClick={() => logPlayerAppBannerClickedCta("Apple")}
aria-label="Download the D&amp;D Beyond Player App on the Apple App Store"
>
<img
className={styles.storeImage}
src={appleStoreBadge}
alt="Apple app store button"
width={120}
/>
</a>
)}
{googlePlayStoreLink && googlePlayStoreLink !== "DISABLED" && (
<a
className={styles.storeLink}
href={googlePlayStoreLink}
target="_blank"
rel="noreferrer"
onClick={() => logPlayerAppBannerClickedCta("Apple")}
aria-label="Download the D&amp;D Beyond Player App on Google Play"
>
<img
className={styles.storeImage}
src={googlePlayBadge}
alt="Google Play store button"
width={134}
/>
</a>
)}
<Button
className={styles.closeButton}
onClick={handleClose}
aria-label="Dismiss player app banner"
>
<XMark className={styles.closeIcon} />
</Button>
</div>
</aside>
);
};
@@ -0,0 +1,43 @@
import { HTMLAttributes } from "react";
import { Button } from "~/components/Button";
import heroIcon from "../../../../images/icons/hero-tier-icon.png";
import masterIcon from "../../../../images/icons/master-tier-icon.png";
import styles from "./styles.module.css";
export interface SubscriptionBannerProps
extends HTMLAttributes<HTMLDivElement> {
iconType: "hero" | "master" | null;
text: string;
buttonLabel: string;
onClick: () => void;
}
export const SubscriptionBanner: React.FC<SubscriptionBannerProps> = ({
iconType,
text,
buttonLabel,
onClick,
...props
}) => {
return (
<div className={styles.container} data-testid="subscription-banner">
<div className={styles.detailContainer}>
{iconType && (
<img
src={iconType === "master" ? masterIcon : heroIcon}
alt={`${iconType} tier icon`}
className={styles.icon}
/>
)}
<div className={styles.text}>{text || props.children}</div>
</div>
{onClick && (
<Button variant="builder" onClick={onClick} className={styles.button}>
{buttonLabel}
</Button>
)}
</div>
);
};
@@ -0,0 +1,399 @@
import clsx from "clsx";
import { FC, HTMLAttributes, ReactNode } from "react";
import {
Creature,
CreatureUtils,
ConditionUtils,
DamageAdjustmentContract,
FormatUtils,
RuleData,
CreatureAbilityInfo,
} from "@dndbeyond/character-rules-engine/es";
import { HtmlContent } from "~/components/HtmlContent";
import {
AbilityStatMentalList,
AbilityStatPhysicalList,
DDB_MEDIA_URL,
StatBlockTypeEnum,
} from "../../../../constants";
import styles from "./styles.module.css";
interface Props extends HTMLAttributes<HTMLDivElement> {
variant: "default" | "basic";
creature: Creature;
ruleData: RuleData;
}
export const CreatureBlock: FC<Props> = ({
variant = "default",
creature,
ruleData,
className = "",
}) => {
const statBlockType = CreatureUtils.getStatBlockType(creature);
const isVersion2024 = statBlockType === StatBlockTypeEnum.CORE_RULES_2024;
const getDamageAdjustment = (
type: "vulnerability" | "resistance" | "immunity"
): string | null => {
let damageAdjustments: Array<DamageAdjustmentContract> = [];
switch (type) {
case "vulnerability":
damageAdjustments = CreatureUtils.getDamageVulnerabilities(creature);
break;
case "resistance":
damageAdjustments = CreatureUtils.getDamageResistances(creature);
break;
case "immunity":
damageAdjustments = CreatureUtils.getDamageImmunities(creature);
break;
default:
break;
}
if (!damageAdjustments.length) {
return null;
} else {
return damageAdjustments
.map((damageAdjustment) => damageAdjustment.name)
.join(", ");
}
};
const renderSeparator = (): ReactNode => {
if (isVersion2024) {
return null;
}
//TODO not hard-code the src url here?
return (
<img
className={styles.separator}
alt=""
src={`${DDB_MEDIA_URL}/file-attachments/0/579/stat-block-header-bar.svg`}
/>
);
};
const renderStatTable = (
stats: Array<CreatureAbilityInfo>,
type: "physical" | "mental"
): ReactNode => {
return (
<table className={clsx([styles.statTable, styles[type]])}>
<thead>
<tr>
<th />
<th />
<th>Mod</th>
<th>Save</th>
</tr>
</thead>
<tbody>
{stats.map((stat) => {
return (
<tr key={stat.id}>
<th>{stat.statKey}</th>
<td>{stat.score}</td>
<td className={styles.modifier}>
{FormatUtils.renderSignedNumber(stat.modifier)}
</td>
<td className={styles.modifier}>
{FormatUtils.renderSignedNumber(stat.saveModifier)}
</td>
</tr>
);
})}
</tbody>
</table>
);
};
const renderStats = (): ReactNode => {
const stats = CreatureUtils.getAbilities(creature);
if (isVersion2024) {
const mentalStats: Array<CreatureAbilityInfo> = [];
const physicalStats: Array<CreatureAbilityInfo> = [];
stats.forEach((ability) => {
if (AbilityStatPhysicalList.includes(ability.id)) {
physicalStats.push(ability);
}
if (AbilityStatMentalList.includes(ability.id)) {
mentalStats.push(ability);
}
});
return (
<div className={styles.stats}>
{renderStatTable(physicalStats, "physical")}
{renderStatTable(mentalStats, "mental")}
</div>
);
}
return (
<div className={styles.stats}>
{stats.map((stat) => {
const statKey: string = stat.statKey ? stat.statKey : "";
const modifier: number = stat.modifier ? stat.modifier : 0;
const score: number | null = stat.score;
return (
<div className={styles.stat} key={statKey}>
<h2 className={styles.statHeading}>{statKey}</h2>
<p className={styles.statScore}>{score}</p>
<p className={styles.statModifier}>
({FormatUtils.renderSignedNumber(modifier)})
</p>
</div>
);
})}
</div>
);
};
const renderActions = (): ReactNode => {
let groupActionNode: ReactNode;
const groupActionSnippet = CreatureUtils.getGroupActionSnippet(creature);
if (groupActionSnippet) {
groupActionNode = <HtmlContent html={groupActionSnippet} />;
}
let actionsNode: ReactNode;
const actionsDescription = CreatureUtils.getActionsDescription(creature);
if (actionsDescription) {
actionsNode = <HtmlContent html={actionsDescription} />;
}
const contentNode: ReactNode = (
<>
{groupActionNode}
{actionsNode}
</>
);
return renderDescription("Actions", contentNode);
};
const renderLegendaryActions = (): ReactNode => {
if (!CreatureUtils.canUseLegendaryActions(creature)) {
return null;
}
return renderDescription(
"Legendary Actions",
CreatureUtils.getLegendaryActionsDescription(creature)
);
};
const renderSpecialDescription = (): ReactNode => {
const groupInfo = CreatureUtils.getGroupInfo(creature);
let groupNode: ReactNode;
if (groupInfo?.specialQualityText) {
let groupTitle: ReactNode;
if (groupInfo.specialQualityTitle) {
groupTitle = (
<>
<strong>
<em>{groupInfo.specialQualityTitle}</em>
</strong>
.{" "}
</>
);
}
groupNode = (
<p>
{groupTitle}
{groupInfo.specialQualityText}
</p>
);
}
let monsterNode: ReactNode;
const monsterDescription =
CreatureUtils.getSpecialTraitsDescription(creature);
if (monsterDescription) {
monsterNode = <HtmlContent html={monsterDescription} />;
}
let contentNode: ReactNode = null;
if (monsterNode || groupNode) {
contentNode = (
<>
{monsterNode}
{groupNode}
</>
);
}
return renderDescription("Traits", contentNode);
};
const renderDescription = (
label: string | null,
description: ReactNode
): ReactNode => {
if (!description) {
return null;
}
let descriptionNode: ReactNode;
if (typeof description === "string") {
descriptionNode = (
<HtmlContent className={styles.description} html={description} />
);
} else {
descriptionNode = <div className={styles.description}>{description}</div>;
}
return (
<>
{label && <h2 className={styles.descriptionHeading}>{label}</h2>}
{descriptionNode}
</>
);
};
const renderAttribute = (label: string, content: string | null) => {
if (!content) {
return null;
}
return (
<div className={styles.attribute}>
<h2 className={styles.attributeLabel}>{label}</h2>
<p>{content}</p>
</div>
);
};
const renderImmunities = (): ReactNode => {
const damageImmunities = getDamageAdjustment("immunity");
const conditionImmunities = CreatureUtils.getConditionImmunities(creature)
.map((condition) => ConditionUtils.getName(condition))
.join(", ");
if (!isVersion2024) {
if (!damageImmunities && !conditionImmunities) {
return null;
}
const immunityChunks: Array<string> = [];
if (damageImmunities) {
immunityChunks.push(damageImmunities);
}
if (conditionImmunities) {
immunityChunks.push(conditionImmunities);
}
return renderAttribute("Immunities", immunityChunks.join("; "));
}
return (
<>
{renderAttribute("Damage Immunities", damageImmunities)}
{renderAttribute("Condition Immunities", conditionImmunities)}
</>
);
};
return (
<section
className={clsx([
className,
styles.creatureBlock,
isVersion2024 && styles.v2024,
])}
>
<h1 className={styles.header}>{CreatureUtils.getName(creature)}</h1>
<p className={styles.meta}>
{CreatureUtils.renderMetaText(creature, ruleData, variant === "basic")}
</p>
{renderSeparator()}
{renderAttribute(
isVersion2024 ? "AC" : "Armor Class",
CreatureUtils.renderArmorClass(creature)
)}
{renderAttribute(
"Initiative",
CreatureUtils.renderInitiativeInfo(creature)
)}
{renderAttribute(
isVersion2024 ? "HP" : "Hit Points",
CreatureUtils.renderHitPointInfo(creature)
)}
{renderAttribute(
"Speed",
CreatureUtils.renderSpeedInfo(creature, ruleData)
)}
{variant === "default" && (
<>
{renderSeparator()}
{renderStats()}
{renderSeparator()}
</>
)}
{variant === "default" && (
<>
{!isVersion2024 &&
renderAttribute(
"Saving Throws",
CreatureUtils.renderSavingThrows(creature)
)}
{renderAttribute(
"Skills",
CreatureUtils.renderSkills(creature, ruleData)
)}
{renderAttribute(
isVersion2024 ? "Vulnerabilities" : "Damage Vulnerabilities",
getDamageAdjustment("vulnerability")
)}
{renderAttribute(
isVersion2024 ? "Resistances" : "Damage Resistances",
getDamageAdjustment("resistance")
)}
{renderImmunities()}
{renderAttribute("Gear", CreatureUtils.getGear(creature))}
{renderAttribute(
"Senses",
CreatureUtils.renderSensesInfo(creature, ruleData)
)}
{renderAttribute(
"Languages",
CreatureUtils.renderLanguages(creature, ruleData)
)}
</>
)}
{renderAttribute(
isVersion2024 ? "CR" : "Challenge",
CreatureUtils.renderChallengeRatingInfo(creature)
)}
{variant === "default" && (
<>
{!isVersion2024 &&
renderAttribute(
"Proficiency Bonus",
CreatureUtils.renderProficiencyBonus(creature)
)}
{renderSeparator()}
<div className={styles.descriptions}>
{renderSpecialDescription()}
{renderActions()}
{renderDescription(
"Bonus Actions",
CreatureUtils.getBonusActionsDescription(creature)
)}
{renderDescription(
"Reactions",
CreatureUtils.getReactionsDescription(creature)
)}
{renderLegendaryActions()}
</div>
</>
)}
</section>
);
};
@@ -0,0 +1,285 @@
import { keyBy } from "lodash";
import React from "react";
import { Snippet } from "@dndbeyond/character-components/es";
import {
AbilityLookup,
BaseSpell,
BaseFeat,
Choice,
ChoiceUtils,
ClassDefinitionContract,
Constants,
DataOriginBaseAction,
DataOriginRefData,
InfusionChoice,
LevelScaleContract,
Option,
OptionUtils,
RuleData,
RuleDataUtils,
SnippetData,
CharacterTheme,
} from "@dndbeyond/character-rules-engine/es";
import { Reference } from "~/components/Reference";
import FeatureSnippetActions from "~/tools/js/CharacterSheet/components/FeatureSnippet/FeatureSnippetActions";
import FeatureSnippetChoices from "~/tools/js/CharacterSheet/components/FeatureSnippet/FeatureSnippetChoices";
import FeatureSnippetInfusionChoices from "~/tools/js/CharacterSheet/components/FeatureSnippet/FeatureSnippetInfusionChoices";
import FeatureSnippetOption from "~/tools/js/CharacterSheet/components/FeatureSnippet/FeatureSnippetOption";
import FeatureSnippetSpells from "~/tools/js/CharacterSheet/components/FeatureSnippet/FeatureSnippetSpells";
import styles from "./styles.module.css";
//TODO: tools/js/CharacterSheet/components/FeatureSnipper components still need to be gradually migrated to FC
interface Props {
heading: React.ReactNode;
dataOriginExtra?: string;
extraMeta: Array<string>;
className: string;
snippetData: SnippetData;
ruleData: RuleData;
abilityLookup: AbilityLookup;
sourceId: number | null;
sourcePage: number | null;
spells: Array<BaseSpell>;
feats: Array<BaseFeat>;
actions: Array<DataOriginBaseAction>;
options: Array<Option>;
choices: Array<Choice>;
infusionChoices?: Array<InfusionChoice>;
dataOriginRefData: DataOriginRefData;
levelScale?: LevelScaleContract | null;
classLevel?: number;
subclass?: ClassDefinitionContract | null;
onActionClick?: (action: DataOriginBaseAction) => void;
onActionUseSet?: (action: DataOriginBaseAction, uses: number) => void;
onSpellClick?: (spell: BaseSpell) => void;
onSpellUseSet?: (spell: BaseSpell, uses: number) => void;
onFeatureClick: () => void;
onInfusionChoiceClick?: (infusionChoice: InfusionChoice) => void;
showHeader?: boolean;
showDescription?: boolean;
isReadonly: boolean;
proficiencyBonus: number;
theme: CharacterTheme;
}
export const FeatureSnippet: React.FC<Props> = ({
heading,
dataOriginExtra,
extraMeta = [],
choices,
actions,
spells,
infusionChoices = [],
className,
snippetData,
sourceId,
sourcePage,
classLevel,
levelScale,
children,
abilityLookup,
ruleData,
subclass,
options,
feats,
dataOriginRefData,
onActionUseSet,
onActionClick,
onSpellClick,
onSpellUseSet,
onInfusionChoiceClick,
onFeatureClick,
showHeader = true,
showDescription = false,
isReadonly,
proficiencyBonus,
theme,
}) => {
const handleFeatureClick = (evt: React.MouseEvent): void => {
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
if (onFeatureClick) {
onFeatureClick();
}
};
const getNonChoiceOptions = (): Array<Option> => {
let optionLookup = keyBy(options, (option) => OptionUtils.getId(option));
let usedOptionIds: Array<number> = [];
choices.forEach((choice) => {
const type = ChoiceUtils.getType(choice);
const optionValue = ChoiceUtils.getOptionValue(choice);
switch (type) {
case Constants.BuilderChoiceTypeEnum.RACIAL_TRAIT_OPTION:
case Constants.BuilderChoiceTypeEnum.FEAT_OPTION:
case Constants.BuilderChoiceTypeEnum.FEATURE_OPTION:
if (optionValue !== null && optionLookup[optionValue]) {
usedOptionIds.push(optionValue);
}
break;
}
});
return options.filter(
(option) => !usedOptionIds.includes(OptionUtils.getId(option))
);
};
const nonChoiceOptions = getNonChoiceOptions();
const classNames = [styles.snippet, className];
const metaItems: Array<React.ReactNode> = [];
if (levelScale) {
metaItems.push(
<span className={styles.levelScale}>{levelScale.description}</span>
);
}
metaItems.push(...extraMeta);
const sourceDataLookup = RuleDataUtils.getSourceDataLookup(ruleData);
if (sourceId) {
metaItems.push(
<Reference name={sourceDataLookup[sourceId]?.name} page={sourcePage} />
);
}
return (
<div className={classNames.join(" ")} onClick={handleFeatureClick}>
{showHeader && (
<div
className={`${styles.heading} ${
theme?.isDarkMode ? styles.headingDarkMode : ""
}`}
>
{heading}
<span className={styles.meta}>
{metaItems.map((metaItem, idx) => (
<span
className={`${styles.metaItem} ${
theme?.isDarkMode ? styles.metaItemDarkMode : ""
}`}
key={idx}
>
{metaItem}
</span>
))}
</span>
{dataOriginExtra && dataOriginExtra !== "Unknown" && (
<div>
<span className={styles.origin}>
<span className={styles.originLabel}>From</span>
<span className={styles.originName}>{dataOriginExtra}</span>
</span>
</div>
)}
</div>
)}
<div className={styles.content}>
<Snippet
snippetData={snippetData}
levelScale={levelScale}
classLevel={classLevel}
parseSnippet={!showDescription}
proficiencyBonus={proficiencyBonus}
theme={theme}
>
{children}
</Snippet>
</div>
{(choices.length > 0 ||
actions.length > 0 ||
spells.length > 0 ||
nonChoiceOptions.length > 0) && (
<div
className={styles.extra}
style={
theme?.isDarkMode ? { borderColor: theme.themeColor } : undefined
}
>
<FeatureSnippetChoices
choices={choices}
options={options}
onSpellClick={onSpellClick}
onSpellUseSet={onSpellUseSet}
onActionUseSet={onActionUseSet}
onActionClick={onActionClick}
ruleData={ruleData}
abilityLookup={abilityLookup}
subclass={subclass}
feats={feats}
sourceDataLookup={sourceDataLookup}
classLevel={classLevel}
snippetData={snippetData}
dataOriginRefData={dataOriginRefData}
showDescription={showDescription}
isInteractive={!isReadonly}
proficiencyBonus={proficiencyBonus}
theme={theme}
/>
<FeatureSnippetActions
actions={actions}
abilityLookup={abilityLookup}
ruleData={ruleData}
onActionClick={onActionClick}
onActionUseSet={onActionUseSet}
isInteractive={!isReadonly}
proficiencyBonus={proficiencyBonus}
theme={theme}
/>
<FeatureSnippetSpells
spells={spells}
abilityLookup={abilityLookup}
ruleData={ruleData}
onSpellClick={onSpellClick}
onSpellUseSet={onSpellUseSet}
isInteractive={!isReadonly}
dataOriginRefData={dataOriginRefData}
proficiencyBonus={proficiencyBonus}
theme={theme}
/>
{nonChoiceOptions.length > 0 && (
<div className={styles.options}>
{nonChoiceOptions.map((option) => (
<FeatureSnippetOption
key={OptionUtils.getId(option)}
option={option}
onSpellClick={onSpellClick}
onSpellUseSet={onSpellUseSet}
onActionUseSet={onActionUseSet}
onActionClick={onActionClick}
abilityLookup={abilityLookup}
ruleData={ruleData}
sourceDataLookup={sourceDataLookup}
classLevel={classLevel}
snippetData={snippetData}
showDescription={showDescription}
isInteractive={!isReadonly}
dataOriginRefData={dataOriginRefData}
proficiencyBonus={proficiencyBonus}
theme={theme}
/>
))}
</div>
)}
<FeatureSnippetInfusionChoices
infusionChoices={infusionChoices}
onInfusionChoiceClick={onInfusionChoiceClick}
/>
</div>
)}
</div>
);
};
@@ -0,0 +1,104 @@
import clsx from "clsx";
import { FC, HTMLAttributes } from "react";
import { Constants } from "@dndbeyond/character-rules-engine";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import styles from "./styles.module.css";
interface Props extends HTMLAttributes<HTMLDivElement> {}
export const DeathSummary: FC<Props> = ({ className, onClick, ...props }) => {
const { deathSaveInfo, ruleData, ruleDataUtils, deathCause, characterTheme } =
useCharacterEngine();
const renderDeathSavesSummaryMarkGroup = (
key: string,
label: string,
activeCount: number,
totalCount: number,
isDarkMode: boolean
): React.ReactNode => {
let marks: Array<React.ReactNode> = [];
for (let i = 0; i < totalCount; i++) {
marks.push(
<span
key={`${key}-${i}`}
className={clsx([
styles.mark,
i < activeCount ? styles[`activeMark-${key}`] : styles.inactiveMark,
isDarkMode &&
i < activeCount &&
styles[`activeMarkDarkMode-${key}`],
])}
/>
);
}
return (
<div className={styles.group}>
<h2 className={styles.label}>{label}</h2>
<span className={styles.marks}>{marks}</span>
</div>
);
};
return (
<>
{/* If the death cause is exhaustion, render the exhaustion summary. Otherwise, render the death saves summary. */}
{deathCause === Constants.DeathCauseEnum.CONDITION ? (
<div
className={clsx([styles.container, className])}
onClick={onClick}
{...props}
>
<div className={styles.exhaustion}>
<div
className={clsx([
styles.icon,
characterTheme.isDarkMode && styles.iconDarkMode,
])}
/>
<h2 className={clsx([styles.label, styles.exhaustionLabel])}>
Exhaustion Level 6
</h2>
</div>
<h1 className={styles.deathLabel}>Death</h1>
</div>
) : (
<div
className={clsx([styles.container, className])}
onClick={onClick}
{...props}
>
<div className={styles.deathSaves}>
<div
className={clsx([
styles.icon,
characterTheme.isDarkMode && styles.iconDarkMode,
])}
/>
<div className={styles.markGroupsContainer}>
{renderDeathSavesSummaryMarkGroup(
"fail",
"Failure",
deathSaveInfo.failCount,
ruleDataUtils.getMaxDeathsavesFail(ruleData),
characterTheme.isDarkMode
)}
{renderDeathSavesSummaryMarkGroup(
"success",
"Success",
deathSaveInfo.successCount,
ruleDataUtils.getMaxDeathsavesSuccess(ruleData),
characterTheme.isDarkMode
)}
</div>
</div>
<h1 className={styles.deathLabel}>Death Saves</h1>
</div>
)}
</>
);
};
@@ -0,0 +1,62 @@
import clsx from "clsx";
import { FC, HTMLAttributes } from "react";
import { useSelector } from "react-redux";
import {
BeveledBoxSvg317x89,
BoxBackground,
} from "@dndbeyond/character-components/es";
import { Constants } from "@dndbeyond/character-rules-engine";
import { useSidebar } from "~/contexts/Sidebar/Sidebar";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
import { PaneComponentEnum } from "../Sidebar/types";
import { DeathSummary } from "./DeathSummary";
import { HitPointsQuickAdjust } from "./HitPointsQuickAdjust";
import { HitPointsSummary } from "./HitPointsSummary";
import styles from "./styles.module.css";
interface Props extends HTMLAttributes<HTMLDivElement> {}
export const HitPointsBox: FC<Props> = ({ className, ...props }) => {
const {
pane: { paneHistoryStart },
} = useSidebar();
const isReadonly = useSelector(appEnvSelectors.getIsReadonly);
const { hpInfo, deathCause, characterTheme } = useCharacterEngine();
const onClick = (evt: React.MouseEvent): void => {
if (!isReadonly) {
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
paneHistoryStart(PaneComponentEnum.HEALTH_MANAGE);
}
};
return (
<div
className={clsx([styles.hitPointsBox, className])}
onClick={onClick}
{...props}
>
<BoxBackground
StyleComponent={BeveledBoxSvg317x89}
theme={characterTheme}
/>
{deathCause === Constants.DeathCauseEnum.CONDITION ||
hpInfo.remainingHp <= 0 ? (
<DeathSummary />
) : (
<div className={styles.content}>
<h1 className={styles.label}>Hit Points</h1>
<HitPointsQuickAdjust />
<HitPointsSummary hpInfo={hpInfo} />
</div>
)}
</div>
);
};
@@ -0,0 +1,136 @@
import clsx from "clsx";
import { FC, HTMLAttributes, useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { Button } from "~/components/Button";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { useUnpropagatedClick } from "~/hooks/useUnpropagatedClick";
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
import { HP_DAMAGE_TAKEN_VALUE } from "../../../constants";
import styles from "./styles.module.css";
interface Props extends HTMLAttributes<HTMLDivElement | HTMLButtonElement> {}
export const HitPointsQuickAdjust: FC<Props> = ({ className, ...props }) => {
const dispatch = useDispatch();
const { hpInfo, characterActions, characterUtils, helperUtils } =
useCharacterEngine();
const isReadonly = useSelector(appEnvSelectors.getIsReadonly);
const [adjustmentValue, setAdjustmentValue] = useState<number | null>(null);
const [isFocused, setIsFocused] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const adjustHitPoints = (adjustmentSign: number): void => {
const difference = adjustmentSign * (adjustmentValue ?? 0);
const { newTemp, newRemovedHp } = characterUtils.calculateHitPoints(
hpInfo,
difference
);
dispatch(characterActions.hitPointsSet(newRemovedHp, newTemp));
setAdjustmentValue(null);
setIsHovered(false);
};
const onHealClick = useUnpropagatedClick(() => {
adjustHitPoints(1);
});
const onDamageClick = useUnpropagatedClick(() => {
adjustHitPoints(-1);
});
const onFocus = (): void => {
setIsFocused(true);
};
const onMouseEnter = (): void => {
setIsHovered(true);
};
const onMouseLeave = (): void => {
setIsHovered(false);
};
const onBlur = (evt: React.FocusEvent<HTMLInputElement>): void => {
setAdjustmentValue(helperUtils.parseInputInt(evt.target.value));
setIsFocused(false);
};
const onChange = (evt: React.ChangeEvent<HTMLInputElement>): void => {
setAdjustmentValue(helperUtils.parseInputInt(evt.target.value));
};
const onWheel = (evt: WheelEvent): void => {
if (isHovered || isFocused) {
evt.preventDefault();
let direction = evt.deltaY < 0 ? 1 : -1;
if (!isReadonly) {
let newValue = (adjustmentValue ?? 0) + direction;
newValue = helperUtils.clampInt(newValue ?? 0, 0, hpInfo.totalHp * 2);
setAdjustmentValue(newValue);
}
}
};
const onScroll = (evt: Event): void => {
if (isHovered || isFocused) {
evt.preventDefault();
}
};
useEffect(() => {
window.addEventListener("wheel", onWheel, { passive: false });
window.addEventListener("scroll", onScroll);
return () => {
window.removeEventListener("wheel", onWheel);
window.removeEventListener("scroll", onScroll);
};
});
return (
<div className={clsx([styles.container, className])} {...props}>
<Button
size="xx-small"
variant="outline"
themed
disabled={isReadonly}
onClick={onHealClick}
className={clsx([styles.button, styles.heal])}
>
Heal
</Button>
<input
className={styles.input}
data-testid="hp-adjust-input"
type="number"
aria-label="Hit Points Adjustment"
value={adjustmentValue ?? ""}
min={HP_DAMAGE_TAKEN_VALUE.MIN}
max={HP_DAMAGE_TAKEN_VALUE.MAX}
onClick={useUnpropagatedClick()}
onFocus={onFocus}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onChange={onChange}
onBlur={onBlur}
readOnly={isReadonly}
/>
<Button
size="xx-small"
variant="outline"
themed
disabled={isReadonly}
onClick={onDamageClick}
className={clsx([styles.button, styles.damage])}
>
Damage
</Button>
</div>
);
};
@@ -0,0 +1,353 @@
import clsx from "clsx";
import { FC, HTMLAttributes, useEffect, useState, useRef } from "react";
import { useDispatch, useSelector } from "react-redux";
import { v4 as uuidv4 } from "uuid";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import accessibility from "~/styles/accessibility.module.css";
import { HP_TEMP_VALUE } from "~/subApps/sheet/constants";
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
import { CharacterHitPointInfo, Creature, HitPointInfo } from "~/types";
import styles from "./styles.module.css";
interface Props extends HTMLAttributes<HTMLDivElement> {
hpInfo: CharacterHitPointInfo | HitPointInfo;
creature?: Creature;
showOriginalMax?: boolean;
showPermanentInputs?: boolean;
}
export const HitPointsSummary: FC<Props> = ({
hpInfo,
creature,
showOriginalMax = false,
showPermanentInputs = false,
className,
...props
}) => {
// TODO This hitpoints summary doesn't work as a generic component for vehicles yet. Works for creatures and characters.
const { characterActions, creatureUtils, helperUtils } = useCharacterEngine();
const isReadonly = useSelector(appEnvSelectors.getIsReadonly);
const [isCurrentEditorVisible, setIsCurrentEditorVisible] = useState(false);
const [isTempEditorVisible, setIsTempEditorVisible] = useState(false);
const [currentValue, setCurrentValue] = useState<number | null>(
hpInfo.remainingHp
);
const [tempValue, setTempValue] = useState<number | null>(hpInfo.tempHp);
const [isMaxDifferent, setIsMaxDifferent] = useState<boolean>(false);
const currentEditorRef = useRef<HTMLInputElement | null>(null);
const tempEditorRef = useRef<HTMLInputElement | null>(null);
const currentId = uuidv4();
const tempId = uuidv4();
const dispatch = useDispatch();
/* --- Current hit point functions --- */
const onCurrentInputChange = (
evt: React.ChangeEvent<HTMLInputElement>
): void => {
setCurrentValue(helperUtils.parseInputInt(evt.target.value));
};
const onCurrentInputClick = (
evt: React.MouseEvent<HTMLInputElement>
): void => {
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
};
const onCurrentClick = (evt: React.MouseEvent<HTMLButtonElement>): void => {
if (!isReadonly) {
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
setIsCurrentEditorVisible(true);
}
};
const dispatchCurrentHp = (value: number | null) => {
if (value === null) {
value = hpInfo.remainingHp;
} else {
let newRemovedAmount = hpInfo.totalHp - value;
newRemovedAmount = helperUtils.clampInt(
newRemovedAmount,
0,
hpInfo.totalHp
);
value = hpInfo.totalHp - newRemovedAmount;
if (newRemovedAmount !== hpInfo.removedHp) {
creature
? dispatch(
characterActions.creatureHitPointsSet(
creatureUtils.getMappingId(creature),
newRemovedAmount,
hpInfo.tempHp ?? HP_TEMP_VALUE.MIN
)
)
: dispatch(
characterActions.hitPointsSet(
newRemovedAmount,
hpInfo.tempHp ?? HP_TEMP_VALUE.MIN
)
);
}
}
setCurrentValue(value);
setIsCurrentEditorVisible(false);
};
const onCurrentInputBlur = (
evt: React.FocusEvent<HTMLInputElement>
): void => {
let current: number | null = helperUtils.parseInputInt(evt.target.value);
dispatchCurrentHp(current);
};
const onCurrentInputEnter = (
evt: React.KeyboardEvent<HTMLInputElement>
): void => {
if (evt.key === "Enter") {
let current: number | null = helperUtils.parseInputInt(
evt.currentTarget.value
);
dispatchCurrentHp(current);
}
};
/* --- Temp hit point functions --- */
const onTempInputClick = (evt: React.MouseEvent<HTMLInputElement>): void => {
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
};
const onTempClick = (evt: React.MouseEvent<HTMLButtonElement>): void => {
if (!isReadonly) {
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
setIsTempEditorVisible(true);
}
};
const onTempInputChange = (
evt: React.ChangeEvent<HTMLInputElement>
): void => {
setTempValue(helperUtils.parseInputInt(evt.target.value));
};
const dispatchTempHp = (value: number | null) => {
if (value === null) {
value = hpInfo.tempHp;
} else {
value = helperUtils.clampInt(value, HP_TEMP_VALUE.MIN, HP_TEMP_VALUE.MAX);
if (value !== hpInfo.tempHp) {
creature
? dispatch(
characterActions.creatureHitPointsSet(
creatureUtils.getMappingId(creature),
hpInfo.removedHp,
value
)
)
: dispatch(characterActions.hitPointsSet(hpInfo.removedHp, value));
}
}
setTempValue(value);
setIsTempEditorVisible(false);
};
const onTempInputBlur = (evt: React.FocusEvent<HTMLInputElement>): void => {
let temp: number | null = helperUtils.parseInputInt(evt.target.value);
dispatchTempHp(temp);
};
const onTempInputEnter = (
evt: React.KeyboardEvent<HTMLInputElement>
): void => {
if (evt.key === "Enter") {
let temp: number | null = helperUtils.parseInputInt(
evt.currentTarget.value
);
dispatchTempHp(temp);
}
};
/* --- useEffects --- */
useEffect(() => {
if (isTempEditorVisible) {
tempEditorRef.current?.focus();
}
}, [isTempEditorVisible]);
useEffect(() => {
if (isCurrentEditorVisible) {
currentEditorRef.current?.focus();
}
}, [isCurrentEditorVisible]);
useEffect(() => {
setCurrentValue(hpInfo.remainingHp);
}, [hpInfo.remainingHp]);
useEffect(() => {
setTempValue(hpInfo.tempHp);
}, [hpInfo.tempHp]);
useEffect(() => {
if (showOriginalMax && (hpInfo.overrideHp || hpInfo.bonusHp)) {
setIsMaxDifferent(true);
} else {
setIsMaxDifferent(false);
}
}, [hpInfo.overrideHp, hpInfo.bonusHp, showOriginalMax]);
/* --- Render Functions --- */
const renderTempHitPoints = (): React.ReactNode => {
if (isTempEditorVisible || showPermanentInputs) {
return (
<div>
<input
data-testid="temp-hp-input"
id={tempId}
ref={tempEditorRef}
className={styles.input}
type="number"
value={tempValue ?? ""}
min={HP_TEMP_VALUE.MIN}
max={HP_TEMP_VALUE.MAX}
onChange={onTempInputChange}
onBlur={onTempInputBlur}
onClick={onTempInputClick}
onKeyDown={onTempInputEnter}
/>
</div>
);
}
if (!tempValue) {
return (
<button
id={tempId}
className={clsx([
styles.valueButton,
styles.tempEmpty,
styles.number,
])}
onClick={onTempClick}
>
--
</button>
);
}
return (
<button
id={tempId}
className={clsx([styles.valueButton, styles.number])}
onClick={onTempClick}
>
{tempValue}
</button>
);
};
return (
<div className={clsx([styles.container, className])} {...props}>
<div className={styles.innerContainer}>
<div className={styles.item}>
<label
htmlFor={currentId}
className={styles.label}
aria-label={`Current hit points ${currentValue}, change current hit points`}
>
Current
</label>
{isCurrentEditorVisible || showPermanentInputs ? (
<div>
<input
data-testid="current-hp-input"
id={currentId}
ref={currentEditorRef}
className={styles.input}
type="number"
value={currentValue ?? ""}
min={0}
max={hpInfo.totalHp}
onChange={onCurrentInputChange}
onBlur={onCurrentInputBlur}
onClick={onCurrentInputClick}
onKeyDown={onCurrentInputEnter}
/>
</div>
) : (
<button
id={currentId}
className={clsx([styles.valueButton, styles.number])}
onClick={onCurrentClick}
>
{currentValue}
</button>
)}
</div>
<div className={styles.item}>
<div className={styles.spacer} />
<span className={clsx([styles.number, styles.slash])}>/</span>
</div>
<div className={styles.item}>
<span className={styles.label} aria-hidden="true">
Max
</span>
<span
className={accessibility.screenreaderOnly}
>{`Max hit points`}</span>
<div className={clsx([styles.number, styles.maxContainer])}>
<span
data-testid="max-hp"
className={clsx([
styles.max,
isMaxDifferent &&
"baseTotalHp" in hpInfo &&
(hpInfo.totalHp - hpInfo.baseTotalHp > 0
? styles.positive
: styles.negative),
])}
>
{hpInfo.totalHp}
</span>
{isMaxDifferent && "baseTotalHp" in hpInfo && (
<span
data-testid="origin-max-hp"
className={clsx([styles.originalMax])}
>
({hpInfo.baseTotalHp})
</span>
)}
</div>
</div>
</div>
<div className={clsx([styles.item, styles.temp])}>
<label
htmlFor={tempId}
aria-label={
!tempValue
? "Temporary hit points 0, set temporary hit points"
: `Temporary hit points ${tempValue}, change temporary hit points`
}
className={styles.label}
>
Temp
</label>
{renderTempHitPoints()}
</div>
</div>
);
};
@@ -0,0 +1,121 @@
import { visuallyHidden } from "@mui/utils";
import clsx from "clsx";
import { FC, HTMLAttributes, useContext } from "react";
import { useSelector } from "react-redux";
import {
AdvantageIcon,
BoxBackground,
DigitalDiceWrapper,
InitiativeBoxSvg,
} from "@dndbeyond/character-components/es";
import { DiceTools, RollKind, RollType } from "@dndbeyond/dice";
import { GameLogContext } from "@dndbeyond/game-log-components";
import { useCharacterTheme } from "~/contexts/CharacterTheme";
import { useSidebar } from "~/contexts/Sidebar";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import {
appEnvSelectors,
characterRollContextSelectors,
} from "~/tools/js/Shared/selectors";
import { isNotNullOrUndefined } from "~/tools/js/Shared/utils/TypeScript/utils";
import { PaneComponentEnum } from "../Sidebar/types";
import styles from "./styles.module.css";
import { NumberDisplay } from "~/components/NumberDisplay";
interface Props extends HTMLAttributes<HTMLDivElement> {
isMobile?: boolean;
isTablet?: boolean;
}
export const InitiativeBox: FC<Props> = ({ isMobile, isTablet, ...props }) => {
const [{ messageTargetOptions, defaultMessageTargetOption, userId }] =
useContext(GameLogContext);
const {
pane: { paneHistoryStart },
} = useSidebar();
const handleClick = (evt: React.MouseEvent): void => {
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
paneHistoryStart(PaneComponentEnum.INITIATIVE);
};
const {
hasInitiativeAdvantage,
processedInitiative: initiative,
characterTheme: theme,
} = useCharacterEngine();
const { isDarkMode } = useCharacterTheme();
const diceEnabled = useSelector(appEnvSelectors.getDiceEnabled);
const rollContext = useSelector(
characterRollContextSelectors.getCharacterRollContext
);
return (
<section
className={clsx([isMobile ? styles.boxMobile : styles.box])}
onClick={handleClick}
{...props}
>
{!isMobile && (
<div
className={clsx([
styles.label,
isDarkMode && !isTablet && styles.dark,
])}
data-testid="combat-initiative-label"
>
Initiative
</div>
)}
<div className={isMobile ? styles.valueMobile : styles.value}>
{!isMobile && (
<>
<BoxBackground StyleComponent={InitiativeBoxSvg} theme={theme} />
<h2 style={visuallyHidden}>Initiative</h2>
</>
)}
<DigitalDiceWrapper
diceNotation={DiceTools.CustomD20(initiative)}
rollType={RollType.Roll}
rollAction={"Initiative"}
rollKind={hasInitiativeAdvantage ? RollKind.Advantage : RollKind.None}
diceEnabled={diceEnabled}
themeColor={theme.themeColor}
rollContext={rollContext}
rollTargetOptions={
messageTargetOptions?.entities
? Object.values(messageTargetOptions?.entities).filter(
isNotNullOrUndefined
)
: undefined
}
rollTargetDefault={defaultMessageTargetOption}
userId={Number(userId)}
>
<NumberDisplay
number={initiative}
type="signed"
size="large"
className={clsx([
isMobile && !theme.isDarkMode && styles.numberColorOverride,
])}
/>
</DigitalDiceWrapper>
</div>
{hasInitiativeAdvantage && (
<div
className={clsx([styles.advantage, isMobile && styles.mobile])}
aria-label="Has advantage on initiative"
>
<AdvantageIcon
theme={theme}
className={styles.advantageIcon}
title={"Advantage on Initiative"}
/>
</div>
)}
{isMobile && <div className={styles.labelMobile}>Initiative</div>}
</section>
);
};
@@ -0,0 +1,92 @@
import clsx from "clsx";
import { FC, HTMLAttributes } from "react";
import {
BoxBackground,
InspirationBoxSvg,
InspirationTokenSvg,
} from "@dndbeyond/character-components/es";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import styles from "./styles.module.css";
interface Props extends HTMLAttributes<HTMLDivElement> {
inspiration: boolean;
onToggle?: () => void;
onClick?: () => void;
isInteractive: boolean;
isMobile?: boolean;
}
export const Inspiration: FC<Props> = ({
inspiration,
onClick,
onToggle,
isInteractive,
isMobile,
...props
}) => {
const { characterTheme: theme } = useCharacterEngine();
const inspirationText = "Heroic Inspiration";
const handleToggleClick = (evt: React.MouseEvent): void => {
if (onToggle && isInteractive) {
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
onToggle();
}
};
const handleClick = (evt: React.MouseEvent): void => {
if (onClick) {
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
onClick();
}
};
if (isMobile) {
return (
<div className={styles.mobileWrapper} {...props}>
<div
className={clsx([
styles.mobileButton,
inspiration && styles.active,
isInteractive && styles.interactive,
])}
onClick={handleToggleClick}
role="checkbox"
aria-checked={!!inspiration}
data-testid="inspiration"
>
{inspirationText}
</div>
</div>
);
}
return (
<div onClick={handleClick} {...props}>
<div
className={clsx([styles.box, isInteractive && styles.interactive])}
onClick={handleToggleClick}
role="checkbox"
aria-checked={!!inspiration}
data-testid="inspiration"
>
<BoxBackground StyleComponent={InspirationBoxSvg} theme={theme} />
<div className={styles.status}>
{inspiration && <InspirationTokenSvg theme={theme} />}
</div>
</div>
<div
className={clsx([styles.label, theme.isDarkMode && styles.dark])}
onClick={handleClick}
data-testid="inspiration-label"
>
{inspirationText}
</div>
</div>
);
};
@@ -0,0 +1,70 @@
import clsx from "clsx";
import { FC, HTMLAttributes, type MouseEvent } from "react";
import { createPortal } from "react-dom";
import GridIcon from "@dndbeyond/fontawesome-cache/svgs/solid/grid.svg";
import { useIsVisible } from "@dndbeyond/ttui/hooks/useIsVisible";
import { useSidebar } from "~/contexts/Sidebar";
import { useUnpropagatedClick } from "~/hooks/useUnpropagatedClick";
import { sidebarId } from "../../constants";
import { ArrowsLeftIcon } from "../Sidebar/svgs/ArrowsLeftIcon";
import { ArrowsRightIcon } from "../Sidebar/svgs/ArrowsRightIcon";
import { SectionMenu } from "./SectionMenu";
import styles from "./styles.module.css";
export interface MobileNavProps extends HTMLAttributes<HTMLDivElement> {}
export const MobileNav: FC<MobileNavProps> = ({ className, ...props }) => {
const {
ref,
isVisible: isMenuVisible,
setIsVisible: setIsMenuVisible,
} = useIsVisible(false);
const { sidebar } = useSidebar();
const handleToggleSidebar = useUnpropagatedClick(() => {
setIsMenuVisible(false);
sidebar.setIsVisible(!sidebar.isVisible);
});
const handleToggleMenu = () => {
setIsMenuVisible(!isMenuVisible);
};
const handleCloseMenu = (e?: MouseEvent) => {
e?.stopPropagation();
setIsMenuVisible(false);
};
return createPortal(
<div className={clsx([styles.mobileNav, className])} ref={ref} {...props}>
{!sidebar.isVisible && (
<div>
<button
className={styles.navToggle}
onClick={handleToggleMenu}
aria-label="Show navigation"
>
<GridIcon className={styles.icon} />
</button>
<SectionMenu open={isMenuVisible} onClose={handleCloseMenu} />
</div>
)}
<button
className={styles.sidebarToggle}
onClick={handleToggleSidebar}
aria-controls={sidebarId}
aria-label={`${sidebar.isVisible ? "Hide" : "Show"} sidebar`}
>
{sidebar.isVisible ? (
<ArrowsRightIcon className={styles.icon} />
) : (
<ArrowsLeftIcon className={styles.icon} />
)}
</button>
</div>,
document.body
);
};
@@ -0,0 +1,24 @@
import clsx from "clsx";
import { FC } from "react";
import { ButtonProps } from "@dndbeyond/ttui/components/Button";
import { Button } from "~/components/Button";
import styles from "./styles.module.css";
export interface SectionButtonProps extends ButtonProps {}
export const SectionButton: FC<SectionButtonProps> = ({
className,
onClick,
...props
}) => {
return (
<Button
className={clsx([styles.sectionButton, className])}
onClick={onClick}
{...props}
/>
);
};
@@ -0,0 +1,116 @@
import clsx from "clsx";
import { FC } from "react";
import { createPortal } from "react-dom";
import { useSelector } from "react-redux";
import { rulesEngineSelectors } from "@dndbeyond/character-rules-engine";
import { Dialog, DialogProps } from "@dndbeyond/ttui/components/Dialog";
import { useSheetContext } from "~/subApps/sheet/contexts/Sheet";
import {
ActionsIcon,
AttributesIcon,
DescriptionIcon,
EquipmentIcon,
ExtrasIcon,
FeatureTraitsIcon,
NotesIcon,
ProficienciesSvg,
SkillsIcon,
SpellsIcon,
} from "~/svgs";
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
import { SectionButton } from "../SectionButton";
import styles from "./styles.module.css";
export interface SectionMenuProps extends DialogProps {
open: boolean;
}
export const SectionMenu: FC<SectionMenuProps> = ({
className,
open,
...props
}) => {
const { setMobileActiveSectionId: setId } = useSheetContext();
const isReadonly = useSelector(appEnvSelectors.getIsReadonly);
const hasSpells = useSelector(rulesEngineSelectors.hasSpells);
return createPortal(
<>
<Dialog
className={clsx([styles.sectionNav, className])}
open={open}
modal
{...props}
>
<SectionButton
className={styles.fullWidth}
onClick={() => setId("main")}
>
<AttributesIcon />
<span>
Abilities, Saves, Senses
<span className={styles.mobile}>
, Proficiencies, Training, Skills
</span>
</span>
</SectionButton>
<SectionButton
className={styles.mobile}
onClick={() => setId("skills")}
>
<SkillsIcon />
Skills
</SectionButton>
<SectionButton onClick={() => setId("actions")}>
<ActionsIcon />
Actions
</SectionButton>
<SectionButton onClick={() => setId("equipment")}>
<EquipmentIcon />
Inventory
</SectionButton>
{hasSpells && (
<SectionButton onClick={() => setId("spells")}>
<SpellsIcon />
Spells
</SectionButton>
)}
<SectionButton onClick={() => setId("features_traits")}>
<FeatureTraitsIcon />
Features & Traits
</SectionButton>
<SectionButton
className={styles.mobile}
onClick={() => setId("proficiencies")}
>
<ProficienciesSvg />
<span>Proficiencies & Training</span>
</SectionButton>
{!isReadonly && (
<SectionButton onClick={() => setId("description")}>
<DescriptionIcon />
Background
</SectionButton>
)}
{!isReadonly && (
<SectionButton onClick={() => setId("notes")}>
<NotesIcon />
Notes
</SectionButton>
)}
<SectionButton
className={hasSpells ? styles.fullWidth : ""}
onClick={() => setId("extras")}
>
<ExtrasIcon />
Extras
</SectionButton>
</Dialog>
{open && <div className={styles.backdrop} />}
</>,
document.body
);
};
@@ -0,0 +1,40 @@
import clsx from "clsx";
import { FC, HTMLAttributes, ReactNode } from "react";
import { useUnpropagatedClick } from "~/hooks/useUnpropagatedClick";
import styles from "./styles.module.css";
interface HeaderProps extends HTMLAttributes<HTMLElement> {
callout?: ReactNode;
onClick?: () => void;
parent?: ReactNode;
preview?: ReactNode;
}
export const Header: FC<HeaderProps> = ({
callout,
children,
className,
onClick,
parent,
preview,
...props
}) => {
const handleClick = useUnpropagatedClick(onClick);
return (
<header className={clsx(["ct-sidebar__header", className])} {...props}>
<div onClick={handleClick} className={onClick && styles.interactive}>
{parent}
</div>
<div className="ct-sidebar__header-primary">
{preview}
<h1 className={clsx(["ct-sidebar__heading", styles.heading])}>
{children}
</h1>
{callout && <div className={styles.callout}>{callout}</div>}
</div>
</header>
);
};
@@ -0,0 +1,27 @@
import clsx from "clsx";
import { FC, HTMLAttributes } from "react";
import styles from "./styles.module.css";
/**
* This component is used to render a subheading in the sidebar.
*/
type HeadingProps = HTMLAttributes<HTMLHeadingElement>;
export const Heading: FC<HeadingProps> = ({
className,
children,
...props
}) => (
<h2
className={clsx([
"ct-sidebar__subheading",
props.onClick && styles.clickable,
className,
])}
{...props}
>
{children}
</h2>
);
@@ -0,0 +1,57 @@
import clsx from "clsx";
import { FC, HTMLAttributes } from "react";
import { useSidebar } from "~/contexts/Sidebar/Sidebar";
import { PaneBorder } from "~/svgs";
import { PaneContent } from "../PaneContent";
import { PaneControls } from "../PaneControls";
import styles from "./styles.module.css";
/**
* The Pane component serves as a way to render the active pane inside of the sidebar. It is a simple component that renders the children passed to it. It is used in the Sidebar component to render the active pane inside of the sidebar. The `isFullWidth` prop determines whether the pane should have padding on the left and right or not. Panes such as CharacterManagePane have items which should be full width. Most panes should have padding on the left and right.
**/
interface PaneProps extends HTMLAttributes<HTMLDivElement> {
handlePrevious: () => void;
handleNext: () => void;
isFullWidth?: boolean;
forceDarkMode?: boolean;
}
export const Pane: FC<PaneProps> = ({
isFullWidth,
forceDarkMode,
handlePrevious,
handleNext,
...props
}) => {
const {
pane: { activePane },
} = useSidebar();
return (
<div className={styles.pane} {...props}>
<PaneBorder
className={clsx([styles.border, forceDarkMode && styles.dark])}
/>
<div className={clsx([styles.gap, forceDarkMode && styles.dark])} />
<div
className={clsx([
styles.content,
forceDarkMode && styles.dark,
isFullWidth && styles.fullWidth,
])}
>
<PaneControls handlePrevious={handlePrevious} handleNext={handleNext} />
<PaneContent activePane={activePane} />
</div>
<PaneBorder
className={clsx([
styles.border,
styles.bottom,
forceDarkMode && styles.dark,
])}
/>
</div>
);
};
@@ -0,0 +1,38 @@
import { FC, HTMLAttributes } from "react";
import { useCharacterTheme } from "~/contexts/CharacterTheme";
import { getActiveEntryComponent } from "../../helpers/getActiveEntryComponent";
import { PaneComponentInfo } from "../../types";
import styles from "./styles.module.css";
interface PaneContentProps extends HTMLAttributes<HTMLElement> {
activePane: PaneComponentInfo | null;
}
export const PaneContent: FC<PaneContentProps> = ({ activePane, ...props }) => {
const { isDarkMode } = useCharacterTheme();
// Empty pane
if (!activePane)
return (
<div className={styles.default} {...props}>
Select elements on the character sheet to display more information about
</div>
);
const Component = getActiveEntryComponent(activePane.type);
// Component not found
if (!Component) return <div {...props}>Component was not found.</div>;
// Return correct component
return (
<div className={styles.maxHeight} {...props}>
<Component
theme={isDarkMode ? "DARK" : "LIGHT"}
identifiers={activePane.identifiers}
/>
</div>
);
};
@@ -0,0 +1,56 @@
import clsx from "clsx";
import { FC, HTMLAttributes } from "react";
import ChevronLeft from "@dndbeyond/fontawesome-cache/svgs/solid/chevron-left.svg";
import ChevronRight from "@dndbeyond/fontawesome-cache/svgs/solid/chevron-right.svg";
import { Button } from "~/components/Button";
import { useCharacterTheme } from "~/contexts/CharacterTheme";
import { useSidebar } from "~/contexts/Sidebar/Sidebar";
import styles from "./styles.module.css";
interface PaneControlsProps extends HTMLAttributes<HTMLElement> {
handlePrevious?: () => void;
handleNext?: () => void;
}
export const PaneControls: FC<PaneControlsProps> = ({
handlePrevious,
handleNext,
...props
}) => {
const { isDarkMode } = useCharacterTheme();
const {
pane: { activePane, showControls, isAtStart, isAtEnd },
} = useSidebar();
if (!activePane || !showControls) return null;
const forceDarkMode = !isDarkMode && activePane.type === "CHARACTER_MANAGE";
return (
<nav className={styles.controls} {...props}>
<Button
className={clsx([styles.button, forceDarkMode && styles.dark])}
size="xx-small"
variant="text"
disabled={isAtStart}
onClick={isAtStart ? undefined : handlePrevious}
>
<ChevronLeft />
<span className={styles.label}>Prev</span>
</Button>
<Button
className={clsx([styles.button, forceDarkMode && styles.dark])}
size="xx-small"
variant="text"
disabled={isAtEnd}
onClick={isAtEnd ? undefined : handleNext}
>
<span className={styles.label}>Next</span>
<ChevronRight />
</Button>
</nav>
);
};
@@ -0,0 +1,28 @@
import { FC, useEffect } from "react";
import { useDispatch } from "react-redux";
import { useSidebar } from "~/contexts/Sidebar";
import { toastMessageActions } from "../../../../../../tools/js/Shared/actions";
interface Props {
errorMessage?: string;
}
export const PaneInitFailureContent: FC<Props> = ({ errorMessage }) => {
const dispatch = useDispatch();
const {
sidebar: { setIsVisible },
} = useSidebar();
useEffect(() => {
setIsVisible(false);
dispatch(
toastMessageActions.toastError(
"Content Error",
errorMessage ?? "We couldn't find that content! Please try again."
)
);
}, [errorMessage, dispatch]);
return null;
};
@@ -0,0 +1,15 @@
import clsx from "clsx";
import { FC, HTMLAttributes } from "react";
import styles from "./styles.module.css";
interface Props extends HTMLAttributes<HTMLUListElement> {
className?: string;
}
export const PaneMenu: FC<Props> = ({ className, children, ...props }) => {
return (
<ul className={clsx([styles.menu, className])} {...props}>
{children}
</ul>
);
};
@@ -0,0 +1,25 @@
import clsx from "clsx";
import { FC, HTMLAttributes } from "react";
import { PaneMenuItem } from "../PaneMenuItem";
import styles from "./styles.module.css";
interface Props extends HTMLAttributes<HTMLDivElement> {
className?: string;
label: string;
}
export const PaneMenuGroup: FC<Props> = ({
label,
className = "",
children,
...props
}) => {
return (
<div className={clsx([styles.group, className])} {...props}>
<PaneMenuItem menukey={label} className={styles.label}>
{label}
</PaneMenuItem>
{children}
</div>
);
};
@@ -0,0 +1,42 @@
import clsx from "clsx";
import { FC, HTMLAttributes } from "react";
import styles from "./styles.module.css";
interface Props extends Omit<HTMLAttributes<HTMLDivElement>, "onClick"> {
onClick?: (menuKey: string) => void;
menukey: string;
className?: string;
prefixIcon?: React.ReactNode;
suffixIcon?: React.ReactNode;
}
export const PaneMenuItem: FC<Props> = ({
onClick,
menukey,
prefixIcon,
suffixIcon,
className,
children,
...props
}) => {
const handleClick = (evt: React.MouseEvent): void => {
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
if (onClick) {
onClick(menukey);
}
};
return (
<div
className={clsx([styles.item, className])}
onClick={handleClick}
{...props}
>
{prefixIcon && <div className={styles.prefixIcon}>{prefixIcon}</div>}
<div className={styles.label}>{children}</div>
<div className={styles.suffixIcon}>{suffixIcon}</div>
</div>
);
};
@@ -0,0 +1,25 @@
import clsx from "clsx";
import { FC, HTMLAttributes } from "react";
interface PreviewProps extends HTMLAttributes<HTMLDivElement> {
imageUrl?: string;
}
export const Preview: FC<PreviewProps> = ({
children,
className,
imageUrl,
...props
}) => (
<div className={clsx(["ct-sidebar__header-preview", className])} {...props}>
{imageUrl ? (
<div
role="presentation"
className="ct-sidebar__header-preview-image"
style={{ backgroundImage: `url(${imageUrl})` }}
/>
) : (
children
)}
</div>
);
@@ -0,0 +1,21 @@
import clsx from "clsx";
import { FC, HTMLAttributes } from "react";
import styles from "./styles.module.css";
export interface BarProps extends HTMLAttributes<HTMLButtonElement> {
variant?: "active" | "implied" | "inactive";
value?: string | number | null;
}
export const Bar: FC<BarProps> = ({
children,
className,
variant = "inactive",
value,
...props
}) => (
<button className={clsx([styles.bar, styles[variant], className])} {...props}>
{children}
</button>
);
@@ -0,0 +1,58 @@
import clsx from "clsx";
import { FC, HTMLAttributes } from "react";
import { Bar } from "./Bar";
import styles from "./styles.module.css";
export interface ProgressBarProps
extends Omit<HTMLAttributes<HTMLDivElement>, "onClick"> {
options: Array<number>;
value: number | null;
onClick: (val: string | number | null) => void;
isInteractive?: boolean;
}
export const ProgressBar: FC<ProgressBarProps> = ({
className,
options,
value,
onClick,
isInteractive,
...props
}) => {
const handleClick = (val: number | null) => {
const isAlreadyNull = value === null && val === null;
if (!isInteractive || isAlreadyNull) return;
onClick(val);
};
return (
<div className={clsx([styles.progressBar, className])} {...props}>
<Bar
value={null}
variant={value !== null ? "implied" : "inactive"}
onClick={() => handleClick(null)}
aria-disabled={!isInteractive}
>
--
</Bar>
{options.map((number) => (
<Bar
variant={
number === value
? "active"
: !!value && number < value
? "implied"
: "inactive"
}
value={number}
onClick={() => handleClick(number)}
aria-disabled={!isInteractive}
key={`progress-bar-${number}`}
>
{number}
</Bar>
))}
</div>
);
};
@@ -0,0 +1,31 @@
import { FC, HTMLAttributes } from "react";
import { ThemeButton } from "~/tools/js/Shared/components/common/Button";
import styles from "./styles.module.css";
interface Props extends HTMLAttributes<HTMLDivElement> {
actions: { label: string; onClick: () => void; disabled: boolean }[];
}
export const QuickActions: FC<Props> = ({ actions, ...props }) => {
return (
<div className={styles.container}>
<div className={styles.title}>Quick Actions:</div>
<div className={styles.buttons}>
{actions.map((item, idx) => {
return (
<ThemeButton
key={idx}
size="medium"
onClick={item.onClick}
isInteractive={!item.disabled}
>
{item.label}
</ThemeButton>
);
})}
</div>
</div>
);
};
@@ -0,0 +1,171 @@
import clsx from "clsx";
import { FC, HTMLAttributes, useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import { useSelector } from "react-redux";
import { DOWN, SwipeEventData, UP } from "react-swipeable";
import { syncTransactionSelectors } from "@dndbeyond/character-rules-engine";
import { Swipeable } from "~/components/Swipeable";
import { useCharacterTheme } from "~/contexts/CharacterTheme";
import { useSidebar } from "~/contexts/Sidebar";
import { getPaneProperties } from "../../helpers/paneUtils";
import { SidebarAlignmentEnum, SidebarPlacementEnum } from "../../types";
import { Pane } from "../Pane";
import { VisibilityControls } from "../VisiblilityControls";
import styles from "./styles.module.css";
export interface SidebarProps extends HTMLAttributes<HTMLDivElement> {
setSwipedAmount?: (amount: number) => void;
}
/**
* This component is responsible for rendering the sidebar and its contents. It uses volatile state to manage the visibility, placement, and alignment of the sidebar. It also uses the `Pane` component to render the active pane (the content of the sidebar) and the `VisibilityControls` component to render the controls for the sidebar. The sidebar is rendered as a portal to the body of the document.
*/
export const Sidebar: FC<SidebarProps> = ({
children,
className,
setSwipedAmount,
...props
}) => {
const sidebarRef = useRef<HTMLDivElement>(null);
const { isDarkMode } = useCharacterTheme();
const isSyncActive = useSelector(syncTransactionSelectors.getActive);
const {
sidebar: {
isLocked,
isVisible,
placement,
alignment,
setIsVisible,
setIsLocked,
setPlacement,
setAlignment,
},
pane: { activePane, paneHistoryNext, paneHistoryPrevious },
} = useSidebar();
const { isFullWidth, forceDarkMode } = getPaneProperties(activePane);
const handlePrevious = () => paneHistoryPrevious();
const handleNext = () => paneHistoryNext();
const handleTogglePlacement = (place: SidebarPlacementEnum) =>
setPlacement(place);
const handleToggleAlignment = (align: SidebarAlignmentEnum) =>
setAlignment(align);
const handleToggleLock = () => setIsLocked(!isLocked);
const handleToggleVisibility = () => {
if (isLocked) return;
if (isVisible && setSwipedAmount) setSwipedAmount(0);
setIsVisible(!isVisible);
};
const handleSwipe = ({ deltaX, dir }: SwipeEventData): void => {
const isVerticalChange = [UP, DOWN].includes(dir);
if (!isVerticalChange && setSwipedAmount) setSwipedAmount(deltaX);
};
const handleEscape = (e: KeyboardEvent) => {
if (
e.key === "Escape" &&
isVisible &&
!isLocked &&
placement !== SidebarPlacementEnum.FIXED
)
handleToggleVisibility();
};
const handleClickOutside = (e: MouseEvent) => {
const sidebar = sidebarRef.current;
const containsElement =
e.composedPath().indexOf(sidebar as EventTarget) !== -1;
const target = e.target as HTMLElement;
const isDiceControl = target?.classList?.contains(
"dice_notification_controls"
);
const isFixed = placement === SidebarPlacementEnum.FIXED;
// If element clicked is a dice control, don't close the sidebar
if (isDiceControl) return;
// If element clicked is not in the sidebar, and the sidebar is visible, not locked, and not fixed, close the sidebar
if (sidebar && !containsElement && isVisible && !isLocked && !isFixed)
handleToggleVisibility();
};
useEffect(() => {
document.body.addEventListener("keydown", handleEscape);
document.addEventListener("click", handleClickOutside);
return () => {
document.body.removeEventListener("keydown", handleEscape);
document.removeEventListener("click", handleClickOutside);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isVisible, isLocked, placement]);
return createPortal(
<div className="ct-sidebar__portal">
<div
className={clsx([
styles.mask,
isVisible ? styles.visible : styles.hidden,
isDarkMode && styles.dark,
])}
/>
<div>
<Swipeable
handlerFunctions={{
onSwipedRight: handleToggleVisibility,
onSwiping: handleSwipe,
}}
>
<div
role="dialog"
aria-label="Information Sidebar"
className={clsx([
"ct-sidebar",
styles.sidebar,
styles[alignment],
isVisible ? styles.visible : styles.hidden,
isFullWidth && "ct-sidebar--is-full-width",
isDarkMode && "ct-sidebar--is-dark-mode",
])}
ref={sidebarRef}
{...props}
>
<div className="ct-sidebar__inner">
<VisibilityControls
isVisible={isVisible}
isLocked={isLocked}
placement={placement}
alignment={alignment}
onToggleVisibility={handleToggleVisibility}
onToggleLock={handleToggleLock}
onToggleAlignment={handleToggleAlignment}
onTogglePlacement={handleTogglePlacement}
/>
{isVisible && (
<Pane
isFullWidth={isFullWidth}
forceDarkMode={forceDarkMode || isDarkMode}
handlePrevious={handlePrevious}
handleNext={handleNext}
/>
)}
</div>
{isSyncActive && <div className={styles.syncBlocker} />}
</div>
</Swipeable>
</div>
</div>,
document.body
);
};
@@ -0,0 +1,156 @@
import clsx from "clsx";
import { FC, HTMLAttributes } from "react";
import Lock from "@dndbeyond/fontawesome-cache/svgs/solid/lock.svg";
import Unlock from "@dndbeyond/fontawesome-cache/svgs/solid/unlock.svg";
import {
SidebarAlignmentEnum,
SidebarPlacementEnum,
} from "~/subApps/sheet/components/Sidebar/types";
import Tooltip from "~/tools/js/commonComponents/Tooltip";
import { AlignLeftIcon } from "../../svgs/AlignLeftIcon";
import { AlignRightIcon } from "../../svgs/AlignRightIcon";
import { ArrowsLeftIcon } from "../../svgs/ArrowsLeftIcon";
import { ArrowsRightIcon } from "../../svgs/ArrowsRightIcon";
import { FixedIcon } from "../../svgs/FixedIcon";
import { OverlayIcon } from "../../svgs/OverlayIcon";
import styles from "./styles.module.css";
interface VisibilityControlsProps extends HTMLAttributes<HTMLDivElement> {
isLocked: boolean;
isVisible: boolean;
placement: SidebarPlacementEnum;
alignment: SidebarAlignmentEnum;
onToggleLock: () => void;
onToggleVisibility: () => void;
onTogglePlacement: (val: "overlay" | "fixed") => void;
onToggleAlignment: (val: "left" | "right") => void;
}
export const VisibilityControls: FC<VisibilityControlsProps> = ({
className,
isLocked,
isVisible,
placement,
alignment,
onToggleLock,
onToggleVisibility,
onTogglePlacement,
onToggleAlignment,
...props
}) => {
const isActive = (value: "overlay" | "fixed" | "left" | "right") => {
if (placement === value || alignment === value) return styles.active;
return undefined;
};
const tippyOpts = {
dynamicTitle: true,
interactive: true,
};
return (
<div
className={clsx([
styles.controls,
alignment === "left" && styles.left,
className,
])}
{...props}
>
{isLocked ? (
<Tooltip title="Locked" isDarkMode tippyOpts={tippyOpts}>
<button
className={styles.locked}
onClick={onToggleLock}
aria-label="Locked"
>
<Lock />
</button>
</Tooltip>
) : (
<>
<div className={styles.visibility}>
<Tooltip
className={!isVisible ? styles.closed : ""}
title={isVisible ? "Hide Sidebar" : "Show Sidebar"}
isDarkMode
tippyOpts={{
...tippyOpts,
placement: "top-end",
}}
>
<button
className={styles.arrows}
onClick={onToggleVisibility}
aria-label={isVisible ? "Hide sidebar" : "Show sidebar"}
>
{isVisible ? <ArrowsRightIcon /> : <ArrowsLeftIcon />}
</button>
</Tooltip>
<Tooltip
title="Unlocked"
isDarkMode
tippyOpts={tippyOpts}
className={!isVisible ? styles.hidden : ""}
>
<button onClick={onToggleLock} aria-label="Unlocked">
<Unlock />
</button>
</Tooltip>
</div>
<div
className={clsx([styles.overlay, !isVisible ? styles.hidden : ""])}
>
<Tooltip title="Overlay" isDarkMode tippyOpts={tippyOpts}>
<button
className={isActive("overlay")}
onClick={() => onTogglePlacement("overlay")}
aria-label="Set to overlay position"
>
<OverlayIcon />
</button>
</Tooltip>
<Tooltip title="Fixed" isDarkMode tippyOpts={tippyOpts}>
<button
className={isActive("fixed")}
onClick={() => onTogglePlacement("fixed")}
aria-label="Set to fixed position"
>
<FixedIcon />
</button>
</Tooltip>
</div>
<div
className={clsx([
styles.placement,
alignment === "left" && styles.left,
!isVisible ? styles.hidden : "",
])}
>
<Tooltip title="Left" isDarkMode tippyOpts={tippyOpts}>
<button
className={isActive("left")}
onClick={() => onToggleAlignment("left")}
aria-label="Align Left"
>
<AlignLeftIcon />
</button>
</Tooltip>
<Tooltip title="Right" isDarkMode tippyOpts={tippyOpts}>
<button
className={isActive("right")}
onClick={() => onToggleAlignment("right")}
aria-label="Align Right"
>
<AlignRightIcon />
</button>
</Tooltip>
</div>
</>
)}
</div>
);
};
@@ -0,0 +1,244 @@
import { CreaturePane } from "~/subApps/sheet/components/Sidebar/panes/CreaturePane";
import { GameLogPane } from "~/subApps/sheet/components/Sidebar/panes/GameLogPane";
import BlessingPane from "~/tools/js/Shared/containers/panes/BlessingPane";
import CharacterSpellPane from "~/tools/js/Shared/containers/panes/CharacterSpellPane";
import ClassSpellPane from "~/tools/js/Shared/containers/panes/ClassSpellPane";
import ConditionManagePane from "~/tools/js/Shared/containers/panes/ConditionManagePane";
import ContainerPane from "~/tools/js/Shared/containers/panes/ContainerPane";
import CurrencyPane from "~/tools/js/Shared/containers/panes/CurrencyPane";
import CustomActionPane from "~/tools/js/Shared/containers/panes/CustomActionPane";
import CustomActionsPane from "~/tools/js/Shared/containers/panes/CustomActionsPane";
import CustomSkillPane from "~/tools/js/Shared/containers/panes/CustomSkillPane";
import DecoratePane from "~/tools/js/Shared/containers/panes/DecoratePane";
import DefenseManagePane from "~/tools/js/Shared/containers/panes/DefenseManagePane";
import DescriptionPane from "~/tools/js/Shared/containers/panes/DescriptionPane";
import EncumbrancePane from "~/tools/js/Shared/containers/panes/EncumbrancePane";
import EquipmentManagePane from "~/tools/js/Shared/containers/panes/EquipmentManagePane";
import { ExportPdfPane } from "~/tools/js/Shared/containers/panes/ExportPdfPane";
import ExtraManagePane from "~/tools/js/Shared/containers/panes/ExtraManagePane";
import InfusionChoicePane from "~/tools/js/Shared/containers/panes/InfusionChoicePane";
import ItemPane from "~/tools/js/Shared/containers/panes/ItemPane";
import LongRestPane from "~/tools/js/Shared/containers/panes/LongRestPane";
import NoteManagePane from "~/tools/js/Shared/containers/panes/NoteManagePane";
import { PreferencesHitPointConfirmPane } from "~/tools/js/Shared/containers/panes/PreferencesHitPointConfirmPane";
import PreferencesOptionalClassFeaturesConfirmPane from "~/tools/js/Shared/containers/panes/PreferencesOptionalClassFeaturesConfirmPane/PreferencesOptionalClassFeaturesConfirmPane";
import PreferencesOptionalOriginsConfirmPane from "~/tools/js/Shared/containers/panes/PreferencesOptionalOriginsConfirmPane/PreferencesOptionalOriginsConfirmPane";
import PreferencesPane from "~/tools/js/Shared/containers/panes/PreferencesPane";
import { PreferencesProgressionConfirmPane } from "~/tools/js/Shared/containers/panes/PreferencesProgressionConfirmPane";
import ProficienciesPane from "~/tools/js/Shared/containers/panes/ProficienciesPane";
import ProficiencyBonusPane from "~/tools/js/Shared/containers/panes/ProficiencyBonusPane";
import SavingThrowsPane from "~/tools/js/Shared/containers/panes/SavingThrowsPane";
import SenseManagePane from "~/tools/js/Shared/containers/panes/SenseManagePane";
import SettingsPane from "~/tools/js/Shared/containers/panes/SettingsPane";
import { ShareUrlPane } from "~/tools/js/Shared/containers/panes/ShareUrlPane";
import ShortRestPane from "~/tools/js/Shared/containers/panes/ShortRestPane";
import SkillPane from "~/tools/js/Shared/containers/panes/SkillPane";
import SkillsPane from "~/tools/js/Shared/containers/panes/SkillsPane";
import SpeciesTraitPane from "~/tools/js/Shared/containers/panes/SpeciesTraitPane";
import SpeedManagePane from "~/tools/js/Shared/containers/panes/SpeedManagePane";
import SpellManagePane from "~/tools/js/Shared/containers/panes/SpellManagePane";
import StartingEquipmentPane from "~/tools/js/Shared/containers/panes/StartingEquipmentPane";
import TraitPane from "~/tools/js/Shared/containers/panes/TraitPane";
import VehicleComponentPane from "~/tools/js/Shared/containers/panes/VehicleComponentPane";
import VehiclePane from "~/tools/js/Shared/containers/panes/VehiclePane";
import { AbilityPane } from "../panes/AbilityPane";
import { AbilitySavingThrowsPane } from "../panes/AbilitySavingThrowsPane";
import { ActionPane } from "../panes/ActionPane";
import { ArmorManagePane } from "../panes/ArmorManagePane";
import { BackgroundPane } from "../panes/BackgroundPane";
import { BasicActionPane } from "../panes/BasicActionPane";
import { CampaignPane } from "../panes/CampaignPane";
import { CharacterManagePane } from "../panes/CharacterManagePane";
import { ClassFeaturePane } from "../panes/ClassFeaturePane";
import { FeatPane } from "../panes/FeatPane";
import { FeatsManagePane } from "../panes/FeatsManagePane";
import { HitPointsManagePane } from "../panes/HitPointsManagePane";
import { InitiativePane } from "../panes/InitiativePane";
import { InspirationPane } from "../panes/InspirationPane";
import { XpPane } from "../panes/XpPane";
import { PaneComponentEnum, SidebarPaneComponent } from "../types";
// Refactor this to a hook that returns a component?
export const getActiveEntryComponent = (
componentType?: PaneComponentEnum
): SidebarPaneComponent<any> | null => {
if (!componentType) return null;
switch (componentType) {
case PaneComponentEnum.HEALTH_MANAGE:
return HitPointsManagePane;
case PaneComponentEnum.ITEM_DETAIL:
return ItemPane;
case PaneComponentEnum.CHARACTER_SPELL_DETAIL:
return CharacterSpellPane;
case PaneComponentEnum.CLASS_SPELL_DETAIL:
return ClassSpellPane;
case PaneComponentEnum.ABILITY:
return AbilityPane;
case PaneComponentEnum.SKILLS:
return SkillsPane;
case PaneComponentEnum.PROFICIENCIES:
return ProficienciesPane;
case PaneComponentEnum.XP:
return XpPane;
case PaneComponentEnum.SHORT_REST:
return ShortRestPane;
case PaneComponentEnum.LONG_REST:
return LongRestPane;
case PaneComponentEnum.SPELL_MANAGE:
return SpellManagePane;
case PaneComponentEnum.CONDITION_MANAGE:
return ConditionManagePane;
case PaneComponentEnum.SPEED_MANAGE:
return SpeedManagePane;
case PaneComponentEnum.SENSE_MANAGE:
return SenseManagePane;
case PaneComponentEnum.DEFENSE_MANAGE:
return DefenseManagePane;
case PaneComponentEnum.ARMOR_MANAGE:
return ArmorManagePane;
case PaneComponentEnum.FEATS_MANAGE:
return FeatsManagePane;
case PaneComponentEnum.NOTE_MANAGE:
return NoteManagePane;
case PaneComponentEnum.TRAIT:
return TraitPane;
case PaneComponentEnum.CAMPAIGN:
return CampaignPane;
case PaneComponentEnum.EQUIPMENT_MANAGE:
return EquipmentManagePane;
case PaneComponentEnum.CURRENCY:
return CurrencyPane;
case PaneComponentEnum.ENCUMBRANCE:
return EncumbrancePane;
case PaneComponentEnum.STARTING_EQUIPMENT:
return StartingEquipmentPane;
case PaneComponentEnum.CUSTOM_ACTIONS:
return CustomActionsPane;
case PaneComponentEnum.CUSTOM_ACTION:
return CustomActionPane;
case PaneComponentEnum.CLASS_FEATURE_DETAIL:
return ClassFeaturePane;
case PaneComponentEnum.FEAT_DETAIL:
return FeatPane;
case PaneComponentEnum.SPECIES_TRAIT_DETAIL:
return SpeciesTraitPane;
case PaneComponentEnum.BACKGROUND:
return BackgroundPane;
case PaneComponentEnum.DESCRIPTION:
return DescriptionPane;
case PaneComponentEnum.PROFICIENCY_BONUS:
return ProficiencyBonusPane;
case PaneComponentEnum.INSPIRATION:
return InspirationPane;
case PaneComponentEnum.INITIATIVE:
return InitiativePane;
case PaneComponentEnum.ACTION:
return ActionPane;
case PaneComponentEnum.PREFERENCES:
return PreferencesPane;
case PaneComponentEnum.SKILL:
return SkillPane;
case PaneComponentEnum.CUSTOM_SKILL:
return CustomSkillPane;
case PaneComponentEnum.SAVING_THROWS:
return SavingThrowsPane;
case PaneComponentEnum.ABILITY_SAVING_THROW:
return AbilitySavingThrowsPane;
case PaneComponentEnum.BASIC_ACTION:
return BasicActionPane;
case PaneComponentEnum.PREFERENCES_HIT_POINT_CONFIRM:
return PreferencesHitPointConfirmPane;
case PaneComponentEnum.PREFERENCES_OPTIONAL_CLASS_FEATURES_CONFIRM:
return PreferencesOptionalClassFeaturesConfirmPane;
case PaneComponentEnum.PREFERENCES_OPTIONAL_ORIGINS_CONFIRM:
return PreferencesOptionalOriginsConfirmPane;
case PaneComponentEnum.PREFERENCES_PROGRESSION_CONFIRM:
return PreferencesProgressionConfirmPane;
case PaneComponentEnum.SHARE_URL:
return ShareUrlPane;
case PaneComponentEnum.CREATURE:
return CreaturePane;
case PaneComponentEnum.VEHICLE:
return VehiclePane;
case PaneComponentEnum.VEHICLE_COMPONENT:
return VehicleComponentPane;
case PaneComponentEnum.EXTRA_MANAGE:
return ExtraManagePane;
case PaneComponentEnum.INFUSION_CHOICE:
return InfusionChoicePane;
case PaneComponentEnum.EXPORT_PDF:
return ExportPdfPane;
case PaneComponentEnum.CHARACTER_MANAGE:
return CharacterManagePane;
case PaneComponentEnum.GAME_LOG:
return GameLogPane;
case PaneComponentEnum.CONTAINER:
return ContainerPane;
case PaneComponentEnum.DECORATE:
return DecoratePane;
case PaneComponentEnum.SETTINGS:
return SettingsPane;
case PaneComponentEnum.BLESSING_DETAIL:
default:
return BlessingPane;
}
};
@@ -0,0 +1,203 @@
import {
BaseInventoryContract,
Hack__BaseCharClass,
FeatDetailsContract,
RacialTraitContract,
ClassFeatureUtils,
ClassUtils,
Constants,
DataOriginRef,
DataOriginRefData,
DataOriginUtils,
FeatUtils,
ItemUtils,
RacialTraitUtils,
DataOrigin,
Spell,
SpellUtils,
ClassFeatureContract,
} from "@dndbeyond/character-rules-engine/es";
import { PaneIdentifierUtils } from "~/tools/js/Shared/utils";
import {
PaneComponentEnum,
PaneComponentInfo,
PaneComponentProperties,
PaneIdentifiers,
} from "../types";
// List any full width panes
const fullWidthPanes = [PaneComponentEnum.CHARACTER_MANAGE];
// Check to see if pane is in the list
const isFullWidthPane = (activeEntry: PaneComponentInfo | null) =>
activeEntry?.type && fullWidthPanes.includes(activeEntry.type);
// List any forced dark mode panes
const darkModePanes = [PaneComponentEnum.CHARACTER_MANAGE];
// Check to see if pane is in the list
const forceDarkMode = (activeEntry: PaneComponentInfo | null) =>
activeEntry?.type && darkModePanes.includes(activeEntry.type);
// Return optional pane props
export const getPaneProperties = (
activeEntry: PaneComponentInfo | null
): PaneComponentProperties => {
const props: PaneComponentProperties = {
isFullWidth: isFullWidthPane(activeEntry) ?? false,
forceDarkMode: forceDarkMode(activeEntry) ?? false,
};
return { ...props };
};
export function getSpellComponentInfo(spell: Spell): PaneComponentInfo {
let dataOrigin = SpellUtils.getDataOrigin(spell);
let dataOriginType = SpellUtils.getDataOriginType(spell);
let type: PaneComponentEnum = PaneComponentEnum.CHARACTER_SPELL_DETAIL;
let identifiers: PaneIdentifiers | null = null;
const mappingId = SpellUtils.getMappingId(spell);
if (mappingId !== null) {
if (dataOriginType === Constants.DataOriginTypeEnum.CLASS) {
type = PaneComponentEnum.CLASS_SPELL_DETAIL;
identifiers = PaneIdentifierUtils.generateClassSpell(
ClassUtils.getMappingId(dataOrigin.primary as Hack__BaseCharClass),
mappingId
);
} else {
identifiers = PaneIdentifierUtils.generateCharacterSpell(mappingId);
}
}
return {
type,
identifiers,
};
}
export function getDataOriginComponentInfo(
dataOrigin: DataOrigin
): PaneComponentInfo {
let type: PaneComponentEnum = PaneComponentEnum.ERROR_404;
let identifiers: PaneIdentifiers | null = null;
switch (dataOrigin.type) {
case Constants.DataOriginTypeEnum.ITEM:
type = PaneComponentEnum.ITEM_DETAIL;
identifiers = PaneIdentifierUtils.generateItem(
ItemUtils.getMappingId(dataOrigin.primary as BaseInventoryContract)
);
break;
case Constants.DataOriginTypeEnum.CLASS_FEATURE:
type = PaneComponentEnum.CLASS_FEATURE_DETAIL;
identifiers = PaneIdentifierUtils.generateClassFeature(
ClassFeatureUtils.getId(dataOrigin.primary as ClassFeatureContract),
ClassUtils.getMappingId(dataOrigin.parent as Hack__BaseCharClass)
);
break;
case Constants.DataOriginTypeEnum.RACE:
type = PaneComponentEnum.SPECIES_TRAIT_DETAIL;
identifiers = PaneIdentifierUtils.generateRacialTrait(
RacialTraitUtils.getId(dataOrigin.primary as RacialTraitContract)
);
break;
case Constants.DataOriginTypeEnum.FEAT:
type = PaneComponentEnum.FEAT_DETAIL;
identifiers = PaneIdentifierUtils.generateFeat(
FeatUtils.getId(dataOrigin.primary as FeatDetailsContract)
);
break;
case Constants.DataOriginTypeEnum.BACKGROUND:
type = PaneComponentEnum.BACKGROUND;
break;
case Constants.DataOriginTypeEnum.FEAT_LIST:
if (dataOrigin.parentType === Constants.DataOriginTypeEnum.BACKGROUND) {
type = PaneComponentEnum.BACKGROUND;
}
break;
default:
// Not implemented
}
return {
type,
identifiers,
};
}
export function getDataOriginRefComponentInfo(
ref: DataOriginRef,
refData: DataOriginRefData
): PaneComponentInfo {
let type: PaneComponentEnum = PaneComponentEnum.ERROR_404;
let identifiers: PaneIdentifiers | null = null;
switch (ref.type) {
case Constants.DataOriginTypeEnum.ITEM: {
const primary = DataOriginUtils.getRefPrimary(ref, refData);
if (primary !== null) {
type = PaneComponentEnum.ITEM_DETAIL;
identifiers = PaneIdentifierUtils.generateItem(
ItemUtils.getMappingId(primary)
);
}
break;
}
case Constants.DataOriginTypeEnum.CLASS_FEATURE: {
const primary = DataOriginUtils.getRefPrimary(ref, refData);
const parent = DataOriginUtils.getRefParent(ref, refData);
if (primary !== null && parent !== null) {
type = PaneComponentEnum.CLASS_FEATURE_DETAIL;
identifiers = PaneIdentifierUtils.generateClassFeature(
ClassFeatureUtils.getId(primary),
ClassUtils.getMappingId(parent)
);
}
break;
}
case Constants.DataOriginTypeEnum.RACE: {
const primary = DataOriginUtils.getRefPrimary(ref, refData);
if (primary !== null) {
type = PaneComponentEnum.SPECIES_TRAIT_DETAIL;
identifiers = PaneIdentifierUtils.generateRacialTrait(
RacialTraitUtils.getId(primary)
);
}
break;
}
case Constants.DataOriginTypeEnum.FEAT: {
const primary = DataOriginUtils.getRefPrimary(ref, refData);
if (primary !== null) {
type = PaneComponentEnum.FEAT_DETAIL;
identifiers = PaneIdentifierUtils.generateFeat(
FeatUtils.getId(primary)
);
}
break;
}
case Constants.DataOriginTypeEnum.BACKGROUND:
type = PaneComponentEnum.BACKGROUND;
break;
default:
// Not implemented
}
return {
type,
identifiers,
};
}
@@ -0,0 +1,83 @@
import { FC, HTMLAttributes, ReactNode } from "react";
import { useSelector } from "react-redux";
import { AbilityScoreManager } from "~/components/AbilityScoreManager";
import { HtmlContent } from "~/components/HtmlContent";
import { NumberDisplay } from "~/components/NumberDisplay";
import { useCharacterTheme } from "~/contexts/CharacterTheme";
import { useAbilities } from "~/hooks/useAbilities";
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
import { PaneInitFailureContent } from "~/subApps/sheet/components/Sidebar/components/PaneInitFailureContent";
import { Preview } from "~/subApps/sheet/components/Sidebar/components/Preview";
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
import { AbilityIcon } from "~/tools/js/smartComponents/Icons";
import { PaneIdentifiersAbility } from "../../types";
import styles from "./styles.module.css";
/*
AbilityPane is the specific Sidebar for each Ability on a character
(Strength, Dexterity, Constitution, Wisdom, Intelligence, Charisma)
*/
interface Props extends HTMLAttributes<HTMLDivElement> {
identifiers: PaneIdentifiersAbility | null;
}
export const AbilityPane: FC<Props> = ({ identifiers, ...props }) => {
const isReadonly = useSelector(appEnvSelectors.getIsReadonly);
const { isDarkMode } = useCharacterTheme();
const abilities = useAbilities();
const ability =
(identifiers &&
abilities.find((a) => a.getId() === identifiers?.ability.getId())) ||
identifiers?.ability;
if (!ability) {
return <PaneInitFailureContent />;
}
const statId = ability.getId();
let sidebarPreviewNode: ReactNode = (
<Preview>
<AbilityIcon
className={styles.icon}
statId={statId}
themeMode={isDarkMode ? "light" : "dark"}
/>
</Preview>
);
const modifier = ability.getModifier();
const abilityCompendiumText = ability.getCompendiumText();
return (
<div key={statId} {...props}>
<Header preview={sidebarPreviewNode}>
{ability.getLabel()} {ability.getTotalScore()}
{modifier !== null && (
<span className={styles.modifier}>
(
<NumberDisplay
type="signed"
number={modifier}
className={styles.signedNumber}
/>
)
</span>
)}
</Header>
<AbilityScoreManager
ability={ability}
showHeader={false}
isReadonly={isReadonly}
/>
{abilityCompendiumText && (
<HtmlContent
className={styles.description}
html={abilityCompendiumText}
withoutTooltips
/>
)}
</div>
);
};
@@ -0,0 +1,214 @@
import { ComponentType, FC, HTMLAttributes, ReactNode } from "react";
import { useDispatch, useSelector } from "react-redux";
import {
AbilityIcon,
Collapsible,
DarkModeNegativeBonusNegativeSvg,
DarkModePositiveBonusPositiveSvg,
NegativeBonusNegativeSvg,
PositiveBonusPositiveSvg,
} from "@dndbeyond/character-components/es";
import {
AbilityUtils,
characterActions,
Constants,
FormatUtils,
ModifierUtils,
ValueUtils,
} from "@dndbeyond/character-rules-engine/es";
import { HtmlContent } from "~/components/HtmlContent";
import { NumberDisplay } from "~/components/NumberDisplay";
import { RuleKeyEnum } from "~/constants";
import { useCharacterTheme } from "~/contexts/CharacterTheme";
import { useSidebar } from "~/contexts/Sidebar";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { useRuleData } from "~/hooks/useRuleData";
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
import { Preview } from "~/subApps/sheet/components/Sidebar/components/Preview";
import { Ability, SituationalSavingThrowInfo, StatDataContract } from "~/types";
import EditorBox from "../../../../../../tools/js/Shared/components/EditorBox";
import ValueEditor from "../../../../../../tools/js/Shared/components/ValueEditor";
import { appEnvSelectors } from "../../../../../../tools/js/Shared/selectors";
import { PaneInitFailureContent } from "../../components/PaneInitFailureContent";
import {
PaneComponentEnum,
PaneIdentifiersAbilitySavingThrow,
} from "../../types";
import styles from "./styles.module.css";
/*
AbilitySavingThrowsPane is the specific Sidebar for each Ability Saving Throw on a character - (Strength, Dexterity, Constitution, Wisdom, Intelligence, Charisma) - it displays the details, modifiers, any customizations and overrides, as well as the situational bonuses.
*/
interface Props extends HTMLAttributes<HTMLDivElement> {
identifiers: PaneIdentifiersAbilitySavingThrow | null;
}
export const AbilitySavingThrowsPane: FC<Props> = ({
identifiers,
...props
}) => {
const dispatch = useDispatch();
const {
ruleData,
situationalBonusSavingThrowsLookup,
abilityLookup,
entityValueLookup,
} = useCharacterEngine();
const { ruleDataUtils } = useRuleData();
const { isDarkMode } = useCharacterTheme();
const isReadonly = useSelector(appEnvSelectors.getIsReadonly);
const {
pane: { paneHistoryPush },
} = useSidebar();
const abilityDataLookup = ruleDataUtils.getStatsLookup(ruleData);
const ability: Ability | null = identifiers && abilityLookup[identifiers.id];
const abilityData: StatDataContract | null =
identifiers && abilityDataLookup[identifiers.id];
const situationalBonusSavingThrowsInfo: SituationalSavingThrowInfo[] | null =
identifiers && situationalBonusSavingThrowsLookup[identifiers.id];
const rulesText = ruleDataUtils.getRule(RuleKeyEnum.SAVING_THROWS, ruleData);
const handleCustomDataUpdate = (
key: number,
value: any,
source: string
): void => {
if (!abilityData) return;
dispatch(
characterActions.valueSet(
key,
value,
source,
ValueUtils.hack__toString(abilityData.id),
ValueUtils.hack__toString(abilityData.entityTypeId)
)
);
};
const handleParentClick = (): void => {
paneHistoryPush(PaneComponentEnum.SAVING_THROWS);
};
if (!ability || !abilityData) {
return <PaneInitFailureContent />;
}
const statId = AbilityUtils.getId(ability);
let sidebarPreviewNode: ReactNode = (
<Preview>
<AbilityIcon
className={styles.icon}
statId={statId}
themeMode={isDarkMode ? "light" : "dark"}
/>
</Preview>
);
return (
<div key={statId} {...props}>
<Header
preview={sidebarPreviewNode}
parent="Saving Throws"
onClick={handleParentClick}
>
{abilityData.name} Saving Throw
<span className={styles.modifier}>
<NumberDisplay type="signed" number={AbilityUtils.getSave(ability)} />
</span>
</Header>
{!isReadonly && (
<Collapsible
layoutType={"minimal"}
header="Customize"
className={styles.customize}
>
<EditorBox className={styles.editorBox}>
<ValueEditor
dataLookup={ValueUtils.getEntityData(
entityValueLookup,
ValueUtils.hack__toString(abilityData.id),
ValueUtils.hack__toString(abilityData.entityTypeId)
)}
onDataUpdate={handleCustomDataUpdate}
valueEditors={[
Constants.AdjustmentTypeEnum.SAVING_THROW_OVERRIDE,
Constants.AdjustmentTypeEnum.SAVING_THROW_MAGIC_BONUS,
Constants.AdjustmentTypeEnum.SAVING_THROW_MISC_BONUS,
Constants.AdjustmentTypeEnum.SAVING_THROW_PROFICIENCY_LEVEL,
]}
ruleData={ruleData}
/>
</EditorBox>
</Collapsible>
)}
{situationalBonusSavingThrowsInfo && (
<div className={styles.situational}>
{situationalBonusSavingThrowsInfo.map((situationalBonus) => {
let abilityNode: ReactNode;
if (
situationalBonus.type ===
Constants.SituationalBonusSavingThrowTypeEnum.MODIFIER
) {
let restriction = ModifierUtils.getRestriction(
situationalBonus.extra
);
if (restriction) {
restriction = restriction.trim();
restriction = FormatUtils.lowerCaseLetters(restriction, 0);
abilityNode = <>{restriction}</>;
}
}
if (!abilityNode) {
abilityNode = <>on saves</>;
}
const saveAmount = situationalBonus.value;
let IconComponent: ComponentType<any>;
if (situationalBonus.value >= 0) {
IconComponent = isDarkMode
? DarkModePositiveBonusPositiveSvg
: PositiveBonusPositiveSvg;
} else {
IconComponent = isDarkMode
? DarkModeNegativeBonusNegativeSvg
: NegativeBonusNegativeSvg;
}
return (
<div
className={styles.situationalBonus}
key={situationalBonus.key}
>
<IconComponent className={styles.situationalBonusIcon} />
<span className={styles.situationalBonusValue}>
{saveAmount}
</span>
<span className={styles.situationalBonusRestriction}>
{abilityNode}
</span>
</div>
);
})}
</div>
)}
{rulesText && rulesText.description && (
<HtmlContent
className={styles.description}
html={rulesText.description}
withoutTooltips
/>
)}
</div>
);
};
@@ -0,0 +1,195 @@
import { FC, HTMLAttributes, ReactNode } from "react";
import { useDispatch, useSelector } from "react-redux";
import { ActionName } from "@dndbeyond/character-components/es";
import {
ActionUtils,
characterActions,
InfusionUtils,
HelperUtils,
ItemUtils,
EntityUtils,
ItemManager,
} from "@dndbeyond/character-rules-engine/es";
import { ItemName } from "~/components/ItemName";
import { DataOriginTypeEnum } from "~/constants";
import { useSidebar } from "~/contexts/Sidebar";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
import { BaseInventoryContract } from "~/types";
import { ActionDetail } from "../../../../../../tools/js/Shared/components/ActionDetail";
import { appEnvSelectors } from "../../../../../../tools/js/Shared/selectors";
import { PaneInitFailureContent } from "../../components/PaneInitFailureContent";
import { getDataOriginComponentInfo } from "../../helpers/paneUtils";
import { PaneComponentEnum, PaneIdentifiersAction } from "../../types";
/*
ActionPane is the Sidebar content that displays the details and usages of actions. If the Action is directly represented as an Item in the Actions section - it will display the ItemPane in the Sidebar. Some Items contain actions (reactions or bonus actions) when attuned - These will display seperately from the Item and when clicked they use the ActionPane with special data depending on the DataOrigin.
*/
interface ActionPaneProps extends HTMLAttributes<HTMLDivElement> {
identifiers: PaneIdentifiersAction | null;
}
export const ActionPane: FC<ActionPaneProps> = ({ identifiers, ...props }) => {
const {
actions,
abilityLookup,
ruleData,
entityValueLookup,
proficiencyBonus,
inventoryLookup,
characterTheme,
} = useCharacterEngine();
const isReadonly = useSelector(appEnvSelectors.getIsReadonly);
const dispatch = useDispatch();
const {
pane: { paneHistoryPush },
} = useSidebar();
const action = actions.find(
(action) =>
identifiers?.id === ActionUtils.getMappingId(action) &&
identifiers?.entityTypeId === ActionUtils.getMappingEntityTypeId(action)
);
const handleCustomDataUpdate = (
key: number,
value: string,
source: string
): void => {
if (action) {
dispatch(
characterActions.valueSet(
key,
value,
source,
ActionUtils.getMappingId(action),
ActionUtils.getMappingEntityTypeId(action)
)
);
}
};
const handleRemoveCustomizations = (): void => {
if (action) {
const mappingId = ActionUtils.getMappingId(action);
const mappingEntityTypeId = ActionUtils.getMappingEntityTypeId(action);
if (mappingId !== null && mappingEntityTypeId !== null) {
dispatch(
characterActions.actionCustomizationsDelete(
mappingId,
mappingEntityTypeId
)
);
}
}
};
const handleLimitedUseSet = (uses: number): void => {
if (action) {
const mappingId = ActionUtils.getMappingId(action);
const mappingEntityTypeId = ActionUtils.getMappingEntityTypeId(action);
if (mappingId === null || mappingEntityTypeId === null) {
return;
}
let dataOriginType = ActionUtils.getDataOriginType(action);
if (dataOriginType === DataOriginTypeEnum.ITEM) {
const dataOrigin = ActionUtils.getDataOrigin(action);
const itemData = dataOrigin.primary as BaseInventoryContract;
const item = ItemManager.getItem(ItemUtils.getMappingId(itemData));
item.handleItemLimitedUseSet(uses);
} else {
dispatch(
characterActions.actionUseSet(
mappingId,
mappingEntityTypeId,
uses,
dataOriginType
)
);
}
}
};
const handleParentClick = (): void => {
if (action) {
let dataOrigin = ActionUtils.getDataOrigin(action);
let component = getDataOriginComponentInfo(dataOrigin);
if (component.type !== PaneComponentEnum.ERROR_404) {
paneHistoryPush(component.type, component.identifiers);
}
}
};
if (!action) {
return <PaneInitFailureContent />;
}
//We could do the dataOrigin stuff better, this could be refactored
//Check ItemPane and InfusionPanes for similar usages of DataOrigin
const dataOrigin = ActionUtils.getDataOrigin(action);
const dataOriginType = ActionUtils.getDataOriginType(action);
let name: ReactNode = null;
if (ActionUtils.getName(action) !== "") {
name = <ActionName theme={characterTheme} action={action} />;
}
let parentName: string | null = null;
let parentClick: (() => void) | undefined = undefined;
if (dataOriginType === DataOriginTypeEnum.ITEM) {
const itemContract = dataOrigin.primary as BaseInventoryContract;
const itemMappingId = ItemUtils.getMappingId(itemContract);
const item = HelperUtils.lookupDataOrFallback(
inventoryLookup,
itemMappingId
);
if (item !== null) {
let infusion = ItemUtils.getInfusion(item);
if (infusion) {
parentName = `Infusion: ${InfusionUtils.getName(infusion)}`;
parentClick = handleParentClick;
}
if (name === null) {
name = <ItemName item={item} onClick={handleParentClick} />;
}
}
} else {
if (dataOrigin.primary) {
parentName = EntityUtils.getDataOriginName(dataOrigin);
parentClick = handleParentClick;
}
}
return (
<div key={ActionUtils.getUniqueKey(action)} {...props}>
<Header parent={parentName} onClick={parentClick}>
{name}
</Header>
<ActionDetail
theme={characterTheme}
action={action}
abilityLookup={abilityLookup}
showCustomize={dataOriginType !== DataOriginTypeEnum.ITEM}
onCustomDataUpdate={handleCustomDataUpdate}
onCustomizationsRemove={handleRemoveCustomizations}
ruleData={ruleData}
entityValueLookup={entityValueLookup}
inventoryLookup={inventoryLookup}
isReadonly={isReadonly}
onLimitedUseSet={handleLimitedUseSet}
proficiencyBonus={proficiencyBonus}
/>
</div>
);
};
@@ -0,0 +1,137 @@
import clsx from "clsx";
import { FC, HTMLAttributes, ReactNode } from "react";
import { DataOriginName } from "@dndbeyond/character-components/es";
import {
FormatUtils,
ItemUtils,
ModifierUtils,
} from "@dndbeyond/character-rules-engine";
import { ItemName } from "~/components/ItemName";
import { NumberDisplay } from "~/components/NumberDisplay";
import { ArmorClassExtraTypeEnum, ArmorClassTypeEnum } from "~/constants";
import { useSidebar } from "~/contexts/Sidebar";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { PaneIdentifierUtils } from "~/tools/js/Shared/utils";
import { Item, DataOrigin, Modifier } from "~/types";
import { getDataOriginComponentInfo } from "../../../helpers/paneUtils";
import { PaneComponentEnum } from "../../../types";
import styles from "./styles.module.css";
export interface ArmorClassDetailProps extends HTMLAttributes<HTMLDivElement> {}
export const ArmorClassDetail: FC<ArmorClassDetailProps> = ({
className,
...props
}) => {
const {
pane: { paneHistoryPush },
} = useSidebar();
const { acAdjustments, acSuppliers, characterTheme } = useCharacterEngine();
let hasOverrideAcAdjustment =
acAdjustments.overrideAc && acAdjustments.overrideAc.value !== null;
const handleItemDetailShow = (item: Item): void => {
paneHistoryPush(
PaneComponentEnum.ITEM_DETAIL,
PaneIdentifierUtils.generateItem(ItemUtils.getMappingId(item))
);
};
const handleDataOriginShowDetail = (dataOrigin: DataOrigin): void => {
let component = getDataOriginComponentInfo(dataOrigin);
if (component.type !== PaneComponentEnum.ERROR_404) {
paneHistoryPush(component.type, component.identifiers);
}
};
return (
<div
className={clsx([
styles.items,
hasOverrideAcAdjustment && styles.itemsOverridden,
])}
>
{acSuppliers.map((armorSupplier, idx) => {
const { type, amount, extra, extraType } = armorSupplier;
if (!amount) {
return null;
}
let amountDisplay: ReactNode;
switch (type) {
case ArmorClassTypeEnum.ARMOR:
case ArmorClassTypeEnum.OVERRIDE_BASE_ARMOR:
amountDisplay = amount;
break;
default:
amountDisplay = <NumberDisplay type="signed" number={amount} />;
}
let extraDisplay: ReactNode;
switch (type) {
case ArmorClassTypeEnum.DEX_BONUS:
extraDisplay = extra === null ? "" : `Max ${extra}`;
break;
default:
switch (extraType) {
case ArmorClassExtraTypeEnum.MODIFIER:
extraDisplay = (
<DataOriginName
dataOrigin={ModifierUtils.getDataOrigin(extra as Modifier)}
onClick={handleDataOriginShowDetail}
theme={characterTheme}
/>
);
break;
case ArmorClassExtraTypeEnum.ITEM:
if (extra) {
extraDisplay = (
<ItemName
item={extra as Item}
onClick={() => handleItemDetailShow(extra as Item)}
/>
);
} else {
extraDisplay = "None";
}
break;
case ArmorClassExtraTypeEnum.NUMBER:
extraDisplay = extra
? FormatUtils.renderLocaleNumber(extra as number)
: "";
break;
case ArmorClassExtraTypeEnum.STRING:
default:
extraDisplay = extra;
}
break;
}
return (
<div
className={styles.item}
key={idx}
{...props}
data-testid="armor-class-detail"
>
<div className={styles.value}>{amountDisplay}</div>
<div className={styles.label}>
<span>{type}</span>
{extraDisplay && (
<span className={styles.source}>({extraDisplay})</span>
)}
</div>
</div>
);
})}
</div>
);
};
@@ -0,0 +1,106 @@
import { FC, HTMLAttributes } from "react";
import { useDispatch, useSelector } from "react-redux";
import { Collapsible } from "@dndbeyond/character-components/es";
import {
characterActions,
ValueUtils,
} from "@dndbeyond/character-rules-engine/es";
import { HtmlContent } from "~/components/HtmlContent";
import { AdjustmentTypeEnum, RuleKeyEnum } from "~/constants";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { useRuleData } from "~/hooks/useRuleData";
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
import EditorBox from "~/tools/js/Shared/components/EditorBox";
import ValueEditor from "~/tools/js/Shared/components/ValueEditor";
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
import { ArmorClassDetail } from "./ArmorClassDetail";
import styles from "./styles.module.css";
/*
This is the sidebar information for Armor Class. It displays the sources of any Armor Class bonuses and you can provide customizations and overrides here.
*/
interface ArmorManagePaneProps extends HTMLAttributes<HTMLDivElement> {}
export const ArmorManagePane: FC<ArmorManagePaneProps> = ({ ...props }) => {
const dispatch = useDispatch();
const { acAdjustments, acTotal, valueLookup, ruleData } =
useCharacterEngine();
const isReadonly = useSelector(appEnvSelectors.getIsReadonly);
const { ruleDataUtils } = useRuleData();
const acRule =
ruleDataUtils.getRule(RuleKeyEnum.ARMOR_CLASS, ruleData)?.description ??
null;
const customizationValues = [
AdjustmentTypeEnum.OVERRIDE_AC,
AdjustmentTypeEnum.OVERRIDE_BASE_ARMOR,
AdjustmentTypeEnum.MAGIC_BONUS_AC,
AdjustmentTypeEnum.MISC_BONUS_AC,
];
let hasOverrideAcAdjustment: boolean =
acAdjustments.overrideAc !== null &&
acAdjustments.overrideAc.value !== null;
let overrideAcAdjustmentSource: string | null =
hasOverrideAcAdjustment && acAdjustments.overrideAc
? acAdjustments.overrideAc.notes
: "";
const handleCustomDataUpdate = (
key: number,
value: string,
source: string
): void => {
dispatch(characterActions.valueSet(key, value, source));
};
return (
<div {...props}>
<Header>
Armor Class: {acTotal}
{hasOverrideAcAdjustment ? "*" : ""}
{hasOverrideAcAdjustment && (
<span className={styles.headingOverride}>
(
{overrideAcAdjustmentSource
? overrideAcAdjustmentSource
: "Override"}
)
</span>
)}
</Header>
<ArmorClassDetail />
{/* AC CUSTOMIZATION */}
{!isReadonly && (
<Collapsible
layoutType={"minimal"}
header="Customize"
className={styles.customize}
>
<EditorBox className={styles.editorBox}>
<ValueEditor
dataLookup={ValueUtils.getDataLookup(
valueLookup,
customizationValues
)}
onDataUpdate={handleCustomDataUpdate}
valueEditors={customizationValues}
ruleData={ruleData}
/>
</EditorBox>
</Collapsible>
)}
{/* AC Description */}
{acRule && (
<HtmlContent
className={styles.description}
html={acRule}
withoutTooltips
/>
)}
</div>
);
};
@@ -0,0 +1,75 @@
import { FC, HTMLAttributes } from "react";
import { BackgroundUtils } from "@dndbeyond/character-rules-engine/es";
import { HtmlContent } from "~/components/HtmlContent";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { useRuleData } from "~/hooks/useRuleData";
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
import { Heading } from "~/subApps/sheet/components/Sidebar/components/Heading";
import { PaneInitFailureContent } from "~/subApps/sheet/components/Sidebar/components/PaneInitFailureContent";
import { FeatureSnippetChoices } from "../../../../../../tools/js/CharacterSheet/components/FeatureSnippet";
import styles from "./styles.module.css";
/*
This is the Sidebar display for Background. You can access this Pane by clicking on the Background data displayed in the Description tab section of the Character Sheet.
*/
interface BackgroundPaneProps extends HTMLAttributes<HTMLDivElement> {}
export const BackgroundPane: FC<BackgroundPaneProps> = ({ ...props }) => {
const {
ruleData,
abilityLookup,
backgroundInfo,
snippetData,
originRef,
proficiencyBonus,
characterTheme,
} = useCharacterEngine();
const { ruleDataUtils } = useRuleData();
if (!backgroundInfo) {
return <PaneInitFailureContent />;
}
const hasCustomBackground =
BackgroundUtils.getHasCustomBackground(backgroundInfo);
const description = BackgroundUtils.getDescription(backgroundInfo);
const featureName = BackgroundUtils.getFeatureName(backgroundInfo);
return (
<div {...props}>
<Header>{BackgroundUtils.getName(backgroundInfo)}</Header>
<div className={styles.data}>
<div className={styles.choices}>
<FeatureSnippetChoices
choices={BackgroundUtils.getChoices(backgroundInfo)}
ruleData={ruleData}
abilityLookup={abilityLookup}
snippetData={snippetData}
sourceDataLookup={ruleDataUtils.getSourceDataLookup(ruleData)}
dataOriginRefData={originRef}
proficiencyBonus={proficiencyBonus}
theme={characterTheme}
/>
</div>
</div>
{hasCustomBackground ? (
<div className={styles.customDescription}>{description}</div>
) : (
<HtmlContent html={description ? description : ""} withoutTooltips />
)}
{hasCustomBackground && featureName && (
<div>
<Heading>Feature: {featureName}</Heading>
<HtmlContent
html={BackgroundUtils.getFeatureDescription(backgroundInfo) ?? ""}
withoutTooltips
/>
</div>
)}
</div>
);
};
@@ -0,0 +1,41 @@
import { FC, HTMLAttributes } from "react";
import { HtmlContent } from "~/components/HtmlContent";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { useRuleData } from "~/hooks/useRuleData";
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
import { PaneInitFailureContent } from "../../components/PaneInitFailureContent";
import { PaneIdentifiersBasicAction } from "../../types";
/*
This is the Sidebar display for Basic Actions. You can see this Pane by clicking on the Basic Actions (actions, bonus actions, reactions, etc.) displayed in the Actions section of the Character Sheet. Examples of Basic Actions include Attack, Dash, Disengage, Dodge, Help, Hide, Ready, Search, Use an Object, etc.
*/
interface BasicActionPaneProps extends HTMLAttributes<HTMLDivElement> {
identifiers: PaneIdentifiersBasicAction | null;
}
export const BasicActionPane: FC<BasicActionPaneProps> = ({
identifiers,
...props
}) => {
const { ruleData } = useCharacterEngine();
const { ruleDataUtils } = useRuleData();
const action =
identifiers && ruleDataUtils.getBasicAction(identifiers.id, ruleData);
if (action === null) {
return <PaneInitFailureContent />;
}
return (
<div {...props}>
<Header>{action.name}</Header>
{action.description !== null && (
<HtmlContent html={action.description} withoutTooltips />
)}
</div>
);
};
@@ -0,0 +1,69 @@
import clsx from "clsx";
import { FC, HTMLAttributes } from "react";
import { useSelector } from "react-redux";
import { CharacterAvatarPortrait } from "@dndbeyond/character-components/es";
import { useCharacterTheme } from "~/contexts/CharacterTheme";
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
import styles from "./styles.module.css";
/*
This is a single character in the CampaignPane. It displays the character's avatar, name, and user name.
*/
export interface CampaignCharacterProps extends HTMLAttributes<HTMLDivElement> {
characterId: number;
avatarUrl: string;
characterName: string;
userName: string;
}
export const CampaignCharacter: FC<CampaignCharacterProps> = ({
characterId,
avatarUrl,
characterName,
userName,
className,
...props
}) => {
const { isDarkMode } = useCharacterTheme();
const currentCharacterId = useSelector(appEnvSelectors.getCharacterId);
const isCurrentCharacter = currentCharacterId === characterId;
return (
<div
className={clsx([
styles.character,
isCurrentCharacter && styles.current,
className,
])}
key={characterId}
{...props}
>
<CharacterAvatarPortrait
className={styles.preview}
avatarUrl={avatarUrl}
/>
<div className={styles.content}>
<div className={styles.name}>
{isCurrentCharacter ? (
<span>{characterName}</span>
) : (
<a
href={`/characters/${characterId}`}
className={clsx([styles.link, isDarkMode && styles.darkMode])}
target="_blank"
rel="noreferrer"
>
{characterName}
</a>
)}
</div>
<div className={styles.user}>{userName}</div>
</div>
</div>
);
};
@@ -0,0 +1,100 @@
import { FC, HTMLAttributes } from "react";
import { useSelector } from "react-redux";
import {
DarkPlayButtonSvg,
LightPlayButtonSvg,
} from "@dndbeyond/character-components/es";
import { Button } from "~/components/Button/Button";
import { HtmlContent } from "~/components/HtmlContent";
import { PreferencePrivacyTypeEnum } from "~/constants";
import { useCharacterTheme } from "~/contexts/CharacterTheme";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
import { PaneInitFailureContent } from "~/subApps/sheet/components/Sidebar/components/PaneInitFailureContent";
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
import { NavigationUtils } from "~/tools/js/Shared/utils";
import { CampaignCharacterContract } from "~/types";
import { CampaignCharacter } from "./CampaignCharacter";
import styles from "./styles.module.css";
/*
This is the Sidebar for a Campaign. If the character has joined a Campaign, you can click on the campaign button in the Character Sheet header to see this Sidebar. It displays the Campaign name, maps link, DM, and all characters in the campaign. If the character is the DM, they will see all characters in the campaign. If the character is not the DM, they will only see characters that have been set to public or campaign only.
*/
interface CampaignPaneProps extends HTMLAttributes<HTMLDivElement> {}
export const CampaignPane: FC<CampaignPaneProps> = ({ ...props }) => {
const { campaign, campaignUtils } = useCharacterEngine();
const { isDarkMode } = useCharacterTheme();
const isReadonly = useSelector(appEnvSelectors.getIsReadonly);
if (!campaign) {
return <PaneInitFailureContent />;
}
const characters = campaignUtils.getCharacters(campaign);
const description = campaignUtils.getDescription(campaign);
const filteredCharacters = characters
? characters.filter(
(character) =>
character.privacyType === PreferencePrivacyTypeEnum.PUBLIC ||
character.privacyType === PreferencePrivacyTypeEnum.CAMPAIGN_ONLY
)
: [];
const orderedCharacters = filteredCharacters.sort((a, b) =>
(a.characterName ?? "").localeCompare(b.characterName ?? "")
);
return (
<div {...props}>
<div className={styles.header}>
<Header>Campaign</Header>
<Button
href={NavigationUtils.getLaunchGameUrl(campaign)}
target="_blank"
size="x-small"
variant="text"
className={styles.launchButton}
themed
>
{isDarkMode ? <LightPlayButtonSvg /> : <DarkPlayButtonSvg />}
<span>Launch Game</span>
</Button>
</div>
<div className={styles.name}>
{isReadonly ? (
campaignUtils.getName(campaign)
) : (
<a
className={styles.link}
href={campaignUtils.getLink(campaign) ?? ""}
>
{campaignUtils.getName(campaign)}
</a>
)}
</div>
{!isReadonly && description !== null && (
<HtmlContent html={description} withoutTooltips />
)}
<div className={styles.dm}>
<span className={styles.dmLabel}>DM:</span>
<span>{campaignUtils.getDmUsername(campaign)}</span>
</div>
<div className={styles.characters}>
{orderedCharacters.map((character: CampaignCharacterContract) => (
<CampaignCharacter
key={character.characterId}
characterId={character.characterId}
avatarUrl={character.avatarUrl ?? ""}
characterName={character.characterName ?? ""}
userName={character.username ?? ""}
/>
))}
</div>
</div>
);
};
@@ -0,0 +1,247 @@
import { ComponentType, FC, HTMLAttributes, ReactNode } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useNavigate } from "react-router-dom";
import {
LightLinkOutSvg,
ThemedBackdropSvg,
ThemedBuilderSvg,
ThemedExportSvg,
ThemedFrameSvg,
ThemedLongRestSvg,
ThemedManageLevelSvg,
ThemedManageXpSvg,
ThemedPortraitSvg,
ThemedPreferencesSvg,
ThemedShareSvg,
ThemedShortRestSvg,
ThemedThemeIconSvg,
ThemedChatBubbleSvg,
} from "@dndbeyond/character-components/es";
import D6 from "@dndbeyond/fontawesome-cache/svgs/regular/dice-d6.svg";
import { Button } from "~/components/Button";
import config from "~/config";
import {
PreferencePrivacyTypeEnum,
PreferenceProgressionTypeEnum,
} from "~/constants";
import { useSidebar } from "~/contexts/Sidebar";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { sheetAppSelectors } from "~/tools/js/CharacterSheet/selectors";
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
import { CharacterStatusSlug } from "~/types";
import {
PaneMenu,
PaneMenuGroup,
PaneMenuItem,
} from "../../components/PaneMenu";
import { PaneComponentEnum } from "../../types";
import { Overview } from "./Overview";
import styles from "./styles.module.css";
const BASE_PATHNAME = config.basePathname;
interface CharacterManagePaneProps extends HTMLAttributes<HTMLDivElement> {}
export const CharacterManagePane: FC<CharacterManagePaneProps> = ({
...props
}) => {
const navigate = useNavigate();
const isReadonly = useSelector(appEnvSelectors.getIsReadonly);
const builderUrl = useSelector(sheetAppSelectors.getBuilderUrl);
const {
preferences,
decorationInfo,
helperUtils,
decorationUtils,
characterStatusSlug: characterStatus,
characterTheme,
} = useCharacterEngine();
const {
pane: { paneHistoryPush },
} = useSidebar();
const handleXpMenuClick = (): void => {
paneHistoryPush(PaneComponentEnum.XP);
};
const handleGameLogMenuClick = (): void => {
paneHistoryPush(PaneComponentEnum.GAME_LOG);
};
const handleShortRestMenuClick = (): void => {
paneHistoryPush(PaneComponentEnum.SHORT_REST);
};
const handleLongRestMenuClick = (): void => {
paneHistoryPush(PaneComponentEnum.LONG_REST);
};
const handlePreferencesMenuClick = (): void => {
paneHistoryPush(PaneComponentEnum.PREFERENCES);
};
const handleShareMenuClick = (): void => {
paneHistoryPush(PaneComponentEnum.SHARE_URL);
};
const handlePdfClick = (): void => {
paneHistoryPush(PaneComponentEnum.EXPORT_PDF);
};
const handleBuilderClick = (): void => {
navigate(builderUrl);
};
//USE A LOOKUP TO GET EACH MENU ICON
const getMenuIconNode = (menuKey: string): ReactNode => {
const menuIconLookup: Record<string, ComponentType> = {
builder: ThemedBuilderSvg,
shortrest: ThemedShortRestSvg,
longrest: ThemedLongRestSvg,
share: ThemedShareSvg,
managelevel: ThemedManageLevelSvg,
managexp: ThemedManageXpSvg,
gamelog: ThemedChatBubbleSvg,
frame: ThemedFrameSvg,
backdrop: ThemedBackdropSvg,
theme: ThemedThemeIconSvg,
portrait: ThemedPortraitSvg,
preferences: ThemedPreferencesSvg,
downloadpdf: ThemedExportSvg,
};
const IconNode: ComponentType<any> | null =
menuIconLookup[menuKey] &&
helperUtils.lookupDataOrFallback(menuIconLookup, menuKey);
if (IconNode === null) {
return null;
}
if (menuKey === "gamelog") {
return (
<D6
className="ddbc-svg ddbc-svg--themed"
style={{ fill: characterTheme.themeColor }}
/>
);
}
return (
<IconNode theme={decorationUtils.getCharacterTheme(decorationInfo)} />
);
};
return (
<div
className={styles.characterManagePane}
data-testid="character-manage-pane"
{...props}
>
<Overview />
{/* Display Readonly CTA or MENU */}
{isReadonly ? (
<div className={styles.readonlyBackground}>
<div className={styles.readonlyContent}>
<div>
You are viewing a{" "}
{characterStatus === CharacterStatusSlug.PREMADE
? "premade"
: "read-only"}{" "}
Character Sheet.
</div>
<Button
className={styles.createCTA}
size="medium"
variant="solid"
target="_blank"
themed
href={`${BASE_PATHNAME}/builder`}
forceThemeMode="dark"
>
<span>Create a Character</span>
<LightLinkOutSvg />
</Button>
</div>
</div>
) : (
<PaneMenu>
<PaneMenuGroup label="My Character">
<PaneMenuItem
menukey="builder"
prefixIcon={getMenuIconNode("builder")}
suffixIcon={<LightLinkOutSvg />}
onClick={handleBuilderClick}
>
Manage Character & Levels
</PaneMenuItem>
{preferences.progressionType ===
PreferenceProgressionTypeEnum.XP && (
<PaneMenuItem
menukey="managexp"
prefixIcon={getMenuIconNode("managexp")}
onClick={handleXpMenuClick}
>
Manage Experience
</PaneMenuItem>
)}
<PaneMenuItem
menukey="preferences"
prefixIcon={getMenuIconNode("preferences")}
onClick={handlePreferencesMenuClick}
>
Character Settings
</PaneMenuItem>
</PaneMenuGroup>
<PaneMenuGroup label="Play">
<PaneMenuItem
menukey="gamelog"
prefixIcon={getMenuIconNode("gamelog")}
onClick={handleGameLogMenuClick}
>
Game Log
</PaneMenuItem>
<PaneMenuItem
menukey="shortrest"
prefixIcon={getMenuIconNode("shortrest")}
onClick={handleShortRestMenuClick}
>
Short Rest
</PaneMenuItem>
<PaneMenuItem
menukey="longrest"
prefixIcon={getMenuIconNode("longrest")}
onClick={handleLongRestMenuClick}
>
Long Rest
</PaneMenuItem>
</PaneMenuGroup>
<PaneMenuGroup label="Share">
{preferences.privacyType === PreferencePrivacyTypeEnum.PUBLIC && (
<PaneMenuItem
menukey="share"
prefixIcon={getMenuIconNode("share")}
onClick={handleShareMenuClick}
>
Shareable Link
</PaneMenuItem>
)}
<PaneMenuItem
menukey="downloadpdf"
prefixIcon={getMenuIconNode("downloadpdf")}
onClick={handlePdfClick}
>
Export to PDF
</PaneMenuItem>
</PaneMenuGroup>
</PaneMenu>
)}
</div>
);
};
@@ -0,0 +1,280 @@
import clsx from "clsx";
import {
ChangeEvent,
FC,
HTMLAttributes,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useDispatch, useSelector } from "react-redux";
import {
CharacterAvatarPortrait,
CharacterName,
CharacterProgressionSummary,
CharacterSummary,
LightPencilSvg,
ThemedPaintBrushSvg,
} from "@dndbeyond/character-components/es";
import { Button } from "~/components/Button/Button";
import {
CharacterNameLimitMsg,
DeathCauseEnum,
InputLimits,
} from "~/constants";
import { useCharacterTheme } from "~/contexts/CharacterTheme";
import { useSidebar } from "~/contexts/Sidebar";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { useMaxLengthErrorHandling } from "~/hooks/useErrorHandling/useMaxLengthErrorHandling";
import useUserId from "~/hooks/useUserId";
import { appEnvActions } from "~/tools/js/Shared/actions";
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
import { CharacterStatusSlug } from "~/types";
import { Popover } from "../../../../../../../components/Popover";
import { PopoverContent } from "../../../../../../../components/PopoverContent";
import { PaneComponentEnum } from "../../../types";
import styles from "./styles.module.css";
export interface OverviewProps extends HTMLAttributes<HTMLDivElement> {}
export const Overview: FC<OverviewProps> = ({ className, ...props }) => {
const {
ruleData,
characterName,
characterGender: gender,
race: species,
experienceInfo: xpInfo,
deathCause,
preferences,
decorationInfo,
playerName,
playerId,
characterActions,
decorationUtils,
classUtils,
classes,
characterStatusSlug: characterStatus,
} = useCharacterEngine();
const dispatch = useDispatch();
const { isDarkMode } = useCharacterTheme();
const userId = useUserId();
const isReadonly = useSelector(appEnvSelectors.getIsReadonly);
const canOverrideReadOnly = useSelector(
appEnvSelectors.getCanOverrideReadOnly
);
const {
pane: { paneHistoryPush },
} = useSidebar();
const { MaxLengthErrorMessage, hideError, handleMaxLengthErrorMsg } =
useMaxLengthErrorHandling(
(characterName?.length ?? 0) >= InputLimits.characterNameMaxLength,
InputLimits.characterNameMaxLength,
CharacterNameLimitMsg
);
const isUserCharacter = playerId === Number(userId);
const isPremade = characterStatus === CharacterStatusSlug.PREMADE;
const [isNameEditorVisible, setIsNameEditorVisible] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isNameEditorVisible && inputRef.current) {
inputRef.current.focus();
}
}, [isNameEditorVisible]);
const handleNameClick = (): void => {
setIsNameEditorVisible(true);
};
const handleNameChange = (evt: ChangeEvent<HTMLInputElement>): void => {
const inputValue = evt.target.value;
if (inputValue !== characterName && inputValue !== "") {
dispatch(characterActions.nameSet(inputValue));
}
setIsNameEditorVisible(false);
hideError();
};
const handleToggleEdit = (status: boolean): void => {
dispatch(appEnvActions.dataSet({ isReadonly: status }));
};
const handleNameUpdate = (evt: ChangeEvent<HTMLInputElement>): void => {
handleMaxLengthErrorMsg(evt.target.value);
};
const handleDecorateMenuClick = (): void => {
paneHistoryPush(PaneComponentEnum.DECORATE);
};
const isEditingCharacterName = useMemo(
() => isNameEditorVisible && !isReadonly,
[isNameEditorVisible, isReadonly]
);
return (
<div
className={clsx([styles.intro, isDarkMode && styles.dark, className])}
{...props}
>
<div className={styles.summary}>
<CharacterAvatarPortrait
className={styles.avatar}
avatarUrl={decorationUtils.getAvatarInfo(decorationInfo).avatarUrl}
/>
{
<div
className={clsx([
styles.characterName,
isEditingCharacterName && styles.editName,
])}
onClick={isReadonly ? undefined : handleNameClick}
>
{isEditingCharacterName ? (
<>
<input
ref={inputRef}
type="text"
defaultValue={characterName ?? ""}
maxLength={InputLimits.characterNameMaxLength}
onBlur={handleNameChange}
onChange={handleNameUpdate}
onFocus={handleNameUpdate}
/>
<MaxLengthErrorMessage />
</>
) : (
<>
<CharacterName
name={characterName ?? ""}
isDead={deathCause !== DeathCauseEnum.NONE}
isFaceMenu={true}
/>
{!isReadonly && <LightPencilSvg />}
</>
)}
</div>
}
{/* DISPLAY PLAYER NAME IF ITS NOT A PREMADE OR YOUR CHARACTER */}
{!isUserCharacter && !isPremade && (
<div>
<span className={styles.playerNameLabel}>Player: </span>
{playerName}
</div>
)}
{/* SHOW SPECIES, GENDER AND XP/LEVEL INFO */}
<div className={styles.description}>
<CharacterSummary
className={styles.summaryText}
classes={null}
species={species}
gender={gender}
/>
<CharacterProgressionSummary
ruleData={ruleData}
progressionType={preferences.progressionType}
xpInfo={xpInfo}
/>
</div>
</div>
{/* DISPLAY CLASS LIST */}
<div className={styles.classList}>
{classes.map((charClass) => (
<div className={styles.classItem} key={classUtils.getId(charClass)}>
<div
className={styles.classImage}
style={{
backgroundImage: `url(${classUtils.getPortraitUrl(charClass)})`,
}}
>
<div className={styles.classLevel}>
{classUtils.getLevel(charClass)}
</div>
</div>
<div className={styles.classSummary}>
<div>{classUtils.getName(charClass)}</div>
<div className={styles.classMeta}>
{classUtils.getSubclassName(charClass) ?? "No Subclass"}
</div>
</div>
</div>
))}
</div>
<div className={styles.buttonGroup}>
{/* This is only for PREMADE characters */}
{canOverrideReadOnly && (
<div className={styles.featureCallout}>
{isReadonly ? (
<Popover
trigger={
<Button
size="x-small"
variant="solid"
className={clsx([styles.toggleButton])}
forceThemeMode="dark"
>
<LightPencilSvg />
<span>Enable Edit Premade</span>
</Button>
}
maxWidth={284}
className={styles.popoverToggle}
>
<PopoverContent
title="PROCEED WITH CAUTION!!!"
content="⚠️ If you enable editing you will be modifying a published character ⚠️"
confirmText="Enable"
onConfirm={() => handleToggleEdit(false)}
withCancel
/>
</Popover>
) : (
<Button
size="x-small"
variant="solid"
className={clsx([styles.toggleButton])}
forceThemeMode="dark"
onClick={() => handleToggleEdit(true)}
>
<LightPencilSvg />
<span>DISABLE Edit Premade</span>
</Button>
)}
</div>
)}
{/* DISPLAY DECORATE BUTTON AS A "FEATURE CALLOUT" */}
{!isReadonly && (
<div className={styles.featureCallout}>
<Button
size="x-small"
variant="outline"
className={styles.decorateButton}
forceThemeMode="dark"
themed
onClick={handleDecorateMenuClick}
>
<ThemedPaintBrushSvg
theme={decorationUtils.getCharacterTheme(decorationInfo)}
/>
<span>Change Sheet Appearance</span>
</Button>
</div>
)}
</div>
</div>
);
};
@@ -0,0 +1,385 @@
import {
FC,
HTMLAttributes,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { useDispatch, useSelector } from "react-redux";
import {
ApiAdapterUtils,
FeatManager,
} from "@dndbeyond/character-rules-engine";
import { FeatureChoice } from "~/components/FeatureChoice";
import { BuilderChoiceTypeEnum } from "~/constants";
import { useSidebar } from "~/contexts/Sidebar";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
import { Preview } from "~/subApps/sheet/components/Sidebar/components/Preview";
import DataLoadingStatusEnum from "~/tools/js/Shared/constants/DataLoadingStatusEnum";
import { CharacterFeaturesManagerContext } from "~/tools/js/Shared/managers/CharacterFeaturesManagerContext";
import { apiCreatorSelectors } from "~/tools/js/Shared/selectors";
import { AppLoggerUtils, PaneIdentifierUtils } from "~/tools/js/Shared/utils";
import LoadingPlaceholder from "~/tools/js/smartComponents/LoadingPlaceholder";
import {
Action,
CharClass,
ClassDefinitionContract,
ClassFeature,
Feat,
InfusionChoice,
Spell,
} from "~/types";
import { ClassFeatureSnippet } from "../../../../../../tools/js/CharacterSheet/components/FeatureSnippet";
import { appEnvSelectors } from "../../../../../../tools/js/Shared/selectors";
import { Heading } from "../../components/Heading";
import { PaneInitFailureContent } from "../../components/PaneInitFailureContent";
import { PaneComponentEnum, PaneIdentifiersClassFeature } from "../../types";
import styles from "./styles.module.css";
interface Props extends HTMLAttributes<HTMLDivElement> {
identifiers: PaneIdentifiersClassFeature | null;
}
export const ClassFeaturePane: FC<Props> = ({ identifiers, ...props }) => {
const dispatch = useDispatch();
const {
infusionChoiceUtils,
actionUtils,
characterActions,
spellUtils,
classUtils,
classFeatureUtils,
classes,
feats,
snippetData,
ruleData,
abilityLookup,
proficiencyBonus,
originRef: dataOriginRefData,
characterTheme: theme,
choiceUtils,
featUtils,
helperUtils,
} = useCharacterEngine();
const { characterFeaturesManager } = useContext(
CharacterFeaturesManagerContext
);
const {
pane: { paneHistoryPush },
} = useSidebar();
const isReadonly = useSelector(appEnvSelectors.getIsReadonly);
const loadAvailableFeats = useSelector(
apiCreatorSelectors.makeLoadAvailableFeats
);
const loadAvailableSubclasses = useSelector(
apiCreatorSelectors.makeLoadAvailableSubclasses
);
const [subclassData, setSubclassData] = useState<
Array<ClassDefinitionContract>
>([]);
const [featsData, setFeatsData] = useState<Array<Feat>>([]);
const [featLoadingStatus, setFeatLoadingStatus] =
useState<DataLoadingStatusEnum>(DataLoadingStatusEnum.NOT_INITIALIZED);
const [subclassLoadingStatus, setSubclassLoadingStatus] =
useState<DataLoadingStatusEnum>(DataLoadingStatusEnum.NOT_INITIALIZED);
const currentCharClass = useRef<CharClass | null>(null);
const charClass = identifiers?.classMappingId
? classes.find(
(charClass) =>
identifiers.classMappingId === classUtils.getMappingId(charClass)
)
: null;
const classFeature =
charClass?.classFeatures.find(
(feature) => identifiers?.id === classFeatureUtils.getId(feature)
) ?? null;
const featureChoices = classFeature
? classFeatureUtils.getChoices(classFeature)
: [];
const hasFeatChoice = featureChoices.some(
(choice) =>
choiceUtils.getType(choice) === BuilderChoiceTypeEnum.FEAT_CHOICE_OPTION
);
const hasSubclassChoice = featureChoices.some(
(choice) =>
choiceUtils.getType(choice) === BuilderChoiceTypeEnum.SUB_CLASS_OPTION
);
useEffect(() => {
const isSameClass = (
c1: CharClass | null,
c2: CharClass | null
): boolean => {
if (!c1 || !c2) return false;
return classUtils.getId(c1) === classUtils.getId(c2);
};
if (
hasFeatChoice &&
featLoadingStatus === DataLoadingStatusEnum.NOT_INITIALIZED
) {
setFeatLoadingStatus(DataLoadingStatusEnum.LOADING);
loadAvailableFeats({
// cancelToken: new axios.CancelToken((c) => {
// this.loadFeatsCanceler = c;
// }),
})
.then((response) => {
let featsData: Array<Feat> = [];
const data = ApiAdapterUtils.getResponseData(response);
if (data !== null) {
featsData = data.map((definition) =>
featUtils.simulateFeat(definition)
);
}
setFeatsData(featsData);
setFeatLoadingStatus(DataLoadingStatusEnum.LOADED);
// this.loadFeatsCanceler = null;
})
.catch(AppLoggerUtils.handleAdhocApiError);
}
if (
charClass &&
!isSameClass(charClass, currentCharClass.current) && // Only pull data if the class has changed from previous fetch
hasSubclassChoice &&
subclassLoadingStatus !== DataLoadingStatusEnum.LOADING // Check if its not already fetching data
) {
currentCharClass.current = charClass; //Update ref of fetched class
setSubclassLoadingStatus(DataLoadingStatusEnum.LOADING);
loadAvailableSubclasses(classUtils.getId(charClass), {
// cancelToken: new axios.CancelToken((c) => {
// this.loadSubclassesCanceler = c;
// }),
})
.then((response) => {
let subclassData: Array<ClassDefinitionContract> = [];
const data = ApiAdapterUtils.getResponseData(response);
if (data !== null) {
subclassData = data;
}
setSubclassData(subclassData);
setSubclassLoadingStatus(DataLoadingStatusEnum.LOADED);
// this.loadSubclassesCanceler = null;
})
.catch(AppLoggerUtils.handleAdhocApiError);
}
}, [
hasFeatChoice,
hasSubclassChoice,
subclassLoadingStatus,
featLoadingStatus,
charClass,
classUtils,
featUtils,
loadAvailableFeats,
loadAvailableSubclasses,
currentCharClass,
]);
const handleInfusionChoiceShow = (infusionChoice: InfusionChoice): void => {
const choiceKey = infusionChoiceUtils.getKey(infusionChoice);
if (choiceKey !== null) {
paneHistoryPush(
PaneComponentEnum.INFUSION_CHOICE,
PaneIdentifierUtils.generateInfusionChoice(choiceKey)
);
}
};
const handleChoiceChange = (
id: string,
type: number,
value: any,
parentChoiceId: string | null
): void => {
if (!charClass || !classFeature) {
return;
}
dispatch(
characterActions.classFeatureChoiceSetRequest(
classUtils.getActiveId(charClass),
classFeatureUtils.getId(classFeature),
type,
id,
helperUtils.parseInputInt(value),
parentChoiceId
)
);
};
const handleActionUseSet = (action: Action, uses: number): void => {
const id = actionUtils.getId(action);
const entityTypeId = actionUtils.getEntityTypeId(action);
if (id !== null && entityTypeId !== null) {
dispatch(
characterActions.actionUseSet(
id,
entityTypeId,
uses,
actionUtils.getDataOriginType(action)
)
);
}
};
const handleSpellUseSet = (spell: Spell, uses: number): void => {
const mappingId = spellUtils.getMappingId(spell);
const mappingEntityTypeId = spellUtils.getMappingEntityTypeId(spell);
const dataOriginType = spellUtils.getDataOriginType(spell);
if (mappingId && mappingEntityTypeId && dataOriginType) {
dispatch(
characterActions.spellUseSet(
mappingId,
mappingEntityTypeId,
uses,
dataOriginType
)
);
}
};
const handleSpellDetailShow = (spell: Spell): void => {
const mappingId = spellUtils.getMappingId(spell);
if (mappingId !== null) {
paneHistoryPush(
PaneComponentEnum.CHARACTER_SPELL_DETAIL,
PaneIdentifierUtils.generateCharacterSpell(mappingId)
);
}
};
const handleClassFeatureShow = (
feature: ClassFeature,
charClass: CharClass
): void => {
paneHistoryPush(
PaneComponentEnum.CLASS_FEATURE_DETAIL,
PaneIdentifierUtils.generateClassFeature(
classFeatureUtils.getId(feature),
classUtils.getMappingId(charClass)
)
);
};
const handleActionShow = (action: Action): void => {
const mappingId = actionUtils.getMappingId(action);
const mappingEntityTypeId = actionUtils.getMappingEntityTypeId(action);
if (mappingId !== null && mappingEntityTypeId !== null) {
paneHistoryPush(
PaneComponentEnum.ACTION,
PaneIdentifierUtils.generateAction(mappingId, mappingEntityTypeId)
);
}
};
const handleFeatShow = (feat: FeatManager): void => {
paneHistoryPush(
PaneComponentEnum.FEAT_DETAIL,
PaneIdentifierUtils.generateFeat(feat.getId())
);
};
const isDataLoaded = (): boolean => {
if (hasSubclassChoice) {
if (subclassLoadingStatus !== DataLoadingStatusEnum.LOADED) {
return false;
}
}
if (hasFeatChoice) {
if (featLoadingStatus !== DataLoadingStatusEnum.LOADED) {
return false;
}
}
return true;
};
if (!charClass || !classFeature) {
return <PaneInitFailureContent />;
}
return (
<div
key={`${classUtils.getMappingId(charClass)}-${classFeatureUtils.getId(
classFeature
)}`}
{...props}
>
<Header
parent={classUtils.getName(charClass)}
preview={<Preview imageUrl={classUtils.getPortraitUrl(charClass)} />}
>
{classFeatureUtils.getName(classFeature)}
</Header>
<ClassFeatureSnippet
charClass={charClass}
feature={classFeature}
onActionUseSet={handleActionUseSet}
onActionClick={handleActionShow}
onSpellClick={handleSpellDetailShow}
onSpellUseSet={handleSpellUseSet}
onFeatureClick={handleClassFeatureShow}
onInfusionChoiceClick={handleInfusionChoiceShow}
showHeader={false}
showDescription={true}
feats={feats}
snippetData={snippetData}
ruleData={ruleData}
abilityLookup={abilityLookup}
dataOriginRefData={dataOriginRefData}
isReadonly={isReadonly}
proficiencyBonus={proficiencyBonus}
theme={theme}
onFeatClick={handleFeatShow}
featuresManager={characterFeaturesManager}
/>
{!isReadonly && featureChoices.length > 0 && (
<>
<Heading className={styles.heading}>{`Option${
featureChoices.length > 1 ? "s" : ""
}`}</Heading>
{isDataLoaded() ? (
featureChoices.map((choice) => (
<FeatureChoice
charClass={charClass}
choice={choice}
feature={classFeature}
featsData={featsData}
subclassData={subclassData}
className={styles.choice}
key={choiceUtils.getId(choice)}
onChoiceChange={handleChoiceChange}
collapseDescription={true}
/>
))
) : (
<LoadingPlaceholder />
)}
</>
)}
</div>
);
};
@@ -0,0 +1,606 @@
import clsx from "clsx";
import {
FC,
Fragment,
HTMLAttributes,
useCallback,
useEffect,
useState,
} from "react";
import { useDispatch, useSelector } from "react-redux";
import {
Collapsible,
CollapsibleHeaderCallout,
CollapsibleHeaderContent,
CreatureName,
InfusionPreview,
} from "@dndbeyond/character-components/es";
import {
characterActions,
Constants,
Creature,
CreatureUtils,
InfusionUtils,
serviceDataActions,
SourceUtils,
ValueUtils,
} from "@dndbeyond/character-rules-engine/es";
import { Button } from "~/components/Button";
import { EditableName } from "~/components/EditableName";
import { HtmlContent } from "~/components/HtmlContent";
import { Reference } from "~/components/Reference";
import { TagGroup } from "~/components/TagGroup";
import { useSidebar } from "~/contexts/Sidebar";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { CreatureBlock } from "~/subApps/sheet/components/CreatureBlock";
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
import { Heading } from "~/subApps/sheet/components/Sidebar/components/Heading";
import { Preview } from "~/subApps/sheet/components/Sidebar/components/Preview";
import {
PaneComponentEnum,
PaneIdentifiersCreature,
} from "~/subApps/sheet/components/Sidebar/types";
import CustomizeDataEditor from "../../../../../../tools/js/Shared/components/CustomizeDataEditor";
import EditorBox from "../../../../../../tools/js/Shared/components/EditorBox";
import ValueEditor from "../../../../../../tools/js/Shared/components/ValueEditor";
import { RemoveButton } from "../../../../../../tools/js/Shared/components/common/Button";
import { appEnvSelectors } from "../../../../../../tools/js/Shared/selectors";
import { PaneIdentifierUtils } from "../../../../../../tools/js/Shared/utils";
import { DeathSummary } from "../../../HitPointsBox/DeathSummary";
import { HitPointsSummary } from "../../../HitPointsBox/HitPointsSummary";
import { PaneInitFailureContent } from "../../components/PaneInitFailureContent";
import { QuickActions } from "../../components/QuickActions";
import { HitPointsAdjuster } from "../HitPointsManagePane/HitPointsAdjuster";
import styles from "./styles.module.css";
interface Props extends HTMLAttributes<HTMLDivElement> {
identifiers: PaneIdentifiersCreature | null;
}
export const CreaturePane: FC<Props> = ({ identifiers, ...props }) => {
const { pane } = useSidebar();
const {
ruleData,
creatures,
entityValueLookup,
snippetData,
infusionChoiceLookup,
proficiencyBonus,
characterTheme: theme,
hpInfo,
deathCause,
} = useCharacterEngine();
const isReadonly = useSelector(appEnvSelectors.getIsReadonly);
const dispatch = useDispatch();
const getCreature = useCallback((): Creature | null => {
return (
creatures.find(
(creature) => identifiers?.id === CreatureUtils.getMappingId(creature)
) ?? null
);
}, [creatures, identifiers]);
const [creature, setCreature] = useState<Creature | null>(getCreature());
const [isCustomizeClosed, setIsCustomizeClosed] = useState(true);
useEffect(() => {
const foundCreature = getCreature();
setCreature(foundCreature);
}, [getCreature]);
const getData = (): Record<string, any> => {
if (!creature) {
return {};
}
return {
name: CreatureUtils.getName(creature),
description: creature.description,
groupId: CreatureUtils.getGroupId(creature),
};
};
const handleToggleCustomize = () => {
setIsCustomizeClosed(!isCustomizeClosed);
};
const dispatchTempHp = (value: number): void => {
if (!creature) {
return;
}
const useOwnerHp = CreatureUtils.getUseOwnerHp(creature);
const id = CreatureUtils.getMappingId(creature);
const hitPointInfo = useOwnerHp
? hpInfo
: CreatureUtils.getHitPointInfo(creature);
dispatch(
useOwnerHp
? characterActions.hitPointsSet(hitPointInfo.removedHp, value)
: characterActions.creatureHitPointsSet(
id,
hitPointInfo.removedHp,
value
)
);
};
const handleQuickTempAction = (value: number): void => {
dispatchTempHp(value);
};
const handleSaveProperties = (properties: Record<string, any>): void => {
if (!creature) {
return;
}
const data = getData();
const prevData: Record<string, any> = data || {};
const newProperties: Record<string, any> = {
...prevData,
...properties,
};
dispatch(
characterActions.creatureDataSet(
CreatureUtils.getMappingId(creature),
newProperties as any
)
);
};
const handleDataUpdate = (data: Record<string, any>): void => {
if (!data.name) {
data.name = null;
}
handleSaveProperties(data);
};
const handleCustomDataUpdate = (
key: number,
value: string,
source: string | null
): void => {
if (!creature) {
return;
}
dispatch(
characterActions.valueSet(
key,
value,
source,
ValueUtils.hack__toString(CreatureUtils.getMappingId(creature)),
ValueUtils.hack__toString(
CreatureUtils.getMappingEntityTypeId(creature)
)
)
);
};
const handleRemoveCustomizations = (): void => {
if (creature) {
dispatch(
characterActions.creatureCustomizationsDelete(
CreatureUtils.getMappingId(creature),
CreatureUtils.getMappingEntityTypeId(creature)
)
);
}
};
const handleRemove = (): void => {
if (!creature) {
return;
}
pane.paneHistoryStart(PaneComponentEnum.EXTRA_MANAGE);
dispatch(
characterActions.creatureRemove(CreatureUtils.getMappingId(creature))
);
};
const handleRemoveInfusion = (): void => {
if (!creature) {
return;
}
const infusion = CreatureUtils.getInfusion(creature);
if (infusion) {
const infusionId = InfusionUtils.getId(infusion);
if (infusionId === null) {
return;
}
const choiceKey = InfusionUtils.getChoiceKey(infusion);
if (choiceKey !== null) {
pane.paneHistoryStart(
PaneComponentEnum.INFUSION_CHOICE,
PaneIdentifierUtils.generateInfusionChoice(choiceKey)
);
}
dispatch(
serviceDataActions.infusionMappingDestroy(
infusionId,
InfusionUtils.getInventoryMappingId(infusion)
)
);
}
};
const handleInfusionClick = (): void => {
if (!creature) {
return;
}
const infusion = CreatureUtils.getInfusion(creature);
if (infusion) {
const choiceKey = InfusionUtils.getChoiceKey(infusion);
if (choiceKey !== null) {
pane.paneHistoryPush(
PaneComponentEnum.INFUSION_CHOICE,
PaneIdentifierUtils.generateInfusionChoice(choiceKey)
);
}
}
};
const handleDeathSummaryClick = (evt: React.MouseEvent): void => {
if (!isReadonly) {
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
pane.paneHistoryPush(PaneComponentEnum.HEALTH_MANAGE);
}
};
const renderHealthAdjuster = (): React.ReactNode => {
if (!creature) {
return null;
}
const creatureHpInfo = CreatureUtils.getHitPointInfo(creature);
// Key to check if the creature has useOwnerHp set to true
const creatureGroupName = CreatureUtils.getGroupInfo(creature)?.name;
const useOwnerHp = CreatureUtils.getUseOwnerHp(creature);
const initialTempHp = CreatureUtils.getInitialTempHp(creature);
const hp = useOwnerHp ? hpInfo : creatureHpInfo;
const tempHp = hp.tempHp ?? 0;
const extraNode: React.ReactNode = (
<>
<span className={clsx([tempHp > 0 && styles.hasTempHp])}>
{hp.remainingHp + tempHp}
</span>
<span className={styles.valueSep}>/</span>
<span className={clsx([tempHp > 0 && styles.hasTempHp])}>
{hp.totalHp + tempHp}
</span>
</>
);
const headerCalloutNode: React.ReactNode = (
<CollapsibleHeaderCallout extra={extraNode} value={null} />
);
const headerNode: React.ReactNode = (
<CollapsibleHeaderContent
heading="Hit Points"
callout={headerCalloutNode}
/>
);
return (
<div className={clsx([styles.adjuster, styles.hasSeparator])}>
<Collapsible header={headerNode}>
{useOwnerHp &&
(deathCause === Constants.DeathCauseEnum.CONDITION ||
hpInfo.remainingHp <= 0) ? (
<DeathSummary
className={styles.deathSummary}
onClick={handleDeathSummaryClick}
/>
) : (
<div className={styles.hitPointsContainer}>
{creatureGroupName === Constants.DB_STRING_WILDSHAPE_2024 &&
useOwnerHp && (
<p className={clsx([styles.wildshapeInfo, styles.block])}>
Wild Shape modifies your character's HP. Your sheet will
reflect these changes.
</p>
)}
<HitPointsSummary
hpInfo={hp}
creature={useOwnerHp ? undefined : creature}
showPermanentInputs
/>
{!isReadonly && (
<HitPointsAdjuster
hpInfo={hp}
creature={useOwnerHp ? undefined : creature}
/>
)}
{creatureGroupName === Constants.DB_STRING_WILDSHAPE_2024 &&
initialTempHp !== 0 && (
<p className={styles.wildshapeInfo}>
When you assume a Wild Shape form, you gain{" "}
<b>{initialTempHp}</b> Temporary HP.
</p>
)}
{!isReadonly && renderQuickActions()}
</div>
)}
</Collapsible>
</div>
);
};
const renderQuickActions = (): React.ReactNode => {
if (!creature) {
return null;
}
// If initialTempHp exists and is not 0, render a button to add the initial temp hp to the creature.
const initialTempHp = CreatureUtils.getInitialTempHp(creature);
if (!initialTempHp) {
return null;
}
const actions = [] as {
label: string;
onClick: () => void;
disabled: boolean;
}[];
actions.push({
label: `Set Temp HP to ${initialTempHp}`,
onClick: () => handleQuickTempAction(initialTempHp),
disabled: false,
});
return <QuickActions actions={actions}></QuickActions>;
};
const renderDescription = (
label: React.ReactNode,
description: string | null
): React.ReactNode => {
if (!description) {
return null;
}
return (
<div className={styles.description}>
{label && <Heading>{label}</Heading>}
<HtmlContent html={description} withoutTooltips />
</div>
);
};
const renderTags = (): React.ReactNode => {
if (!creature) {
return null;
}
const tags = CreatureUtils.getTags(creature);
const envTags = CreatureUtils.getEnvironmentTags(creature);
return (
<>
{tags.length > 0 && <TagGroup label="Tags" tags={tags} />}
{envTags.length > 0 && <TagGroup label="Habitats" tags={envTags} />}
</>
);
};
const renderCustomize = (): React.ReactNode => {
if (isReadonly || !creature) {
return null;
}
const useOwnerHp = CreatureUtils.getUseOwnerHp(creature);
const optionRestrictions: Record<number, Array<number> | null> = {};
const groupInfo = CreatureUtils.getGroupInfo(creature);
if (groupInfo?.monsterTypes?.length) {
optionRestrictions[Constants.AdjustmentTypeEnum.CREATURE_TYPE_OVERRIDE] =
groupInfo.monsterTypes;
}
const isCustomized = CreatureUtils.isCustomized(creature);
const valueEditors = [
Constants.AdjustmentTypeEnum.CREATURE_SIZE,
Constants.AdjustmentTypeEnum.CREATURE_TYPE_OVERRIDE,
Constants.AdjustmentTypeEnum.CREATURE_ALIGNMENT,
Constants.AdjustmentTypeEnum.CREATURE_AC,
Constants.AdjustmentTypeEnum.CREATURE_HIT_POINTS,
Constants.AdjustmentTypeEnum.CREATURE_NOTES,
];
// Filter out hitpoints if the creature has useOwnerHp set to true
if (useOwnerHp) {
valueEditors.splice(
valueEditors.indexOf(Constants.AdjustmentTypeEnum.CREATURE_HIT_POINTS),
1
);
}
const labelOverrides = {
[Constants.AdjustmentTypeEnum.CREATURE_AC]: "Armor Class",
[Constants.AdjustmentTypeEnum.CREATURE_ALIGNMENT]: "Alignment",
[Constants.AdjustmentTypeEnum.CREATURE_HIT_POINTS]: "Max HP",
[Constants.AdjustmentTypeEnum.CREATURE_SIZE]: "Size",
[Constants.AdjustmentTypeEnum.CREATURE_TYPE_OVERRIDE]: "Type",
[Constants.AdjustmentTypeEnum.CREATURE_NOTES]: "Notes",
};
return (
<div className={styles.hasSeparator}>
<Collapsible
layoutType={"minimal"}
header={`Customize${isCustomized ? "*" : ""}`}
collapsed={isCustomizeClosed}
onChangeHandler={handleToggleCustomize}
>
<EditorBox className={styles.editor}>
<CustomizeDataEditor
data={getData()}
enableName={true}
onDataUpdate={handleDataUpdate}
/>
<ValueEditor
dataLookup={ValueUtils.getEntityData(
entityValueLookup,
ValueUtils.hack__toString(CreatureUtils.getMappingId(creature)),
ValueUtils.hack__toString(
CreatureUtils.getMappingEntityTypeId(creature)
)
)}
onDataUpdate={handleCustomDataUpdate}
valueEditors={valueEditors}
labelOverrides={labelOverrides}
layoutType={"standard"}
optionRestrictions={optionRestrictions}
ruleData={ruleData}
/>
<RemoveButton
enableConfirm={true}
size="medium"
style="filled"
disabled={!isCustomized}
isInteractive={isCustomized}
onClick={handleRemoveCustomizations}
>
{isCustomized ? "Remove" : "No"} Customizations
</RemoveButton>
</EditorBox>
</Collapsible>
</div>
);
};
const renderInfusionPreview = (): React.ReactNode => {
if (!creature) {
return null;
}
const infusion = CreatureUtils.getInfusion(creature);
if (infusion) {
return (
<div className={styles.hasSeparator}>
<InfusionPreview
infusion={infusion}
snippetData={snippetData}
ruleData={ruleData}
infusionChoiceLookup={infusionChoiceLookup}
onClick={handleInfusionClick}
proficiencyBonus={proficiencyBonus}
/>
</div>
);
}
return null;
};
const key: string = creature ? CreatureUtils.getUniqueKey(creature) : "";
const groupInfo = creature ? CreatureUtils.getGroupInfo(creature) : null;
const largeAvatarUrl = creature
? CreatureUtils.getLargeAvatarUrl(creature)
: null;
const infusion = creature ? CreatureUtils.getInfusion(creature) : null;
return (
<div key={key} {...props}>
{!creature ? (
<>
<Header>Missing Creature</Header>
<PaneInitFailureContent />
</>
) : (
<>
<Header
parent={groupInfo?.name || null}
preview={
<Preview imageUrl={CreatureUtils.getAvatarUrl(creature)} />
}
>
<EditableName onClick={handleToggleCustomize}>
<CreatureName theme={theme} creature={creature} />
</EditableName>
</Header>
{CreatureUtils.isHomebrew(creature) ? (
<Reference isDarkMode={theme.isDarkMode} name="Homebrew" />
) : (
SourceUtils.getSourceFullNames(
CreatureUtils.getSources(creature),
ruleData
).map((source, idx) => {
return (
<Fragment key={source}>
{idx > 0 ? " /" : ""}{" "}
<Reference isDarkMode={theme.isDarkMode} name={source} />
</Fragment>
);
})
)}
{renderCustomize()}
{renderHealthAdjuster()}
<div className={styles.block}>
<CreatureBlock
variant="default"
creature={creature}
ruleData={ruleData}
/>
</div>
{largeAvatarUrl && (
<img
className={styles.img}
src={largeAvatarUrl}
alt={CreatureUtils.getDefinitionName(creature)}
/>
)}
{renderTags()}
{renderDescription(
null,
CreatureUtils.getCharacteristicsDescription(creature)
)}
{renderDescription(
"Lair",
CreatureUtils.getLairDescription(creature)
)}
{renderInfusionPreview()}
{!isReadonly && (
<div className={clsx([styles.hasSeparator, styles.removeButton])}>
<Button
variant="outline"
size="xx-small"
themed
onClick={() => {
if (infusion) {
handleRemoveInfusion();
} else {
handleRemove();
}
}}
>
{infusion ? "Remove Infusion" : "Delete"}
</Button>
</div>
)}
</>
)}
</div>
);
};
@@ -0,0 +1,187 @@
import { FC, HTMLAttributes, useContext } from "react";
import { useDispatch, useSelector } from "react-redux";
import { Accordion } from "~/components/Accordion";
import { Button } from "~/components/Button";
import { HelperTextAccordion } from "~/components/HelperTextAccordion";
import { HtmlContent } from "~/components/HtmlContent";
import { useSidebar } from "~/contexts/Sidebar";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { FeatFeatureSnippet } from "~/tools/js/CharacterSheet/components/FeatureSnippet";
import { DetailChoiceFeat } from "~/tools/js/Shared/containers/DetailChoice";
import { CharacterFeaturesManagerContext } from "~/tools/js/Shared/managers/CharacterFeaturesManagerContext";
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
import { PaneIdentifierUtils } from "~/tools/js/Shared/utils";
import { DdbBadgeSvg } from "~/tools/js/smartComponents/Svg";
import { Action, Spell } from "~/types";
import { Header } from "../../components/Header";
import { PaneInitFailureContent } from "../../components/PaneInitFailureContent";
import { getDataOriginComponentInfo } from "../../helpers/paneUtils";
import { PaneComponentEnum, PaneIdentifiersFeat } from "../../types";
import styles from "./styles.module.css";
interface Props extends HTMLAttributes<HTMLDivElement> {
identifiers: PaneIdentifiersFeat | null;
}
export const FeatPane: FC<Props> = ({ identifiers, ...props }) => {
const dispatch = useDispatch();
const {
actionUtils,
characterActions,
spellUtils,
snippetData,
ruleData,
abilityLookup,
proficiencyBonus,
originRef: dataOriginRefData,
characterTheme: theme,
entityUtils,
} = useCharacterEngine();
const { characterFeaturesManager } = useContext(
CharacterFeaturesManagerContext
);
const {
pane: { paneHistoryPush, paneHistoryStart },
} = useSidebar();
const isReadonly = useSelector(appEnvSelectors.getIsReadonly);
const feat = identifiers?.id
? characterFeaturesManager.getFeatById(identifiers.id)
: null;
const handleActionUseSet = (action: Action, uses: number): void => {
const id = actionUtils.getId(action);
const entityTypeId = actionUtils.getEntityTypeId(action);
if (id !== null && entityTypeId !== null) {
dispatch(
characterActions.actionUseSet(
id,
entityTypeId,
uses,
actionUtils.getDataOriginType(action)
)
);
}
};
const handleSpellUseSet = (spell: Spell, uses: number): void => {
const mappingId = spellUtils.getMappingId(spell);
const mappingEntityTypeId = spellUtils.getMappingEntityTypeId(spell);
if (mappingId && mappingEntityTypeId) {
dispatch(
characterActions.spellUseSet(
mappingId,
mappingEntityTypeId,
uses,
spellUtils.getDataOriginType(spell)
)
);
}
};
const handleSpellDetailShow = (spell: Spell): void => {
const mappingId = spellUtils.getMappingId(spell);
if (mappingId !== null) {
paneHistoryPush(
PaneComponentEnum.CHARACTER_SPELL_DETAIL,
PaneIdentifierUtils.generateCharacterSpell(mappingId)
);
}
};
const handleActionShow = (action: Action): void => {
const mappingId = actionUtils.getMappingId(action);
const mappingEntityTypeId = actionUtils.getMappingEntityTypeId(action);
if (mappingId !== null && mappingEntityTypeId !== null) {
paneHistoryPush(
PaneComponentEnum.ACTION,
PaneIdentifierUtils.generateAction(mappingId, mappingEntityTypeId)
);
}
};
const handleParentClick = (): void => {
if (feat) {
let component = getDataOriginComponentInfo(feat.getDataOrigin());
if (component.type !== PaneComponentEnum.ERROR_404) {
paneHistoryPush(component.type, component.identifiers);
}
}
};
if (feat === null) {
return <PaneInitFailureContent />;
}
const prerequisiteDescription = feat.getPrerequisiteDescription();
return (
<div key={feat.getId()} {...props}>
<Header
parent={entityUtils.getDataOriginName(feat.getDataOrigin(), "", true)}
onClick={handleParentClick}
>
{feat.getName()}
</Header>
<HelperTextAccordion
builderHelperText={feat.getHelperText()}
size="small"
useTheme
/>
{prerequisiteDescription && (
<div className={styles.prereq}>
Prerequisite: {prerequisiteDescription}
</div>
)}
<FeatFeatureSnippet
feat={feat}
onActionUseSet={handleActionUseSet}
onActionClick={handleActionShow}
onSpellUseSet={handleSpellUseSet}
onSpellClick={handleSpellDetailShow}
onFeatureClick={() =>
paneHistoryStart(
PaneComponentEnum.FEAT_DETAIL,
PaneIdentifierUtils.generateFeat(feat.getId())
)
}
showHeader={false}
showDescription={true}
snippetData={snippetData}
ruleData={ruleData}
abilityLookup={abilityLookup}
dataOriginRefData={dataOriginRefData}
isReadonly={isReadonly}
proficiencyBonus={proficiencyBonus}
theme={theme}
/>
{!isReadonly && feat.getChoices().length > 0 && (
<div className={styles.choices}>
<DetailChoiceFeat featId={feat.getId()} />
</div>
)}
{!feat.isHiddenFeat() &&
!isReadonly && ( // don't show "Delete" if readonly mode, or a hidden feat
<div className={styles.footer}>
<Button
variant="outline"
size="xx-small"
themed
onClick={() => {
paneHistoryStart(PaneComponentEnum.FEATS_MANAGE);
feat.handleRemove();
}}
>
Delete
</Button>
</div>
)}
</div>
);
};
@@ -0,0 +1,105 @@
import { FC, useContext, useEffect, useState } from "react";
import { FeatureManager } from "@dndbeyond/character-rules-engine";
import { ThemeButton } from "~/tools/js/Shared/components/common/Button";
import DataLoadingStatusEnum from "~/tools/js/Shared/constants/DataLoadingStatusEnum";
import { CharacterFeaturesManagerContext } from "~/tools/js/Shared/managers/CharacterFeaturesManagerContext";
import { AppLoggerUtils, AppNotificationUtils } from "~/tools/js/Shared/utils";
import Collapsible, {
CollapsibleHeaderContent,
} from "~/tools/js/smartComponents/Collapsible";
export const BlessingShoppe: FC<{}> = () => {
const { characterFeaturesManager } = useContext(
CharacterFeaturesManagerContext
);
const [loadingStatus, setLoadingStatus] = useState<DataLoadingStatusEnum>(
DataLoadingStatusEnum.NOT_INITIALIZED
);
const [blessingShoppe, setBlessingShoppe] = useState<Array<FeatureManager>>(
[]
);
const [blessings, setBlessings] = useState<Array<FeatureManager>>([]);
async function fetchBlessings() {
const blessings = await characterFeaturesManager.getBlessings();
setBlessings(blessings);
}
useEffect(() => {
if (loadingStatus !== DataLoadingStatusEnum.LOADED) {
characterFeaturesManager
.getBlessingShoppe()
.then((blessingShoppe) => {
setLoadingStatus(DataLoadingStatusEnum.LOADED);
setBlessingShoppe(blessingShoppe);
})
.catch(AppLoggerUtils.handleAdhocApiError);
}
fetchBlessings();
}, [loadingStatus, characterFeaturesManager]);
return (
<div>
<p>
A blessing is usually bestowed by a god or a godlike being. A character
might receive a blessing from a deity for doing something truly
momentous an accomplishment that catches the attention of both gods
and mortals.
</p>
<div>
{blessingShoppe.map((blessing) => {
return (
<Collapsible
key={blessing.getId()}
layoutType={"minimal"}
header={
<CollapsibleHeaderContent
heading={blessing.getName()}
callout={
characterFeaturesManager.hasBlessing(blessing) ? (
<ThemeButton
onClick={() => {
blessing.handleRemove(() => {
fetchBlessings();
// AppNotificationUtils.dispatchSuccess(
// 'Removed Blessing',
// 'You removed a blessing',
// );
});
}}
size="small"
>
Remove
</ThemeButton>
) : (
<ThemeButton
onClick={() => {
blessing.handleAdd(() => {
fetchBlessings();
AppNotificationUtils.dispatchSuccess(
"Added Blessing",
"You added a blessing"
);
});
}}
size="small"
style="outline"
>
Add
</ThemeButton>
)
}
/>
}
>
{blessing.getDescription()}
</Collapsible>
);
})}
</div>
</div>
);
};
@@ -0,0 +1,130 @@
import clsx from "clsx";
import { FC, ReactNode } from "react";
import { FeatManager } from "@dndbeyond/character-rules-engine/es";
import { Reference } from "~/components/Reference";
import { DataOriginTypeEnum } from "~/constants";
import { useCharacterTheme } from "~/contexts/CharacterTheme";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import Collapsible, {
CollapsibleHeaderContent,
} from "~/tools/js/smartComponents/Collapsible";
import { TodoIcon } from "~/tools/js/smartComponents/Icons";
import PrerequisiteFailureSummary from "~/tools/js/smartComponents/PrerequisiteFailureSummary";
import { CharacterTheme } from "~/types";
import {
RemoveButton,
ThemeButton,
} from "../../../../../../../tools/js/Shared/components/common/Button";
import { FeatDetail } from "../FeatDetail";
import styles from "./styles.module.css";
interface Props {
feat: FeatManager;
enableRemove?: boolean;
enableAdd?: boolean;
showFailures?: boolean;
theme: CharacterTheme;
}
export const Feat: FC<Props> = ({
feat,
enableAdd = true,
enableRemove = true,
showFailures = false,
theme,
}) => {
const { entityUtils } = useCharacterEngine();
const { isDarkMode } = useCharacterTheme();
let missingChoiceCount: number = feat.getUnfinishedChoices().length;
let calloutNode: ReactNode = null;
let dataOrigin = feat.getDataOrigin();
let dataOriginType = feat.getDataOriginType();
switch (dataOriginType) {
case DataOriginTypeEnum.ADHOC:
if (enableRemove) {
calloutNode = (
<RemoveButton
onClick={feat.handleRemove}
style="filled"
className={styles.button}
/>
);
}
break;
case DataOriginTypeEnum.SIMULATED:
if (enableAdd) {
calloutNode = (
<ThemeButton
onClick={feat.handleAdd}
size="small"
style="outline"
className={styles.button}
>
Add
</ThemeButton>
);
}
break;
default:
const dataOriginExtra = entityUtils.getDataOriginName(
dataOrigin,
"Unknown",
true
);
calloutNode = (
<span className={styles.origin}>
<span className={styles.originLabel}>From</span>
<span className={styles.originName}>{dataOriginExtra}</span>
</span>
);
}
return (
<Collapsible
layoutType={"minimal"}
className={styles.feat}
header={
<CollapsibleHeaderContent
heading={
<div>
<div>
<span>{feat.getName()}</span>
{missingChoiceCount > 0 && (
<TodoIcon
theme={theme}
title={`Choice${
missingChoiceCount !== 1 ? "s" : ""
} Needed`}
/>
)}
</div>
<span className={styles.source}>
<Reference name={feat.getPrimarySourceName()} />
</span>
{showFailures && (
<PrerequisiteFailureSummary
failures={feat.getPrerequisiteFailures()}
className={styles.failures}
/>
)}
</div>
}
callout={calloutNode}
/>
}
>
<FeatDetail
featManager={feat}
className={clsx([styles.detail, isDarkMode && styles.dark])}
/>
</Collapsible>
);
};
@@ -0,0 +1,35 @@
import { FC, HTMLAttributes } from "react";
import { FeatManager } from "@dndbeyond/character-rules-engine/es";
import { HtmlContent } from "~/components/HtmlContent";
import { DetailChoiceFeat } from "~/tools/js/Shared/containers/DetailChoice";
import styles from "./styles.module.css";
interface Props extends HTMLAttributes<HTMLDivElement> {
featManager: FeatManager;
}
export const FeatDetail: FC<Props> = ({ featManager, className, ...props }) => {
const prerequisiteDescription = featManager.getPrerequisiteDescription();
const featDescription = featManager.getDescription();
return (
<div className={className} {...props}>
{prerequisiteDescription && (
<div className={styles.prereq}>
Prerequisite: {prerequisiteDescription}
</div>
)}
{featDescription && (
<HtmlContent html={featDescription} withoutTooltips />
)}
{featManager.getChoices().length > 0 && (
<div className={styles.choices}>
<DetailChoiceFeat featId={featManager.getId()} />
</div>
)}
</div>
);
};
@@ -0,0 +1,131 @@
import { FC, useContext, useEffect, useState } from "react";
import { FeatManager } from "@dndbeyond/character-rules-engine";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { useSource } from "~/hooks/useSource";
import DataLoadingStatusEnum from "~/tools/js/Shared/constants/DataLoadingStatusEnum";
import { CharacterFeaturesManagerContext } from "~/tools/js/Shared/managers/CharacterFeaturesManagerContext";
import { AppLoggerUtils } from "~/tools/js/Shared/utils";
import { isNotNullOrUndefined } from "~/tools/js/Shared/utils/TypeScript";
import Collapsible, {
CollapsibleHeaderContent,
CollapsibleHeading,
} from "~/tools/js/smartComponents/Collapsible";
import LoadingPlaceholder from "~/tools/js/smartComponents/LoadingPlaceholder";
import { Feat } from "../Feat/Feat";
import styles from "../styles.module.css";
export const FeatShoppe: FC<{}> = () => {
const { characterFeaturesManager } = useContext(
CharacterFeaturesManagerContext
);
const {
feats: currentCharacterFeats,
characterTheme: theme,
preferences,
} = useCharacterEngine();
const { getSourceCategoryGroups } = useSource();
const [loadingStatus, setLoadingStatus] = useState<DataLoadingStatusEnum>(
DataLoadingStatusEnum.NOT_INITIALIZED
);
const [featShoppe, setFeatShoppe] = useState<Array<FeatManager>>([]);
useEffect(() => {
if (loadingStatus !== DataLoadingStatusEnum.LOADED) {
characterFeaturesManager
.getFeatShoppe()
.then((featShoppe) => {
setLoadingStatus(DataLoadingStatusEnum.LOADED);
setFeatShoppe(featShoppe);
})
.catch(AppLoggerUtils.handleAdhocApiError);
} else {
setFeatShoppe(characterFeaturesManager.updateFeatShoppe(featShoppe));
}
}, [currentCharacterFeats]);
const getFeatsInSourceCategoryGroups = (feats: FeatManager[]) => {
const featDefs = feats
.map((feat) => feat.getDefinition())
.filter(isNotNullOrUndefined);
return getSourceCategoryGroups(featDefs).map((group) => {
return {
...group,
items: characterFeaturesManager.transformLoadedFeats(group.items),
};
});
};
return loadingStatus !== DataLoadingStatusEnum.LOADED ? (
<LoadingPlaceholder />
) : (
<>
<div className={styles.featList}>
{getFeatsInSourceCategoryGroups(
characterFeaturesManager.getAvailableFeats(featShoppe)
).map((group) => (
<Collapsible
key={group.id + "available"}
layoutType="minimal"
initiallyCollapsed={false}
header={<h2>{group.name}</h2>}
>
<div>
{group.items.map((feat) => (
<Feat theme={theme} key={feat.getId()} feat={feat} />
))}
</div>
</Collapsible>
))}
</div>
{characterFeaturesManager.getUnavailableFeats(featShoppe).length > 0 && (
<Collapsible
header={
<CollapsibleHeaderContent
heading={
<CollapsibleHeading>
Unavailable Feats
<span className={styles.unavailableCallout}>
Prerequisites Not Met
</span>
</CollapsibleHeading>
}
/>
}
className={styles.featList}
>
<>
{getFeatsInSourceCategoryGroups(
characterFeaturesManager.getUnavailableFeats(featShoppe)
).map((group) => (
<Collapsible
key={group.id + "unavailable"}
layoutType="minimal"
initiallyCollapsed={false}
header={<h2>{group.name}</h2>}
>
<div>
{group.items.map((feat) => (
<Feat
theme={theme}
key={feat.getId()}
feat={feat}
enableAdd={!preferences?.enforceFeatRules}
showFailures={true}
/>
))}
</div>
</Collapsible>
))}
</>
</Collapsible>
)}
</>
);
};
@@ -0,0 +1,66 @@
import { FC } from "react";
import { useSelector } from "react-redux";
import { useFeatureFlags } from "~/contexts/FeatureFlag";
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
import SettingsButton from "~/tools/js/CharacterSheet/components/SettingsButton";
import { SettingsContextsEnum } from "~/tools/js/Shared/containers/panes/SettingsPane/typings";
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
import Collapsible, {
CollapsibleHeaderContent,
CollapsibleHeading,
} from "~/tools/js/smartComponents/Collapsible";
import { BlessingShoppe } from "./BlessingShoppe";
import { FeatShoppe } from "./FeatShoppe";
import styles from "./styles.module.css";
export const FeatsManagePane: FC<{}> = () => {
const { gfsBlessingsUiFlag } = useFeatureFlags();
const isReadonly = useSelector(appEnvSelectors.getIsReadonly);
if (isReadonly) {
return null;
}
return (
<div>
<Header
callout={
<SettingsButton
context={SettingsContextsEnum.FEATURES}
isReadonly={isReadonly}
/>
}
>
Manage {gfsBlessingsUiFlag ? "Features" : "Feats"}
</Header>
{gfsBlessingsUiFlag ? (
<Collapsible
header={
<CollapsibleHeaderContent
heading={<CollapsibleHeading>Add Feats</CollapsibleHeading>}
/>
}
className={styles.featList}
>
<FeatShoppe />
</Collapsible>
) : (
<FeatShoppe />
)}
{gfsBlessingsUiFlag && (
<Collapsible
header={
<CollapsibleHeaderContent
heading={<CollapsibleHeading>Add Blessings</CollapsibleHeading>}
/>
}
className={styles.featList}
>
<BlessingShoppe />
</Collapsible>
)}
</div>
);
};
@@ -0,0 +1,61 @@
import clsx from "clsx";
import { useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import { Dice } from "@dndbeyond/dice";
import { GameLog } from "@dndbeyond/game-log-components";
import { appEnvActions } from "~/tools/js/Shared/actions/appEnv";
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
import styles from "./styles.module.css";
export const GameLogPane = () => {
const dispatch = useDispatch();
const gameLog = useSelector(appEnvSelectors.getGameLog);
const characterId = useSelector(appEnvSelectors.getCharacterId);
useEffect(
() => {
const updateOpenState = (isOpen: boolean) => {
const lastMessageTime = new Date().getTime();
dispatch(
appEnvActions.dataSet({
gameLog: {
...gameLog,
isOpen: isOpen,
lastMessageTime: lastMessageTime,
},
})
);
try {
localStorage.setItem(
`gameLog-lastMessageTime-${characterId}`,
lastMessageTime.toString()
);
} catch (e) {}
// turn the dice notifications off if the panel is open
Dice.setDiceNotificationEnabled(!isOpen);
};
updateOpenState(true);
return () => {
updateOpenState(false);
};
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
return (
<div className={clsx(styles.gameLogPane)} data-testid="gamelog-pane">
{characterId && <GameLog />}
</div>
);
};
export default GameLogPane;
@@ -0,0 +1,220 @@
import clsx from "clsx";
import { FC, HTMLAttributes, useContext } from "react";
import { useDispatch, useSelector } from "react-redux";
import {
DigitalDiceWrapper,
LightDiceSvg,
} from "@dndbeyond/character-components/es";
import { RollKind, RollRequest, RollType } from "@dndbeyond/dice";
import { GameLogContext } from "@dndbeyond/game-log-components";
import { useSidebar } from "~/contexts/Sidebar";
import { isNotNullOrUndefined } from "~/helpers/validation";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { getDataOriginComponentInfo } from "~/subApps/sheet/components/Sidebar/helpers/paneUtils";
import { PaneComponentEnum } from "~/subApps/sheet/components/Sidebar/types";
import DiceAdjustmentSummary from "~/tools/js/Shared/components/DiceAdjustmentSummary";
import {
appEnvSelectors,
characterRollContextSelectors,
} from "~/tools/js/Shared/selectors";
import { DataOrigin } from "~/types";
import { Heading } from "../../../components/Heading";
import { DeathSavesMarks } from "./DeathSavesMarks";
import styles from "./styles.module.css";
interface Props extends HTMLAttributes<HTMLDivElement> {
damageValue: number;
}
export const DeathSavesManager: FC<Props> = ({
damageValue,
className,
...props
}) => {
const {
characterTheme,
deathSaveInfo,
ruleData,
characterActions,
ruleDataUtils,
} = useCharacterEngine();
const isDiceEnabled = useSelector(appEnvSelectors.getDiceEnabled);
const characterRollContext = useSelector(
characterRollContextSelectors.getCharacterRollContext
);
const [{ messageTargetOptions, defaultMessageTargetOption, userId }] =
useContext(GameLogContext);
const dispatch = useDispatch();
const {
pane: { paneHistoryPush },
} = useSidebar();
const oneHPRestoreType = ruleDataUtils
.getRestoreTypes(ruleData)
.find((r) => r.name === "OneHP");
const hasAdvantage = deathSaveInfo.advantageAdjustments.length > 0;
const hasDisadvantage = deathSaveInfo.disadvantageAdjustments.length > 0;
let rollKind = RollKind.None;
if (hasAdvantage && !hasDisadvantage) {
rollKind = RollKind.Advantage;
} else if (hasDisadvantage && !hasAdvantage) {
rollKind = RollKind.Disadvantage;
}
/* --- On Click Functions --- */
const onClickDataOrigin = (dataOrigin: DataOrigin): void => {
let component = getDataOriginComponentInfo(dataOrigin);
if (component.type !== PaneComponentEnum.ERROR_404) {
paneHistoryPush(component.type, component.identifiers);
}
};
const dispatchDeathSaves = (fails: number, successes: number) => {
dispatch(
characterActions.deathSavesSet(
Math.min(ruleDataUtils.getMaxDeathsavesFail(ruleData), fails),
Math.min(ruleDataUtils.getMaxDeathsavesSuccess(ruleData), successes)
)
);
};
const onFail = (evt?: React.MouseEvent, isCritical = false) => {
const uses = isCritical ? 2 : 1;
if (evt) {
evt.nativeEvent.stopImmediatePropagation();
evt.stopPropagation();
}
dispatchDeathSaves(
deathSaveInfo.failCount + uses,
deathSaveInfo.successCount
);
};
const onFailClear = (evt?: React.MouseEvent) => {
if (evt) {
evt.nativeEvent.stopImmediatePropagation();
evt.stopPropagation();
}
dispatchDeathSaves(deathSaveInfo.failCount - 1, deathSaveInfo.successCount);
};
const onSuccess = (evt?: React.MouseEvent) => {
if (evt) {
evt.nativeEvent.stopImmediatePropagation();
evt.stopPropagation();
}
dispatchDeathSaves(deathSaveInfo.failCount, deathSaveInfo.successCount + 1);
};
const onSuccessClear = (evt?: React.MouseEvent) => {
if (evt) {
evt.nativeEvent.stopImmediatePropagation();
evt.stopPropagation();
}
dispatchDeathSaves(deathSaveInfo.failCount, deathSaveInfo.successCount - 1);
};
return (
<div className={clsx([styles.column, className])} {...props}>
<div className={clsx([styles.row, styles.heading])}>
<Heading>Death Saves</Heading>
{isDiceEnabled && (
<DigitalDiceWrapper
diceNotation={"1d20"}
onRollResults={(rollRequest: RollRequest) => {
const roll = rollRequest.rolls[0].result?.total;
if (roll) {
if (roll === 1) {
onFail(undefined, true);
} else if (roll === 20 && oneHPRestoreType) {
dispatch(characterActions.restoreLife(oneHPRestoreType.id));
} else if (roll >= 10) {
onSuccess.bind(this)();
} else {
onFail.bind(this)();
}
}
}}
rollType={RollType.Save}
rollKind={rollKind}
rollAction={"Death"}
diceEnabled={isDiceEnabled}
rollContext={characterRollContext}
rollTargetOptions={
messageTargetOptions?.entities
? Object.values(messageTargetOptions.entities).filter(
isNotNullOrUndefined
)
: undefined
}
rollTargetDefault={defaultMessageTargetOption}
userId={Number(userId)}
>
<LightDiceSvg />
<span>Roll</span>
</DigitalDiceWrapper>
)}
</div>
<div className={clsx([styles.row, styles.deathSavesGroups])}>
<DeathSavesMarks
label="Failures"
type="fails"
activeCount={deathSaveInfo.failCount}
willBeActiveCount={damageValue !== 0 ? 1 : 0}
totalCount={ruleData.maxDeathsavesFail}
onUse={onFail}
onClear={onFailClear}
/>
<DeathSavesMarks
label="Successes"
type="successes"
activeCount={deathSaveInfo.successCount}
totalCount={ruleData.maxDeathsavesSuccess}
onUse={onSuccess}
onClear={onSuccessClear}
/>
</div>
{(!!deathSaveInfo.advantageAdjustments.length ||
!!deathSaveInfo.disadvantageAdjustments.length) && (
<div className={styles.diceAdjustments}>
{deathSaveInfo.advantageAdjustments.map((diceAdjustment) => {
return (
<DiceAdjustmentSummary
key={diceAdjustment.uniqueKey}
diceAdjustment={diceAdjustment}
ruleData={ruleData}
theme={characterTheme}
onDataOriginClick={onClickDataOrigin}
/>
);
})}
{deathSaveInfo.disadvantageAdjustments.map((diceAdjustment) => {
return (
<DiceAdjustmentSummary
key={diceAdjustment.uniqueKey}
diceAdjustment={diceAdjustment}
ruleData={ruleData}
theme={characterTheme}
onDataOriginClick={onClickDataOrigin}
/>
);
})}
</div>
)}
</div>
);
};
@@ -0,0 +1,88 @@
import clsx from "clsx";
import { HTMLAttributes, FC } from "react";
import { CloseSvg } from "@dndbeyond/character-components/es";
import styles from "./styles.module.css";
interface Props extends HTMLAttributes<HTMLDivElement> {
type: "successes" | "fails";
label: string;
activeCount: number;
totalCount: number;
willBeActiveCount?: number;
onUse: (evt: React.MouseEvent) => void;
onClear: (evt: React.MouseEvent) => void;
}
export const DeathSavesMarks: FC<Props> = ({
type,
label,
activeCount,
totalCount,
willBeActiveCount = 0,
onUse,
onClear,
className,
...props
}) => {
// TODO: Need to make these marks accessible - they currently cannot be interacted with with a keyboard.
let marks: Array<React.ReactNode> = [];
let availableSlots: number = totalCount;
for (let i = 0; i < activeCount; i++) {
marks.push(
<span
className={styles.mark}
key={`${type}-active-${i}`}
onClick={onClear}
>
<CloseSvg
fillColor={type === "successes" ? "#00c797" : "#c53131"}
secondaryFillColor=""
key={`${type}-active-${i}`}
className={clsx([styles.mark, styles.activeMark])}
/>
</span>
);
}
availableSlots -= activeCount;
for (let i = 0; i < Math.min(availableSlots, willBeActiveCount); i++) {
marks.push(
<span
key={`${type}-willbe-${i}`}
className={clsx([
styles.mark,
styles.willBeActive,
type === "fails" && styles.willBeFail,
])}
/>
);
}
availableSlots -= willBeActiveCount;
for (let i = 0; i < Math.min(availableSlots, totalCount); i++) {
marks.push(
<span
key={`${type}-inactive-${i}`}
className={clsx([styles.mark])}
onClick={onUse}
/>
);
}
return (
<div
className={clsx([styles.container, className])}
data-testid={`${type}-group`}
{...props}
>
<p className={styles.label}>{label}</p>
<div className={styles.marks}>{marks}</div>
</div>
);
};
@@ -0,0 +1,537 @@
import clsx from "clsx";
import { createRef, FC, HTMLAttributes, useEffect, useState } from "react";
import { useDispatch } from "react-redux";
import { DataOriginName } from "@dndbeyond/character-components/es";
import { Constants, ItemManager } from "@dndbeyond/character-rules-engine";
import { Button } from "~/components/Button";
import { Checkbox } from "~/components/Checkbox";
import { ItemName } from "~/components/ItemName";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { HP_DAMAGE_TAKEN_VALUE } from "~/subApps/sheet/constants";
import { Item, HitPointInfo, Creature } from "~/types";
import styles from "./styles.module.css";
interface Props extends HTMLAttributes<HTMLDivElement> {
hpInfo: HitPointInfo;
creature?: Creature;
handleDamageUpdate?: (damageAmount: number) => void;
vibrationAmount?: number;
}
// TODO This hitpoints adjuster doesn't work as a generic component for vehicles yet.
export const HitPointsAdjuster: FC<Props> = ({
hpInfo,
creature,
handleDamageUpdate,
vibrationAmount = 15,
className,
...props
}) => {
const {
characterTheme,
protectionSuppliers,
deathSaveInfo,
ruleData,
characterActions,
actionUtils,
characterUtils,
helperUtils,
itemUtils,
limitedUseUtils,
modifierUtils,
ruleDataUtils,
creatureUtils,
} = useCharacterEngine();
const getInitialActiveProtectionSupplierKey = (): string | null => {
const foundSupplier = protectionSuppliers.find(
(supplier) =>
supplier.availabilityStatus ===
Constants.ProtectionAvailabilityStatusEnum.AVAILABLE
);
if (foundSupplier) {
return foundSupplier.key;
}
return null;
};
const [tickCount, setTickCount] = useState(0);
const [healingAmount, setHealingAmount] = useState<number | null>(null);
const [damageAmount, setDamageAmount] = useState<number | null>(null);
const [hpDifference, setHpDifference] = useState(
characterUtils.calculateHitPoints(hpInfo, 0)
);
const [isValueChanged, setIsValueChanged] = useState<boolean>(false);
const [activeProtectionSupplierKey, setActiveProtectionSupplierKey] =
useState<string | null>(getInitialActiveProtectionSupplierKey());
const damageInput = createRef<HTMLInputElement>();
const healingInput = createRef<HTMLInputElement>();
const dispatch = useDispatch();
/* --- Helper Functions --- */
const reset = (newTemp: number | null): void => {
if (hpInfo.tempHp !== newTemp && newTemp !== null) {
creature
? dispatch(
characterActions.creatureHitPointsSet(
creatureUtils.getMappingId(creature),
hpInfo.removedHp,
newTemp
)
)
: dispatch(characterActions.hitPointsSet(hpInfo.removedHp, newTemp));
}
setTickCount(0);
setHealingAmount(0);
setDamageAmount(0);
setIsValueChanged(false);
setActiveProtectionSupplierKey(getInitialActiveProtectionSupplierKey());
};
const onKeyUpSave = (evt: React.KeyboardEvent<HTMLInputElement>): void => {
if (evt.key === "Enter") {
onClickSave();
}
};
// onKeyDown must be used for the Escape key to reset the adjuster because the
// Sidebar uses onKeyDown to close when the Escape key is pressed.
const onKeyDownCancel = (
evt: React.KeyboardEvent<HTMLInputElement>
): void => {
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
if (evt.key === "Escape") {
reset(hpInfo.tempHp);
}
};
/* --- On Input Change Functions --- */
const onChangeHealing = (evt: React.ChangeEvent<HTMLInputElement>): void => {
setTickCount(0);
setHealingAmount(
helperUtils.clampInt(
helperUtils.parseInputInt(evt.target.value, HP_DAMAGE_TAKEN_VALUE.MIN),
HP_DAMAGE_TAKEN_VALUE.MIN,
HP_DAMAGE_TAKEN_VALUE.MAX
)
);
setDamageAmount(0);
setIsValueChanged(true);
};
const onChangeDamage = (evt: React.ChangeEvent<HTMLInputElement>): void => {
setTickCount(0);
setHealingAmount(0);
setDamageAmount(
helperUtils.clampInt(
helperUtils.parseInputInt(evt.target.value, HP_DAMAGE_TAKEN_VALUE.MIN),
HP_DAMAGE_TAKEN_VALUE.MIN,
HP_DAMAGE_TAKEN_VALUE.MAX
) * -1
);
setIsValueChanged(true);
};
/* --- On Click Functions --- */
const onClickIncrease = (): void => {
setHealingAmount(
healingAmount !== null && damageAmount === 0 ? healingAmount + 1 : 0
);
setDamageAmount(
damageAmount !== null && damageAmount < 0 ? damageAmount + 1 : 0
);
setIsValueChanged(true);
if (navigator.vibrate) {
navigator.vibrate(vibrationAmount);
}
};
const onClickDecrease = (): void => {
setHealingAmount(
healingAmount !== null && healingAmount > 0 ? healingAmount - 1 : 0
);
setDamageAmount(
damageAmount !== null && healingAmount === 0 ? damageAmount - 1 : 0
);
setIsValueChanged(true);
if (navigator.vibrate) {
navigator.vibrate(vibrationAmount);
}
};
const onClickSave = (): void => {
const { newTemp, startHp, newHp } = hpDifference;
let protectedHpResult: number | null = null;
if (!creature) {
if (
startHp !== 0 &&
newHp === 0 &&
activeProtectionSupplierKey !== null
) {
let foundActiveSupplier = protectionSuppliers.find(
(supplier) => supplier.key === activeProtectionSupplierKey
);
if (foundActiveSupplier) {
protectedHpResult = foundActiveSupplier.setHpValue;
switch (foundActiveSupplier.type) {
case Constants.ProtectionSupplierTypeEnum.ITEM: {
const itemData = foundActiveSupplier.data as Item;
const item = ItemManager.getItem(
itemUtils.getMappingId(itemData)
);
const numberUsed = item.getNumberUsed();
if (numberUsed !== null) {
item.handleItemLimitedUseSet(numberUsed + 1);
}
break;
}
case Constants.ProtectionSupplierTypeEnum.RACIAL_TRAIT:
case Constants.ProtectionSupplierTypeEnum.CLASS_FEATURE:
case Constants.ProtectionSupplierTypeEnum.FEAT: {
let action = foundActiveSupplier.action;
if (action !== null) {
let limitedUse = actionUtils.getLimitedUse(action);
const id = actionUtils.getId(action);
const entityTypeId = actionUtils.getEntityTypeId(action);
const dataOriginType = actionUtils.getDataOriginType(action);
if (
limitedUse !== null &&
id !== null &&
entityTypeId !== null
) {
const numberUsed = limitedUseUtils.getNumberUsed(limitedUse);
dispatch(
characterActions.actionUseSet(
id,
entityTypeId,
numberUsed + 1,
dataOriginType
)
);
}
}
break;
}
default:
// not implemented
}
}
}
}
let removedHp: number = 0;
if (protectedHpResult === null) {
removedHp = hpInfo.totalHp - newHp;
} else {
removedHp = hpInfo.totalHp - protectedHpResult;
}
creature
? dispatch(
characterActions.creatureHitPointsSet(
creatureUtils.getMappingId(creature),
removedHp,
newTemp
)
)
: dispatch(characterActions.hitPointsSet(removedHp, newTemp));
const damage: number =
damageAmount === null
? 0
: Math.min(
tickCount +
(healingAmount === null ? 0 : healingAmount) +
damageAmount,
0
);
if (startHp === 0 && !creature) {
if (newHp > 0) {
dispatch(characterActions.deathSavesSet(0, 0));
} else if (damage !== 0 && protectedHpResult === null) {
dispatch(
characterActions.deathSavesSet(
Math.min(
ruleDataUtils.getMaxDeathsavesFail(ruleData),
deathSaveInfo.failCount + 1
),
Math.min(
ruleDataUtils.getMaxDeathsavesSuccess(ruleData),
deathSaveInfo.successCount
)
)
);
reset(hpInfo.tempHp);
}
}
if (startHp === newHp) {
reset(newTemp);
}
};
const onClickCancel = (): void => {
reset(hpInfo.tempHp);
};
/* --- useEffects --- */
useEffect(() => {
const difference: number =
(healingAmount === null ? 0 : healingAmount) +
(damageAmount === null ? 0 : damageAmount) +
tickCount;
setHpDifference(characterUtils.calculateHitPoints(hpInfo, difference));
}, [healingAmount, damageAmount, tickCount, hpInfo]);
useEffect(() => {
reset(hpInfo.tempHp);
}, [hpInfo, protectionSuppliers]);
useEffect(() => {
if (handleDamageUpdate) {
const damageValue: number =
damageAmount === null
? 0
: Math.min(
tickCount +
(healingAmount === null ? 0 : healingAmount) +
damageAmount,
0
);
handleDamageUpdate(damageValue);
}
}, [tickCount, healingAmount, damageAmount]);
/* --- Render Functions --- */
const renderProtectionInfo = (): React.ReactNode => {
const { startHp, newHp } = hpDifference;
if (
!isValueChanged ||
startHp === 0 ||
newHp !== 0 ||
protectionSuppliers.length === 0
) {
return null;
}
return (
<>
{protectionSuppliers.map((protectionSupplier) => {
if (
protectionSupplier.availabilityStatus !==
Constants.ProtectionAvailabilityStatusEnum.AVAILABLE
) {
return null;
}
let dataOrigin = modifierUtils.getDataOrigin(
protectionSupplier.modifier
);
let isEnabled =
activeProtectionSupplierKey !== null &&
protectionSupplier.key === activeProtectionSupplierKey;
//TODO remove if check and ItemName => use DataOriginName when EntityDataOriginLookup is available to be passed as a prop to DataOriginName
let nameNode: React.ReactNode = null;
if (
protectionSupplier.type ===
Constants.ProtectionSupplierTypeEnum.ITEM
) {
nameNode = <ItemName item={protectionSupplier.data as Item} />;
} else {
nameNode = (
<DataOriginName dataOrigin={dataOrigin} theme={characterTheme} />
);
}
return (
<div
className={clsx([styles.row, styles.protectionNotice])}
key={protectionSupplier.key}
>
<Checkbox
checked={isEnabled}
themed
darkMode={characterTheme.isDarkMode}
id={`${protectionSupplier.key}-checkbox`}
onClick={() => {
isEnabled = !isEnabled;
setActiveProtectionSupplierKey(
isEnabled ? protectionSupplier.key : null
);
}}
/>
<label htmlFor={`${protectionSupplier.key}-checkbox`}>
Instead of dropping to 0 hit points, use{" "}
<strong>{nameNode}</strong> to set hit points to{" "}
<strong>{protectionSupplier.setHpValue}</strong>
</label>
</div>
);
})}
</>
);
};
return (
<div
className={clsx([styles.column, styles.container, className])}
{...props}
>
<div className={clsx([styles.row, styles.container])}>
<div className={clsx([styles.column, styles.inputsContainer])}>
<div
className={clsx([styles.inputContainer, styles.healingContainer])}
>
<label
htmlFor="healing-input"
className={clsx([styles.inputLabel, styles.positiveColor])}
>
Healing
</label>
<input
ref={healingInput}
type="number"
id="healing-input"
className={styles.input}
value={
healingAmount === null
? ""
: Math.max(
tickCount +
healingAmount +
(damageAmount === null ? 0 : damageAmount),
0
)
}
min={HP_DAMAGE_TAKEN_VALUE.MIN}
max={HP_DAMAGE_TAKEN_VALUE.MAX}
onChange={onChangeHealing}
onKeyUp={onKeyUpSave}
onKeyDown={onKeyDownCancel}
/>
</div>
<div className={clsx([styles.row, styles.newValues])}>
<div
className={clsx([
styles.column,
hpDifference.newHp > hpDifference.startHp &&
styles.positiveColor,
hpDifference.newHp < hpDifference.startHp &&
styles.negativeColor,
])}
>
<span className={styles.label}>New HP</span>
<span className={styles.value} data-testid="new-hp">
{hpDifference.newHp}
</span>
</div>
{hpInfo.tempHp !== null && hpInfo.tempHp > 0 && (
<div
className={clsx([
styles.column,
hpDifference.newTemp > hpInfo.tempHp && styles.positiveColor,
hpDifference.newTemp < hpInfo.tempHp && styles.negativeColor,
])}
>
<span className={styles.label}>New Temp</span>
<span className={styles.value} data-testid="new-temp-hp">
{hpDifference.newTemp}
</span>
</div>
)}
</div>
<div
className={clsx([styles.inputContainer, styles.damageContainer])}
>
<label
htmlFor="damage-input"
className={clsx([styles.inputLabel, styles.negativeColor])}
>
Damage
</label>
<input
ref={damageInput}
type="number"
id="damage-input"
className={styles.input}
value={
damageAmount === null
? ""
: Math.abs(
Math.min(
tickCount +
(healingAmount === null ? 0 : healingAmount) +
damageAmount,
0
)
)
}
min={HP_DAMAGE_TAKEN_VALUE.MIN}
max={HP_DAMAGE_TAKEN_VALUE.MAX}
onChange={onChangeDamage}
onKeyUp={onKeyUpSave}
onKeyDown={onKeyDownCancel}
/>
</div>
</div>
<div className={clsx([styles.column, styles.modifyButtons])}>
<Button
onClick={onClickIncrease}
themed
size="xx-small"
className={styles.increase}
aria-label="Increase Hit Points"
/>
<Button
onClick={onClickDecrease}
themed
size="xx-small"
className={styles.decrease}
aria-label="Decrease Hit Points"
/>
</div>
</div>
{!creature && renderProtectionInfo()}
{isValueChanged && (
<div className={clsx([styles.row, styles.applyButtons])}>
<Button onClick={onClickSave} size="xx-small" themed>
Apply Changes
</Button>
<Button
onClick={onClickCancel}
variant="outline"
size="xx-small"
themed
>
Cancel
</Button>
</div>
)}
</div>
);
};
@@ -0,0 +1,104 @@
import { FC, HTMLAttributes, useState } from "react";
import { useDispatch } from "react-redux";
import { Constants } from "@dndbeyond/character-rules-engine";
import { HtmlContent } from "~/components/HtmlContent";
import { RuleKeyEnum } from "~/constants";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
import { Heading } from "~/subApps/sheet/components/Sidebar/components/Heading";
import { toastMessageActions } from "~/tools/js/Shared/actions";
import { ShortModelInfoContract } from "~/types";
import { HitPointsSummary } from "../../../HitPointsBox/HitPointsSummary";
import { DeathSavesManager } from "./DeathSavesManager/DeathSavesManager";
import { HitPointsAdjuster } from "./HitPointsAdjuster";
import { HitPointsOverrides } from "./HitPointsOverrides";
import { RestoreLifeManager } from "./RestoreLifeManager/RestoreLifeManager";
import styles from "./styles.module.css";
interface Props extends HTMLAttributes<HTMLDivElement> {}
export const HitPointsManagePane: FC<Props> = ({ className, ...props }) => {
const { hpInfo, deathCause, ruleData, ruleDataUtils, characterActions } =
useCharacterEngine();
const dispatch = useDispatch();
const [damage, setDamage] = useState<number>(0);
const handleDamageUpdate = (damageValue: number) => {
setDamage(damageValue);
};
const onRestoreToLife = (restoreType: ShortModelInfoContract): void => {
const restoreChoice = restoreType.name === "Full" ? "full" : "1";
dispatch(characterActions.restoreLife(restoreType.id));
dispatch(
toastMessageActions.toastSuccess(
"Character Restored to Life",
`You have been restored to life with ${restoreChoice} HP.`
)
);
};
const renderDeathSavesRules = (): React.ReactNode => {
if (
hpInfo.remainingHp > 0 ||
(deathCause !== Constants.DeathCauseEnum.NONE &&
deathCause !== Constants.DeathCauseEnum.DEATHSAVES)
) {
return null;
}
const deathSavesRule = ruleDataUtils.getRule(
RuleKeyEnum.DEATH_SAVING_THROWS,
ruleData
);
return (
<div>
<Heading>Death Saving Throws Rules</Heading>
<HtmlContent
html={deathSavesRule?.description ? deathSavesRule.description : ""}
withoutTooltips
/>
</div>
);
};
return (
<div className={className} {...props}>
<Header>HP Management</Header>
<div className={styles.container}>
{hpInfo.remainingHp <= 0 &&
(deathCause === Constants.DeathCauseEnum.NONE ||
deathCause === Constants.DeathCauseEnum.DEATHSAVES) && (
<DeathSavesManager damageValue={damage} className={styles.border} />
)}
{deathCause !== Constants.DeathCauseEnum.NONE && (
<RestoreLifeManager
onSave={onRestoreToLife}
className={styles.border}
/>
)}
<HitPointsSummary
hpInfo={hpInfo}
showOriginalMax
showPermanentInputs
className={styles.border}
/>
<HitPointsAdjuster
hpInfo={hpInfo}
handleDamageUpdate={handleDamageUpdate}
className={styles.border}
/>
<HitPointsOverrides className={styles.border} />
{hpInfo.remainingHp <= 0 &&
(deathCause === Constants.DeathCauseEnum.NONE ||
deathCause === Constants.DeathCauseEnum.DEATHSAVES) &&
renderDeathSavesRules()}
</div>
</div>
);
};
@@ -0,0 +1,150 @@
import clsx from "clsx";
import { createRef, FC, HTMLAttributes, useState } from "react";
import { useDispatch } from "react-redux";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import {
HP_BONUS_VALUE,
HP_DAMAGE_TAKEN_VALUE,
HP_OVERRIDE_MAX_VALUE,
HP_TEMP_VALUE,
} from "~/subApps/sheet/constants";
import styles from "./styles.module.css";
interface Props extends HTMLAttributes<HTMLDivElement> {}
export const HitPointsOverrides: FC<Props> = ({ className, ...props }) => {
const { characterActions, hpInfo, ruleData, ruleDataUtils, helperUtils } =
useCharacterEngine();
const dispatch = useDispatch();
const [maxModifier, setMaxModifier] = useState<number | null>(hpInfo.bonusHp);
const [maxOverride, setMaxOverride] = useState<number | null>(
hpInfo.overrideHp
);
const maxModifierInput = createRef<HTMLInputElement>();
const maxOverrideInput = createRef<HTMLInputElement>();
/* --- Helper functions --- */
const updateDamageTaken = (maxHp: number) => {
if (hpInfo.totalHp !== null) {
let maxDiff = hpInfo.totalHp - maxHp;
if (maxDiff > 0) {
let newRemovedHp = Math.max(
HP_DAMAGE_TAKEN_VALUE.MIN,
hpInfo.removedHp - maxDiff
);
if (newRemovedHp !== hpInfo.removedHp) {
dispatch(
characterActions.hitPointsSet(
newRemovedHp,
hpInfo.tempHp ?? HP_TEMP_VALUE.MIN
)
);
}
}
}
};
const onKeyUp = (
func: any,
evt: React.KeyboardEvent<HTMLInputElement>
): void => {
if (evt.key === "Enter") {
func(evt);
}
};
/* --- Max hit point modifier functions --- */
const onChangeMaxModifier = (evt: React.ChangeEvent<HTMLInputElement>) => {
setMaxModifier(helperUtils.parseInputInt(evt.target.value));
};
const onBlurMaxModifier = (evt: React.FocusEvent<HTMLInputElement>) => {
let value = helperUtils.parseInputInt(evt.target.value);
if (value !== null) {
value = helperUtils.clampInt(
value,
HP_BONUS_VALUE.MIN,
HP_BONUS_VALUE.MAX
);
}
if (value !== null) {
updateDamageTaken(hpInfo.baseTotalHp + value);
}
if (value !== hpInfo.bonusHp) {
dispatch(characterActions.bonusHitPointsSet(value));
}
setMaxModifier(value);
};
/* --- Max hit point override functions --- */
const onChangeMaxOverride = (evt: React.ChangeEvent<HTMLInputElement>) => {
setMaxOverride(helperUtils.parseInputInt(evt.target.value));
};
const onBlurMaxOverride = (evt: React.FocusEvent<HTMLInputElement>) => {
let value = helperUtils.parseInputInt(evt.target.value);
if (value !== null) {
value = helperUtils.clampInt(
value,
ruleDataUtils.getMinimumHpTotal(ruleData),
HP_OVERRIDE_MAX_VALUE
);
}
if (value !== null) {
updateDamageTaken(value);
}
if (value !== hpInfo.overrideHp) {
//TODO fix when it can accept null
dispatch(characterActions.overrideHitPointsSet(value as number));
}
setMaxOverride(value);
};
return (
<div className={clsx([styles.row, styles.overrides, className])} {...props}>
<label className={clsx([styles.column, styles.override, styles.label])}>
Max HP Modifier
<input
ref={maxModifierInput}
className={styles.input}
type="number"
value={maxModifier ?? ""}
min={HP_BONUS_VALUE.MIN}
max={HP_BONUS_VALUE.MAX}
onChange={onChangeMaxModifier}
onBlur={onBlurMaxModifier}
onKeyUp={onKeyUp.bind(this, onBlurMaxModifier)}
placeholder="--"
/>
</label>
<label className={clsx([styles.column, styles.override, styles.label])}>
Override Max HP
<input
ref={maxOverrideInput}
className={styles.input}
type="number"
value={maxOverride ?? ""}
min={ruleDataUtils.getMinimumHpTotal(ruleData)}
max={HP_OVERRIDE_MAX_VALUE}
onChange={onChangeMaxOverride}
onBlur={onBlurMaxOverride}
onKeyUp={onKeyUp.bind(this, onBlurMaxOverride)}
placeholder="--"
/>
</label>
</div>
);
};
@@ -0,0 +1,84 @@
import clsx from "clsx";
import { FC, HTMLAttributes, useState } from "react";
import {
ExclusiveCheckbox,
TypeScriptUtils,
} from "@dndbeyond/character-components/es";
import { Button } from "~/components/Button";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { ShortModelInfoContract } from "~/types";
import { Heading } from "../../../components/Heading";
import styles from "./styles.module.css";
interface Props extends HTMLAttributes<HTMLDivElement> {
onSave?: (restoreType: ShortModelInfoContract) => void;
}
export const RestoreLifeManager: FC<Props> = ({
onSave,
className,
...props
}) => {
const { characterTheme, ruleData, ruleDataUtils } = useCharacterEngine();
const [activeChoice, setActiveChoice] = useState<number | null>(null);
const onClickRestore = () => {
if (onSave && activeChoice !== null) {
let restoreTypes = ruleDataUtils.getRestoreTypes(ruleData);
onSave(restoreTypes[activeChoice]);
}
};
const onClickReset = () => {
setActiveChoice(null);
};
const onSelection = (slotIdx: number) => {
setActiveChoice(slotIdx);
};
const renderRestoreLifeChoices = (): React.ReactNode => {
const restoreLifeChoices: Array<string> = ruleDataUtils
.getRestoreTypes(ruleData)
.map((type) => type.description)
.filter(TypeScriptUtils.isNotNullOrUndefined);
return (
<ExclusiveCheckbox
theme={characterTheme}
choices={restoreLifeChoices}
activeChoice={activeChoice}
onSelection={onSelection}
/>
);
};
const renderActions = (): React.ReactNode => {
if (activeChoice === null) {
return null;
}
return (
<div className={styles.actions}>
<Button onClick={onClickRestore} themed size="xx-small">
Restore Life
</Button>
<Button onClick={onClickReset} themed size="xx-small" variant="outline">
Reset
</Button>
</div>
);
};
return (
<div className={clsx([className])} {...props}>
<Heading className={styles.heading}>Restore Life</Heading>
{renderRestoreLifeChoices()}
{renderActions()}
</div>
);
};
@@ -0,0 +1,91 @@
import clsx from "clsx";
import { FC, HTMLAttributes } from "react";
import { AdvantageIcon } from "@dndbeyond/character-components/es";
import { HtmlContent } from "~/components/HtmlContent";
import { NumberDisplay } from "~/components/NumberDisplay";
import { RuleKeyEnum } from "~/constants";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { useRuleData } from "~/hooks/useRuleData";
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
import styles from "./styles.module.css";
interface Props extends HTMLAttributes<HTMLDivElement> {}
export const InitiativePane: FC<Props> = ({ ...props }) => {
const { ruleDataUtils, initiativeScore } = useRuleData();
const {
ruleData,
processedInitiative,
hasInitiativeAdvantage,
characterTheme,
formatUtils,
} = useCharacterEngine();
const staticTotal = processedInitiative + initiativeScore.amount;
let rule = ruleDataUtils.getRule(RuleKeyEnum.INITIATIVE, ruleData);
return (
<div {...props}>
<Header>
{hasInitiativeAdvantage && (
<AdvantageIcon
theme={characterTheme}
title={"Advantage on Initiative"}
className={styles.advantageIcon}
/>
)}
Initiative{" "}
<span className={styles.modifier}>
(
<NumberDisplay
type="signed"
number={processedInitiative}
className={styles.signedNumber}
/>
)
</span>
</Header>
<div className={styles.container}>
<p>
Initiative scores can replace rolls at your DM's discretion. Your
initiative score equals {initiativeScore.amount} plus your DEX
modifier.
</p>
<div
className={clsx([
styles.score,
hasInitiativeAdvantage && styles.isAdvantage,
])}
>
<span className={styles.label}>Initiative Score: </span>
<span className={styles.amount}>{staticTotal}</span>
</div>
<div className={clsx([hasInitiativeAdvantage && styles.isAdvantage])}>
<span className={styles.labelSecondary}>
With Advantage (
{formatUtils.renderSignedNumber(initiativeScore.advantage)}):{" "}
</span>
<span className={styles.amount}>
{staticTotal + initiativeScore.advantage}
</span>
</div>
<div>
<span className={styles.labelSecondary}>
With Disadvantage (
{formatUtils.renderSignedNumber(initiativeScore.disadvantage)}):{" "}
</span>
<span className={styles.amount}>
{staticTotal + initiativeScore.disadvantage}
</span>
</div>
</div>
<HtmlContent
html={rule && rule.description ? rule.description : ""}
withoutTooltips
/>
</div>
);
};

Some files were not shown because too many files have changed in this diff Show More