Grabbed dndbeyond's source code
``` ~/go/bin/sourcemapper -output ddb -jsurl https://media.dndbeyond.com/character-app/static/js/main.90aa78c5.js ```
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
import { orderBy } from "lodash";
|
||||
import { FC, Fragment, useMemo } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import ArrowRightIcon from "@dndbeyond/fontawesome-cache/svgs/solid/arrow-right.svg";
|
||||
|
||||
import { Accordion } from "~/components/Accordion";
|
||||
import { Button } from "~/components/Button";
|
||||
import { CollapsibleContent } from "~/components/CollapsibleContent";
|
||||
import { ConfirmModal, ConfirmModalProps } from "~/components/ConfirmModal";
|
||||
import { HtmlContent } from "~/components/HtmlContent";
|
||||
import { Reference } from "~/components/Reference";
|
||||
import { isNotNullOrUndefined } from "~/helpers/validation";
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
import { builderSelectors } from "~/tools/js/CharacterBuilder/selectors";
|
||||
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
export interface ConfirmClassModalProps
|
||||
extends Omit<ConfirmModalProps, "onConfirm"> {}
|
||||
|
||||
export const ConfirmClassModal: FC<ConfirmClassModalProps> = ({
|
||||
className,
|
||||
onClose,
|
||||
...props
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const confirmClass = useSelector(builderSelectors.getConfirmClass);
|
||||
const {
|
||||
ruleData,
|
||||
characterActions,
|
||||
ruleDataUtils: RuleDataUtils,
|
||||
formatUtils: FormatUtils,
|
||||
} = useCharacterEngine();
|
||||
|
||||
const classFeatures = useMemo(() => {
|
||||
return orderBy(confirmClass?.classFeatures, [
|
||||
(classFeature) => classFeature.requiredLevel,
|
||||
(classFeature) => classFeature.displayOrder,
|
||||
(classFeature) => classFeature.name,
|
||||
]);
|
||||
}, [confirmClass]);
|
||||
|
||||
const onConfirm = () => {
|
||||
if (confirmClass) {
|
||||
dispatch(characterActions.classAddRequest(confirmClass, 1));
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
return confirmClass ? (
|
||||
<ConfirmModal
|
||||
onClose={onClose}
|
||||
heading="Confirm Add Class"
|
||||
onConfirm={onConfirm}
|
||||
confirmButtonText="Add Class"
|
||||
useMobileFullScreen
|
||||
{...props}
|
||||
>
|
||||
<>
|
||||
<div className={styles.confirmClassHeader}>
|
||||
<div>
|
||||
<h3 className={styles.name}>{confirmClass.name}</h3>
|
||||
{confirmClass?.sources
|
||||
?.map((sourceMapping) =>
|
||||
RuleDataUtils.getSourceDataInfo(
|
||||
sourceMapping.sourceId,
|
||||
ruleData
|
||||
)
|
||||
)
|
||||
.filter(isNotNullOrUndefined)
|
||||
.map((source, idx) => (
|
||||
<Fragment key={uuidv4()}>
|
||||
{idx > 0 ? " / " : " "}
|
||||
<Reference name={source.description} />
|
||||
</Fragment>
|
||||
))}
|
||||
{confirmClass.description && (
|
||||
<HtmlContent html={confirmClass.description} withoutTooltips />
|
||||
)}
|
||||
</div>
|
||||
{confirmClass.portraitAvatarUrl && (
|
||||
<img
|
||||
className={styles.image}
|
||||
src={confirmClass.portraitAvatarUrl}
|
||||
alt={confirmClass.name || "Class image"}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{confirmClass.moreDetailsUrl && (
|
||||
<div className={styles.detailsLink}>
|
||||
<Button
|
||||
href={confirmClass.moreDetailsUrl}
|
||||
variant="builder-text"
|
||||
size="x-small"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{confirmClass.name} Details Page <ArrowRightIcon />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.classFeatures}>
|
||||
{classFeatures.map((feature) => (
|
||||
<Accordion
|
||||
summary={feature.name}
|
||||
summaryMetaItems={
|
||||
feature.requiredLevel
|
||||
? [`${FormatUtils.ordinalize(feature.requiredLevel)} level`]
|
||||
: undefined
|
||||
}
|
||||
key={uuidv4()}
|
||||
variant="paper"
|
||||
forceShow
|
||||
>
|
||||
<CollapsibleContent className={styles.description}>
|
||||
{feature.description}
|
||||
</CollapsibleContent>
|
||||
</Accordion>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
</ConfirmModal>
|
||||
) : null;
|
||||
};
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
import { FC, HTMLAttributes } from "react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import { BuilderHelperTextInfoContract } from "@dndbeyond/character-rules-engine";
|
||||
|
||||
import { Accordion } from "~/components/Accordion";
|
||||
import { CollapsibleContent } from "~/components/CollapsibleContent";
|
||||
import { HelperTextAccordion } from "~/components/HelperTextAccordion";
|
||||
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
export interface ConfirmSpeciesContentProps
|
||||
extends HTMLAttributes<HTMLDivElement> {
|
||||
heading: string;
|
||||
helpItems: BuilderHelperTextInfoContract[];
|
||||
speciesTraits: { name: string; description: string | null }[];
|
||||
}
|
||||
|
||||
export const ConfirmSpeciesContent: FC<ConfirmSpeciesContentProps> = ({
|
||||
heading,
|
||||
helpItems,
|
||||
speciesTraits,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<div {...props}>
|
||||
<h3>{heading}</h3>
|
||||
{helpItems.length > 0 && (
|
||||
<HelperTextAccordion builderHelperText={helpItems} />
|
||||
)}
|
||||
{speciesTraits.length > 0 && (
|
||||
<div>
|
||||
{speciesTraits.map(({ name, description }) => {
|
||||
return (
|
||||
<Accordion
|
||||
summary={name}
|
||||
key={uuidv4()}
|
||||
variant="paper"
|
||||
forceShow
|
||||
>
|
||||
<CollapsibleContent className={styles.description}>
|
||||
{description}
|
||||
</CollapsibleContent>
|
||||
</Accordion>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import clsx from "clsx";
|
||||
import { FC, HTMLAttributes, ReactNode } from "react";
|
||||
|
||||
import { HtmlContent } from "~/components/HtmlContent";
|
||||
import { LegacyBadge } from "~/components/LegacyBadge";
|
||||
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
export interface ConfirmSpeciesHeaderProps
|
||||
extends HTMLAttributes<HTMLDivElement> {
|
||||
heading: string;
|
||||
content: string;
|
||||
imageUrl: string;
|
||||
isLegacy?: boolean;
|
||||
sources?: ReactNode;
|
||||
}
|
||||
|
||||
export const ConfirmSpeciesHeader: FC<ConfirmSpeciesHeaderProps> = ({
|
||||
className,
|
||||
heading,
|
||||
content,
|
||||
imageUrl,
|
||||
isLegacy,
|
||||
sources,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<div className={clsx([styles.confirmSpeciesHeader, className])} {...props}>
|
||||
<div>
|
||||
<h3 className={styles.fullName}>
|
||||
{heading}
|
||||
{isLegacy && <LegacyBadge id={heading || ""} />}
|
||||
</h3>
|
||||
{sources}
|
||||
<HtmlContent html={content} withoutTooltips />
|
||||
</div>
|
||||
<img className={styles.image} src={imageUrl} alt={heading} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,201 @@
|
||||
import clsx from "clsx";
|
||||
import React, { FC } from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
|
||||
import ArrowRightIcon from "@dndbeyond/fontawesome-cache/svgs/solid/arrow-right.svg";
|
||||
|
||||
import { Button } from "~/components/Button";
|
||||
import { ConfirmModal, ConfirmModalProps } from "~/components/ConfirmModal";
|
||||
import { Reference } from "~/components/Reference";
|
||||
import { SummaryList } from "~/components/SummaryList";
|
||||
import { AppContextTypeEnum, DisplayConfigurationTypeEnum } from "~/constants";
|
||||
import { isNotNullOrUndefined } from "~/helpers/validation";
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
import { Feat, RacialTraitContract as RacialTrait } from "~/types";
|
||||
|
||||
import { useSpeciesContext } from "../../contexts/Species";
|
||||
import { ConfirmSpeciesContent } from "./ConfirmSpeciesContent";
|
||||
import { ConfirmSpeciesHeader } from "./ConfirmSpeciesHeader";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
export interface ConfirmSpeciesModalProps
|
||||
extends Omit<ConfirmModalProps, "onConfirm"> {
|
||||
feats?: Feat[];
|
||||
}
|
||||
|
||||
export const ConfirmSpeciesModal: FC<ConfirmSpeciesModalProps> = ({
|
||||
className,
|
||||
feats,
|
||||
onClose,
|
||||
...props
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const {
|
||||
characterActions: { raceChoose },
|
||||
definitionUtils: { hack__generateDefinitionKey: generateDefinitionKey },
|
||||
featUtils: { getId: getFeatId, getName: getFeatName },
|
||||
raceUtils: {
|
||||
deriveCalledOutRacialTraits,
|
||||
deriveOrderedRacialTraits,
|
||||
deriveVisibleRacialTraits: deriveVisibleTraits,
|
||||
getDefinitionRacialTraits,
|
||||
getEntityRaceId,
|
||||
getEntityRaceTypeId,
|
||||
getFeatIds,
|
||||
getMoreDetailsUrl,
|
||||
},
|
||||
racialTraitUtils: {
|
||||
filterRacialTraitsByDisplayConfigurationType: filterRacialTraits,
|
||||
getName: getRacialTraitName,
|
||||
getDescription: getRacialTraitDescription,
|
||||
simulateRacialTraitFromContract,
|
||||
},
|
||||
ruleData,
|
||||
ruleDataUtils: {
|
||||
getBuilderHelperTextByDefinitionKeys: getHelperText,
|
||||
getSourceDataInfo,
|
||||
},
|
||||
race: species,
|
||||
} = useCharacterEngine();
|
||||
const { defaultSpeciesImageUrl: defaultImageUrl, selectedSpecies } =
|
||||
useSpeciesContext();
|
||||
|
||||
const { description, fullName, isLegacy, portraitAvatarUrl, sources } =
|
||||
selectedSpecies || {};
|
||||
|
||||
const previewUrl = portraitAvatarUrl || defaultImageUrl;
|
||||
|
||||
let missingFeatDependencies: Feat[] = [];
|
||||
|
||||
if (feats && species && selectedSpecies) {
|
||||
let speciesFeatIds = getFeatIds(species);
|
||||
let confirmSpeciesFeatIds = getFeatIds(selectedSpecies);
|
||||
feats.forEach((feat) => {
|
||||
if (
|
||||
speciesFeatIds.includes(getFeatId(feat)) &&
|
||||
!confirmSpeciesFeatIds.includes(getFeatId(feat))
|
||||
) {
|
||||
missingFeatDependencies.push(feat);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Get listing url for species
|
||||
const moreDetailsUrl = selectedSpecies
|
||||
? getMoreDetailsUrl(selectedSpecies)
|
||||
: "";
|
||||
|
||||
// Get species traits
|
||||
const speciesTraits =
|
||||
selectedSpecies && getDefinitionRacialTraits(selectedSpecies);
|
||||
|
||||
const simulatedSpeciesTraits = speciesTraits?.map((speciesTrait) =>
|
||||
simulateRacialTraitFromContract(speciesTrait)
|
||||
);
|
||||
|
||||
const calledOutSpeciesTraits =
|
||||
simulatedSpeciesTraits &&
|
||||
deriveCalledOutRacialTraits(simulatedSpeciesTraits, ruleData);
|
||||
|
||||
const onConfirm = () => {
|
||||
if (selectedSpecies) dispatch(raceChoose(selectedSpecies));
|
||||
onClose();
|
||||
};
|
||||
|
||||
const definitionKey =
|
||||
(selectedSpecies &&
|
||||
generateDefinitionKey(
|
||||
getEntityRaceTypeId(selectedSpecies),
|
||||
getEntityRaceId(selectedSpecies)
|
||||
)) ||
|
||||
"";
|
||||
|
||||
const builderText = getHelperText(
|
||||
[definitionKey],
|
||||
ruleData,
|
||||
DisplayConfigurationTypeEnum.RACIAL_TRAIT
|
||||
);
|
||||
|
||||
const orderedSpeciesTraits: RacialTrait[] = speciesTraits
|
||||
? deriveOrderedRacialTraits(speciesTraits)
|
||||
: [];
|
||||
|
||||
let visibleSpeciesTraits: RacialTrait[] = deriveVisibleTraits(
|
||||
orderedSpeciesTraits,
|
||||
AppContextTypeEnum.BUILDER
|
||||
);
|
||||
|
||||
visibleSpeciesTraits = filterRacialTraits(visibleSpeciesTraits, [
|
||||
DisplayConfigurationTypeEnum.RACIAL_TRAIT,
|
||||
]);
|
||||
|
||||
const sourceList = sources
|
||||
?.map((sourceMapping) =>
|
||||
getSourceDataInfo(sourceMapping.sourceId, ruleData)
|
||||
)
|
||||
.filter(isNotNullOrUndefined)
|
||||
.map((source, idx) => (
|
||||
<React.Fragment key={`${source}-${idx}`}>
|
||||
{idx > 0 ? " / " : " "}
|
||||
<Reference name={source.description} />
|
||||
</React.Fragment>
|
||||
));
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
className={clsx([styles.confirmSpeciesModal, className])}
|
||||
onClose={onClose}
|
||||
heading={`${species ? "Confirm Change" : "Confirm"} Species`}
|
||||
onConfirm={onConfirm}
|
||||
useMobileFullScreen
|
||||
{...props}
|
||||
>
|
||||
{missingFeatDependencies.length > 0 && (
|
||||
<div className="builder-message builder-message-warning">
|
||||
By changing your species, you will have invalid feat selection(s):
|
||||
{" " +
|
||||
missingFeatDependencies.map((feat) => getFeatName(feat)).join(", ")}
|
||||
</div>
|
||||
)}
|
||||
{selectedSpecies && (
|
||||
<div className={styles.content}>
|
||||
<ConfirmSpeciesHeader
|
||||
heading={fullName || ""}
|
||||
content={description || ""}
|
||||
imageUrl={previewUrl}
|
||||
isLegacy={isLegacy}
|
||||
sources={sourceList}
|
||||
/>
|
||||
{calledOutSpeciesTraits && calledOutSpeciesTraits.length > 0 && (
|
||||
<SummaryList
|
||||
list={calledOutSpeciesTraits}
|
||||
title="Species Traits"
|
||||
className={styles.summaryList}
|
||||
/>
|
||||
)}
|
||||
{moreDetailsUrl && (
|
||||
<div className={styles.detailsLink}>
|
||||
<Button
|
||||
href={moreDetailsUrl}
|
||||
variant="builder-text"
|
||||
size="x-small"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{fullName} Details Page <ArrowRightIcon />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<ConfirmSpeciesContent
|
||||
heading={`${fullName} Traits`}
|
||||
helpItems={builderText}
|
||||
speciesTraits={visibleSpeciesTraits.map((trait) => ({
|
||||
name: getRacialTraitName(trait),
|
||||
description: getRacialTraitDescription(trait),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</ConfirmModal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
import clsx from "clsx";
|
||||
import React, {
|
||||
cloneElement,
|
||||
FC,
|
||||
HTMLAttributes,
|
||||
ReactElement,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import { Button } from "~/components/Button";
|
||||
import { ConfirmModal } from "~/components/ConfirmModal";
|
||||
import { Textarea } from "~/components/Textarea";
|
||||
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
interface EditorWithDialogProps extends HTMLAttributes<HTMLDivElement> {
|
||||
heading: ReactElement;
|
||||
placeholder: string;
|
||||
content: string;
|
||||
editButtonLabel: string;
|
||||
addButtonLabel: string;
|
||||
onSave?: (content: string) => void;
|
||||
extraNode?: React.ReactNode;
|
||||
saveOnBlur?: boolean;
|
||||
}
|
||||
|
||||
export const EditorWithDialog: FC<EditorWithDialogProps> = ({
|
||||
addButtonLabel,
|
||||
className,
|
||||
editButtonLabel,
|
||||
content = "",
|
||||
extraNode,
|
||||
heading,
|
||||
onSave,
|
||||
placeholder,
|
||||
saveOnBlur = true,
|
||||
...props
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const textareaInput = useRef<HTMLDivElement>(null);
|
||||
const id = uuidv4();
|
||||
const headingNode = cloneElement(heading, { className: styles.heading });
|
||||
|
||||
const handleInputBlur = (content: string): void => {
|
||||
if (saveOnBlur) {
|
||||
if (onSave) onSave(content);
|
||||
}
|
||||
};
|
||||
|
||||
const handleShowModal = () => {
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const modalHeading: string = content.trim().length
|
||||
? editButtonLabel
|
||||
: addButtonLabel;
|
||||
const buttonLabel: string = modalHeading;
|
||||
|
||||
return (
|
||||
<div className={clsx([styles.container, className])} {...props}>
|
||||
<header className={styles.header}>
|
||||
{headingNode}
|
||||
<Button onClick={handleShowModal} size="xx-small" color="builder-green">
|
||||
{buttonLabel}
|
||||
</Button>
|
||||
</header>
|
||||
{content.trim().length > 0 && (
|
||||
<div className={styles.preview} data-testid="textarea-editor-preview">
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
<ConfirmModal
|
||||
heading={modalHeading}
|
||||
open={isOpen}
|
||||
onClose={handleCloseModal}
|
||||
onConfirm={handleCloseModal}
|
||||
data-testid="textarea-editor-modal"
|
||||
modal
|
||||
variant="confirm-only"
|
||||
confirmButtonText={"Save and Close"}
|
||||
color="builder-green"
|
||||
useMobileFullScreen
|
||||
>
|
||||
<div className={styles.content}>
|
||||
<label className={styles.title} htmlFor={id}>
|
||||
{heading}
|
||||
</label>
|
||||
<Textarea
|
||||
className={styles.textarea}
|
||||
ref={textareaInput}
|
||||
id={id}
|
||||
placeholder={placeholder}
|
||||
value={content}
|
||||
onInputKeyUp={() => {}}
|
||||
onInputBlur={handleInputBlur}
|
||||
/>
|
||||
{extraNode && <div className={styles.extra}>{extraNode}</div>}
|
||||
</div>
|
||||
</ConfirmModal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import { FC } from "react";
|
||||
|
||||
import { Button } from "~/components/Button";
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
import { useEquipmentMethods } from "~/subApps/builder/hooks/useEquipmentMethods";
|
||||
import { Item } from "~/types";
|
||||
|
||||
export interface EquipmentActionsProps {
|
||||
item: Item;
|
||||
equipLabel: string;
|
||||
unequipLabel: string;
|
||||
}
|
||||
|
||||
export const EquipmentActions: FC<EquipmentActionsProps> = ({
|
||||
item,
|
||||
equipLabel,
|
||||
unequipLabel,
|
||||
}) => {
|
||||
const { itemUtils } = useCharacterEngine();
|
||||
const { onToggleEquip } = useEquipmentMethods();
|
||||
const isEquipped = itemUtils.isEquipped(item);
|
||||
const isStackable = itemUtils.isStackable(item);
|
||||
const canEquip = itemUtils.canEquip(item);
|
||||
const quantity = itemUtils.getQuantity(item);
|
||||
|
||||
const handleToggleEquip = (e) => onToggleEquip(e, item);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isStackable && (
|
||||
<div className="equipment-list-item-callout-quantity">
|
||||
<span className="equipment-list-item-callout-quantity-extra">
|
||||
Qty
|
||||
</span>
|
||||
<span className="equipment-list-item-callout-quantity-value">
|
||||
{quantity}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{canEquip && (
|
||||
<div className="equipment-list-item-callout-action">
|
||||
<Button
|
||||
onClick={handleToggleEquip}
|
||||
size="xx-small"
|
||||
color="builder-green"
|
||||
variant={isEquipped ? "solid" : "outline"}
|
||||
>
|
||||
{isEquipped ? unequipLabel : equipLabel}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,253 @@
|
||||
import clsx from "clsx";
|
||||
import { FC, HTMLAttributes, useMemo, useState } from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
|
||||
import {
|
||||
characterActions,
|
||||
ClassUtils,
|
||||
Constants,
|
||||
DiceUtils,
|
||||
FormatUtils,
|
||||
HelperUtils,
|
||||
RuleDataUtils,
|
||||
} from "@dndbeyond/character-rules-engine";
|
||||
|
||||
import { ConfirmModal, ConfirmModalProps } from "~/components/ConfirmModal";
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
import {
|
||||
HP_BASE_MAX_VALUE,
|
||||
HP_BONUS_VALUE,
|
||||
HP_OVERRIDE_MAX_VALUE,
|
||||
} from "~/subApps/sheet/constants";
|
||||
import { FormInputField } from "~/tools/js/Shared/components/common/FormInputField";
|
||||
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
export interface HpManageModalProps
|
||||
extends Omit<ConfirmModalProps, "onConfirm"> {}
|
||||
|
||||
export const HpManageModal: FC<HpManageModalProps> = ({
|
||||
onClose,
|
||||
...props
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const { hpInfo, preferences, ruleData } = useCharacterEngine();
|
||||
|
||||
const [baseHp, setBaseHp] = useState(hpInfo.baseHp);
|
||||
const [bonusHp, setBonusHp] = useState(hpInfo.bonusHp);
|
||||
const [overrideHp, setOverrideHp] = useState(hpInfo.overrideHp);
|
||||
|
||||
const reset = () => {
|
||||
setBaseHp(hpInfo.baseHp);
|
||||
setBonusHp(hpInfo.bonusHp);
|
||||
setOverrideHp(hpInfo.overrideHp);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
reset();
|
||||
};
|
||||
|
||||
const onConfirm = () => {
|
||||
if (baseHp !== hpInfo.baseHp) {
|
||||
dispatch(characterActions.baseHitPointsSet(baseHp));
|
||||
}
|
||||
|
||||
if (bonusHp !== hpInfo.bonusHp) {
|
||||
dispatch(characterActions.bonusHitPointsSet(bonusHp));
|
||||
}
|
||||
|
||||
if (overrideHp !== hpInfo.overrideHp) {
|
||||
dispatch(characterActions.overrideHitPointsSet(overrideHp));
|
||||
}
|
||||
|
||||
onClose();
|
||||
reset();
|
||||
};
|
||||
|
||||
const transformBaseHp = (value: string): number => {
|
||||
let parsedNumber = HelperUtils.parseInputInt(
|
||||
value,
|
||||
RuleDataUtils.getMinimumHpTotal(ruleData)
|
||||
);
|
||||
return HelperUtils.clampInt(
|
||||
parsedNumber,
|
||||
RuleDataUtils.getMinimumHpTotal(ruleData),
|
||||
HP_BASE_MAX_VALUE
|
||||
);
|
||||
};
|
||||
|
||||
const transformBonusHp = (value: string): number | null => {
|
||||
let parsedNumber = HelperUtils.parseInputInt(value);
|
||||
if (parsedNumber === null) {
|
||||
return parsedNumber;
|
||||
}
|
||||
return HelperUtils.clampInt(
|
||||
parsedNumber,
|
||||
HP_BONUS_VALUE.MIN,
|
||||
HP_BONUS_VALUE.MAX
|
||||
);
|
||||
};
|
||||
|
||||
const transformOverrideHp = (value: string): number | null => {
|
||||
let parsedNumber = HelperUtils.parseInputInt(value, null);
|
||||
if (parsedNumber === null) {
|
||||
return parsedNumber;
|
||||
}
|
||||
return HelperUtils.clampInt(
|
||||
parsedNumber,
|
||||
RuleDataUtils.getMinimumHpTotal(ruleData)
|
||||
);
|
||||
};
|
||||
|
||||
const handleOverrideHpUpdate = (value: number | null): void => {
|
||||
const newOverride =
|
||||
value === null
|
||||
? null
|
||||
: HelperUtils.clampInt(
|
||||
value,
|
||||
RuleDataUtils.getMinimumHpTotal(ruleData),
|
||||
HP_OVERRIDE_MAX_VALUE
|
||||
);
|
||||
|
||||
setOverrideHp(newOverride);
|
||||
};
|
||||
|
||||
const totalHp = useMemo(() => {
|
||||
let totalHp: number =
|
||||
baseHp + hpInfo.totalHitPointSources + (bonusHp === null ? 0 : bonusHp);
|
||||
if (overrideHp !== null) {
|
||||
totalHp = overrideHp;
|
||||
}
|
||||
|
||||
return Math.max(RuleDataUtils.getMinimumHpTotal(ruleData), totalHp);
|
||||
}, [baseHp, bonusHp, overrideHp, hpInfo, ruleData]);
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
onClose={handleClose}
|
||||
heading="Manage Hit Points"
|
||||
onConfirm={onConfirm}
|
||||
confirmButtonText="Apply"
|
||||
useMobileFullScreen
|
||||
{...props}
|
||||
>
|
||||
{/* Total Max Hit Points */}
|
||||
<div className={styles.hpManageModal}>
|
||||
<p className={styles.total}>
|
||||
<span className={styles.totalLabel}>Maximum Hit Points</span>
|
||||
<span className={styles.totalValue}>{totalHp}</span>
|
||||
</p>
|
||||
|
||||
{/* Hit Point Controls */}
|
||||
<div className={styles.controls}>
|
||||
{preferences.hitPointType ===
|
||||
Constants.PreferenceHitPointTypeEnum.FIXED ? (
|
||||
<p className={styles.baseHp}>
|
||||
<span className={clsx([styles.baseHpLabel])}>Fixed HP</span>
|
||||
<span className={styles.baseHpValue}>{baseHp}</span>
|
||||
</p>
|
||||
) : (
|
||||
<FormInputField
|
||||
label="Rolled HP"
|
||||
initialValue={baseHp}
|
||||
type="number"
|
||||
onBlur={(value) => setBaseHp(value as number)}
|
||||
transformValueOnBlur={transformBaseHp}
|
||||
/>
|
||||
)}
|
||||
<FormInputField
|
||||
label="HP Modifier"
|
||||
initialValue={bonusHp}
|
||||
type="number"
|
||||
placeholder={"--"}
|
||||
onBlur={(value) => setBonusHp(value as number)}
|
||||
transformValueOnBlur={transformBonusHp}
|
||||
/>
|
||||
<FormInputField
|
||||
inputAttributes={
|
||||
{
|
||||
min: RuleDataUtils.getMinimumHpTotal(ruleData),
|
||||
max: HP_OVERRIDE_MAX_VALUE,
|
||||
} as HTMLAttributes<HTMLInputElement>
|
||||
}
|
||||
label="Override HP"
|
||||
initialValue={overrideHp}
|
||||
type="number"
|
||||
placeholder={"--"}
|
||||
onBlur={handleOverrideHpUpdate}
|
||||
transformValueOnBlur={transformOverrideHp}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Hit Point Bonus Sources */}
|
||||
{hpInfo.hitPointSources.length > 0 && (
|
||||
<div className={styles.hpSources}>
|
||||
<h3>Hit Point Bonuses</h3>
|
||||
{hpInfo.hitPointSources.map((hitPointSource, idx) => (
|
||||
<p key={`${idx}:${hitPointSource.source}`}>
|
||||
{FormatUtils.renderSignedNumber(hitPointSource.amount)} from{" "}
|
||||
{hitPointSource.source}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hit Dice & Potential Values */}
|
||||
<div className={styles.info}>
|
||||
<div className={styles.infoGroup}>
|
||||
<h3>Hit Dice</h3>
|
||||
{hpInfo.classesHitDice.map((classHitDice) => (
|
||||
<div key={ClassUtils.getId(classHitDice.charClass)}>
|
||||
<span className={styles.infoLabel}>
|
||||
{ClassUtils.getName(classHitDice.charClass)}:
|
||||
</span>
|
||||
<span>{DiceUtils.renderDie(classHitDice.dice)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.infoGroup}>
|
||||
<h3>Potential Values</h3>
|
||||
<div>
|
||||
<span className={styles.infoLabel}>Total Fixed Value HP:</span>
|
||||
<span>{hpInfo.totalFixedValueHp}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className={styles.infoLabel}>Total Average HP:</span>
|
||||
<span>{hpInfo.totalAverageHp}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className={styles.infoLabel}>Total Possible HP:</span>
|
||||
<span>{hpInfo.possibleMaxHitPoints}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* HP Help */}
|
||||
<div className={styles.help}>
|
||||
<h3>Max Hit Points</h3>
|
||||
<p>
|
||||
Your hit point maximum is determined by the number you roll for your
|
||||
hit dice each level or a fixed value determined by your hit dice and
|
||||
your Constitution modifier
|
||||
</p>
|
||||
|
||||
<h3>Bonus Hit Points</h3>
|
||||
<p>
|
||||
Use this field to record any miscellaneous bonus hit points you want
|
||||
to add to your normal hit point maximum. These hit points are
|
||||
different from temporary hit points, which you can add on your
|
||||
character sheet during play.
|
||||
</p>
|
||||
|
||||
<h3>Override Hit Points</h3>
|
||||
<p>
|
||||
Use this field to override your typical hit point maximum. The
|
||||
number you enter here will display as your hit point maximum on your
|
||||
character sheet.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</ConfirmModal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import { orderBy } from "lodash";
|
||||
import { FC, HTMLAttributes, useMemo, useState } from "react";
|
||||
|
||||
import { DiceContract, DiceUtils } from "@dndbeyond/character-rules-engine";
|
||||
|
||||
import { Button } from "~/components/Button";
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
|
||||
import { HpManageModal } from "../HpManageModal";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
export interface HpSummaryProps extends HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
export const HpSummary: FC<HpSummaryProps> = ({ className, ...props }) => {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const { hpInfo } = useCharacterEngine();
|
||||
|
||||
const openModal = () => {
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setIsModalOpen(false);
|
||||
};
|
||||
|
||||
const hitDiceStrings = useMemo(() => {
|
||||
const hitDice = hpInfo.classesHitDice.map(
|
||||
(classHitDice) => classHitDice.dice
|
||||
);
|
||||
const combinedHitDice: Array<DiceContract> = [];
|
||||
hitDice.forEach((hitDie) => {
|
||||
const existingDieIdx = combinedHitDice.findIndex(
|
||||
(hitDice) => hitDice.diceValue === hitDie.diceValue
|
||||
);
|
||||
if (existingDieIdx > -1) {
|
||||
let existingDie = combinedHitDice[existingDieIdx];
|
||||
combinedHitDice[existingDieIdx] = {
|
||||
...combinedHitDice[existingDieIdx],
|
||||
diceCount:
|
||||
(existingDie.diceCount !== null ? existingDie.diceCount : 0) +
|
||||
(hitDie.diceCount !== null ? hitDie.diceCount : 0),
|
||||
};
|
||||
} else {
|
||||
combinedHitDice.push(hitDie);
|
||||
}
|
||||
});
|
||||
|
||||
return orderBy(combinedHitDice, ["diceValue"]).map((hitDie) =>
|
||||
DiceUtils.renderDie(hitDie)
|
||||
);
|
||||
}, [hpInfo]);
|
||||
|
||||
return (
|
||||
<div {...props}>
|
||||
<div className={styles.hpSummaryContent}>
|
||||
<div>
|
||||
<p>
|
||||
<span className={styles.label}>Max Hit Points:</span>
|
||||
<span data-testid="hit-point-max">{hpInfo.totalHp}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span className={styles.label}>Hit Dice:</span>
|
||||
<span data-testid="hit-point-dice">
|
||||
{hitDiceStrings.join(" + ")}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
className={styles.button}
|
||||
variant="builder"
|
||||
size="x-small"
|
||||
onClick={openModal}
|
||||
>
|
||||
Manage HP
|
||||
</Button>
|
||||
</div>
|
||||
<HpManageModal open={isModalOpen} onClose={closeModal} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import clsx from "clsx";
|
||||
import { FC, HTMLAttributes } from "react";
|
||||
|
||||
import { GroupedListingItem, ListingItem } from "../../types";
|
||||
import { ListingGroup } from "./ListingGroup";
|
||||
import { ListingItemButton } from "./ListingItemButton/ListingItemButton";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
export interface ListingProps extends HTMLAttributes<HTMLDivElement> {
|
||||
items: Array<ListingItem | GroupedListingItem>;
|
||||
disabledIds?: Array<number | string>;
|
||||
onQuickSelect?: (entity: ListingItem["entity"]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A generic listing component that displays a list of items. Items cannot be
|
||||
* internal because it is used for both races/species and classes. Keeping the
|
||||
* data fetch outside this component allows for more flexibility.
|
||||
*/
|
||||
|
||||
export const Listing: FC<ListingProps> = ({
|
||||
className,
|
||||
items,
|
||||
disabledIds,
|
||||
onQuickSelect,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<div className={clsx([styles.listing, className])} {...props}>
|
||||
{items.length > 0 ? (
|
||||
items.map((item) => {
|
||||
const isDisabled = disabledIds && disabledIds.includes(item.id);
|
||||
|
||||
return item.type === "group" ? (
|
||||
<ListingGroup
|
||||
item={item as GroupedListingItem}
|
||||
key={item.id + "group"}
|
||||
disabledIds={disabledIds}
|
||||
onQuickSelect={onQuickSelect}
|
||||
/>
|
||||
) : (
|
||||
<ListingItemButton
|
||||
item={item as ListingItem}
|
||||
isDisabled={isDisabled}
|
||||
key={item.id}
|
||||
onQuickSelect={onQuickSelect}
|
||||
/>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<p className={styles.notFound}>No Listings Found</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
import clsx from "clsx";
|
||||
import { FC, HTMLAttributes, MouseEvent, useState } from "react";
|
||||
|
||||
import ChevronDown from "@dndbeyond/fontawesome-cache/svgs/solid/chevron-down.svg";
|
||||
|
||||
import { LegacyBadge } from "~/components/LegacyBadge";
|
||||
import { GroupedListingItem } from "~/subApps/builder/types";
|
||||
|
||||
import { ListingItemButton } from "../ListingItemButton";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
export interface ListingGroupProps extends HTMLAttributes<HTMLDetailsElement> {
|
||||
item: GroupedListingItem;
|
||||
disabledIds?: Array<number | string>;
|
||||
onQuickSelect?: (entity: GroupedListingItem["entity"]) => void;
|
||||
}
|
||||
|
||||
export const ListingGroup: FC<ListingGroupProps> = ({
|
||||
className,
|
||||
item,
|
||||
disabledIds,
|
||||
onQuickSelect,
|
||||
...props
|
||||
}) => {
|
||||
const {
|
||||
entityTypeId,
|
||||
error,
|
||||
heading,
|
||||
id,
|
||||
image,
|
||||
isLegacy,
|
||||
metaItems,
|
||||
text,
|
||||
type,
|
||||
onClick,
|
||||
...itemProps
|
||||
} = item;
|
||||
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
|
||||
const handleToggle = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setIsOpen(!isOpen);
|
||||
};
|
||||
|
||||
return (
|
||||
<details className={styles.details} key={id} open={isOpen} {...props}>
|
||||
<summary
|
||||
className={clsx([styles.summary, className])}
|
||||
onClick={handleToggle}
|
||||
>
|
||||
{image && (
|
||||
<img src={image} alt={`${heading} avatar`} className={styles.image} />
|
||||
)}
|
||||
<div>
|
||||
<header className={styles.header}>
|
||||
<h3 className={styles.heading}>
|
||||
{heading}{" "}
|
||||
<span
|
||||
className={styles.count}
|
||||
>{`(${itemProps.listItems.length})`}</span>
|
||||
</h3>
|
||||
{isLegacy && <LegacyBadge className={styles.tag} />}
|
||||
</header>
|
||||
{text && <p className={styles.text}>{text}</p>}
|
||||
{metaItems && metaItems.length > 0 && (
|
||||
<div className={styles.metaItems}>
|
||||
{metaItems.map((metaItem, idx) => (
|
||||
<span className={styles[metaItem.type]} key={idx}>
|
||||
{metaItem.text}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown className={styles.icon} />
|
||||
</summary>
|
||||
<div className={styles.content}>
|
||||
{itemProps.listItems.map((item) => (
|
||||
<ListingItemButton
|
||||
item={item}
|
||||
isDisabled={disabledIds && disabledIds.includes(item.id)}
|
||||
key={item.id}
|
||||
onQuickSelect={onQuickSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
import clsx from "clsx";
|
||||
import { FC, HTMLAttributes, MouseEvent } from "react";
|
||||
|
||||
import ChevronRight from "@dndbeyond/fontawesome-cache/svgs/solid/chevron-right.svg";
|
||||
|
||||
import { LegacyBadge } from "~/components/LegacyBadge";
|
||||
import { ListingItem } from "~/subApps/builder/types";
|
||||
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
export interface ListingItemProps extends HTMLAttributes<HTMLButtonElement> {
|
||||
item: ListingItem;
|
||||
isDisabled?: boolean;
|
||||
onQuickSelect?: (entity: ListingItem["entity"]) => void;
|
||||
}
|
||||
|
||||
export const ListingItemButton: FC<ListingItemProps> = ({
|
||||
className,
|
||||
item,
|
||||
isDisabled,
|
||||
onQuickSelect,
|
||||
...props
|
||||
}) => {
|
||||
const {
|
||||
entityTypeId,
|
||||
error,
|
||||
heading,
|
||||
id,
|
||||
image,
|
||||
isLegacy,
|
||||
metaItems,
|
||||
text,
|
||||
type,
|
||||
onClick,
|
||||
...itemProps
|
||||
} = item;
|
||||
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
//QuickSelect is from Quick and RandomBuild modes only
|
||||
if (onQuickSelect && !isDisabled) {
|
||||
onQuickSelect(item.entity);
|
||||
} else if (onClick && !isDisabled) {
|
||||
onClick(id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className={clsx([styles.button, className])}
|
||||
onClick={handleClick}
|
||||
aria-disabled={isDisabled}
|
||||
key={id}
|
||||
{...props}
|
||||
>
|
||||
{image && (
|
||||
<img src={image} alt={`${heading} avatar`} className={styles.image} />
|
||||
)}
|
||||
<div>
|
||||
<header className={styles.header}>
|
||||
<h3 className={styles.heading}>{heading}</h3>
|
||||
{isLegacy && <LegacyBadge className={styles.tag} />}
|
||||
</header>
|
||||
{text && <p className={styles.text}>{text}</p>}
|
||||
{metaItems && metaItems.length > 0 && (
|
||||
<div className={styles.metaItems}>
|
||||
{metaItems.map((metaItem, idx) => (
|
||||
<span className={styles[metaItem.type]} key={idx}>
|
||||
{metaItem.text}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ChevronRight className={styles.icon} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,209 @@
|
||||
import clsx from "clsx";
|
||||
import { FC, HTMLAttributes, useEffect, useState } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
|
||||
import { CharacterPortraitContract } from "@dndbeyond/character-rules-engine";
|
||||
import ShuffleIcon from "@dndbeyond/fontawesome-cache/svgs/solid/shuffle.svg";
|
||||
import { LabelChip } from "@dndbeyond/ttui/components/LabelChip";
|
||||
|
||||
import { Button } from "~/components/Button";
|
||||
import { ConfirmModal } from "~/components/ConfirmModal";
|
||||
import {
|
||||
CharacterNameLimitMsg,
|
||||
InputLimits,
|
||||
DefaultCharacterName,
|
||||
} from "~/constants";
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
import { builderActions } from "~/tools/js/CharacterBuilder/actions";
|
||||
import { builderSelectors } from "~/tools/js/CharacterBuilder/selectors";
|
||||
import { FormInputField } from "~/tools/js/Shared/components/common/FormInputField";
|
||||
import { PortraitManager } from "~/tools/js/Shared/containers/panes/DecoratePane";
|
||||
import Tooltip from "~/tools/js/commonComponents/Tooltip";
|
||||
import { CharacterAvatarPortrait } from "~/tools/js/smartComponents/CharacterAvatar";
|
||||
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLDivElement> {
|
||||
hidePortrait?: boolean;
|
||||
handleNameUpdate?: (newValue: string) => void;
|
||||
fullWidth?: boolean;
|
||||
useDefaultCharacterName?: boolean;
|
||||
}
|
||||
|
||||
export const PortraitName: FC<Props> = ({
|
||||
fullWidth,
|
||||
hidePortrait,
|
||||
handleNameUpdate,
|
||||
useDefaultCharacterName,
|
||||
...props
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const { characterActions, decorationUtils, decorationInfo, characterName } =
|
||||
useCharacterEngine();
|
||||
const [localValue, setLocalValue] = useState(characterName);
|
||||
|
||||
const suggestedNames = useSelector(builderSelectors.getSuggestedNames);
|
||||
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedPortrait, setSelectedPortrait] =
|
||||
useState<CharacterPortraitContract | null>(null);
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const [currentSuggestedNames, setCurrentSuggestedNames] = useState<
|
||||
Array<string>
|
||||
>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentSuggestedNames(suggestedNames);
|
||||
}, [suggestedNames]);
|
||||
|
||||
const handleNameFieldSet = (value: string | null): void => {
|
||||
let updatedName = value ?? "";
|
||||
|
||||
// If the flag useDefaultCharacterName is true and value is falsy, set the name to the DefaultCharacterName
|
||||
if (useDefaultCharacterName) updatedName = value || DefaultCharacterName;
|
||||
|
||||
setLocalValue(updatedName);
|
||||
if (handleNameUpdate) {
|
||||
handleNameUpdate(updatedName);
|
||||
} else {
|
||||
dispatch(characterActions.nameSet(updatedName));
|
||||
}
|
||||
};
|
||||
|
||||
const handleShowSuggestions = (): void => {
|
||||
if (currentSuggestedNames.length === 0) {
|
||||
dispatch(builderActions.suggestedNamesRequest());
|
||||
}
|
||||
|
||||
setShowSuggestions(!showSuggestions);
|
||||
};
|
||||
|
||||
const handleSuggestMoreClick = (): void => {
|
||||
dispatch(builderActions.suggestedNamesRequest());
|
||||
};
|
||||
|
||||
const onConfirmPortrait = (): void => {
|
||||
if (selectedPortrait?.avatarId && selectedPortrait?.avatarUrl) {
|
||||
dispatch(
|
||||
characterActions.portraitSet(
|
||||
selectedPortrait.avatarId,
|
||||
selectedPortrait.avatarUrl
|
||||
)
|
||||
);
|
||||
}
|
||||
closeModal();
|
||||
};
|
||||
|
||||
const handleSelectPortrait = (portrait: CharacterPortraitContract): void => {
|
||||
setSelectedPortrait(portrait);
|
||||
};
|
||||
|
||||
const handlePortraitClick = (): void => {
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const closeModal = (): void => {
|
||||
setIsModalOpen(false);
|
||||
setSelectedPortrait(null);
|
||||
};
|
||||
|
||||
const avatarUrl = decorationUtils.getAvatarInfo(decorationInfo).avatarUrl;
|
||||
|
||||
return (
|
||||
<div {...props}>
|
||||
<div
|
||||
className={clsx([styles.nameContainer, fullWidth && styles.fullwidth])}
|
||||
>
|
||||
{!hidePortrait && (
|
||||
<>
|
||||
<div onClick={handlePortraitClick}>
|
||||
<CharacterAvatarPortrait
|
||||
className={clsx([styles.avatar, !avatarUrl && styles.empty])}
|
||||
avatarUrl={avatarUrl}
|
||||
data-testid="character-portrait"
|
||||
/>
|
||||
</div>
|
||||
<ConfirmModal
|
||||
open={isModalOpen}
|
||||
onClose={closeModal}
|
||||
onConfirm={onConfirmPortrait}
|
||||
heading="Manage Portrait"
|
||||
className={styles.portraitManagerModal}
|
||||
confirmButtonText="Apply"
|
||||
useMobileFullScreen
|
||||
>
|
||||
<PortraitManager handleSelectPortrait={handleSelectPortrait} />
|
||||
</ConfirmModal>
|
||||
</>
|
||||
)}
|
||||
<div className={styles.inputContainer}>
|
||||
<FormInputField
|
||||
label="Character Name"
|
||||
onBlur={handleNameFieldSet}
|
||||
initialValue={localValue}
|
||||
inputAttributes={
|
||||
{
|
||||
spellCheck: false,
|
||||
autoComplete: "off",
|
||||
} as HTMLAttributes<HTMLInputElement>
|
||||
}
|
||||
maxLength={InputLimits.characterNameMaxLength}
|
||||
maxLengthErrorMsg={CharacterNameLimitMsg}
|
||||
/>
|
||||
<Button
|
||||
className={styles.suggestionButton}
|
||||
onClick={handleShowSuggestions}
|
||||
size="xx-small"
|
||||
variant="text"
|
||||
forceThemeMode="light"
|
||||
>
|
||||
{showSuggestions ? "Hide" : "Show"} Suggestions
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{showSuggestions && (
|
||||
<div>
|
||||
<div className={styles.suggestionsContainer}>
|
||||
<div className={styles.suggestionsLabel}>Suggestions:</div>
|
||||
<div>
|
||||
<Tooltip title="Shuffle Suggestions">
|
||||
<Button
|
||||
variant="text"
|
||||
size="xx-small"
|
||||
onClick={handleSuggestMoreClick}
|
||||
className={styles.shuffleButton}
|
||||
>
|
||||
<ShuffleIcon className={styles.shuffleIcon} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className={styles.suggestions}>
|
||||
{currentSuggestedNames.map((name: string, index: number) => (
|
||||
<LabelChip
|
||||
key={index}
|
||||
onClick={() => handleNameFieldSet(name)}
|
||||
className={styles.suggestion}
|
||||
data-testid="suggestion-chip"
|
||||
>
|
||||
{name}
|
||||
</LabelChip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.credit}>
|
||||
Names by
|
||||
<Button
|
||||
className={styles.creditLink}
|
||||
href="http://www.fantasynamegenerators.com/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
variant="text"
|
||||
>
|
||||
<span className={styles.creditText}>Fantasy Name Generators</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import clsx from "clsx";
|
||||
import { ChangeEvent, FC, HTMLAttributes } from "react";
|
||||
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
export interface SearchProps extends HTMLAttributes<HTMLInputElement> {
|
||||
placeholder?: string;
|
||||
value: string;
|
||||
onChange: (e: ChangeEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
export const Search: FC<SearchProps> = ({
|
||||
className,
|
||||
placeholder = "Search...",
|
||||
value,
|
||||
onChange,
|
||||
...props
|
||||
}) => (
|
||||
<input
|
||||
type="search"
|
||||
className={clsx([styles.search, className])}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
spellCheck={false}
|
||||
autoComplete={"off"}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
{
|
||||
/* {showAddAmountControls ? this.renderAmountControls() : null} */
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import clsx from "clsx";
|
||||
import { FC, HTMLAttributes } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { Button } from "~/components/Button";
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
import { navigationConfig } from "~/tools/js/CharacterBuilder/config";
|
||||
|
||||
import { RouteKey } from "../../constants";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
export interface SpeciesDisplayProps extends HTMLAttributes<HTMLDivElement> {
|
||||
onRequestAction?: () => void;
|
||||
actionText?: string;
|
||||
headingText?: string;
|
||||
}
|
||||
|
||||
export const SpeciesDisplay: FC<SpeciesDisplayProps> = ({
|
||||
actionText,
|
||||
className,
|
||||
headingText,
|
||||
...props
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const { characterId, race, ruleData, ruleDataUtils } = useCharacterEngine();
|
||||
|
||||
const { baseName, fullName, portraitAvatarUrl, subRaceShortName } =
|
||||
race || {};
|
||||
const portrait =
|
||||
portraitAvatarUrl || ruleDataUtils.getDefaultRaceImageUrl(ruleData);
|
||||
|
||||
// Navigate back to the class manage page
|
||||
const handleNavigate = (): void =>
|
||||
navigate(
|
||||
navigationConfig
|
||||
.getRouteDefPath(RouteKey.RACE_MANAGE)
|
||||
.replace(":characterId", characterId)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={clsx([styles.speciesDisplay, className])} {...props}>
|
||||
<h3 className={styles.heading}>{headingText || `Selected Species`}</h3>
|
||||
<div className={styles.content}>
|
||||
{portrait && (
|
||||
<img
|
||||
className={styles.portrait}
|
||||
src={portrait}
|
||||
alt={fullName || baseName || ""}
|
||||
width={50}
|
||||
height={50}
|
||||
/>
|
||||
)}
|
||||
<p className={styles.species}>
|
||||
{subRaceShortName && (
|
||||
<span className={styles.subspecies}>{subRaceShortName}</span>
|
||||
)}
|
||||
{baseName}
|
||||
</p>
|
||||
<Button
|
||||
className={styles.button}
|
||||
onClick={handleNavigate}
|
||||
size="xx-small"
|
||||
variant="builder"
|
||||
>
|
||||
{actionText || `Change Species`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import clsx from "clsx";
|
||||
import { FC, HTMLAttributes, ReactNode } from "react";
|
||||
|
||||
import { AnimatedLoadingRingSvg } from "~/tools/js/smartComponents/Svg";
|
||||
|
||||
interface SpinnerProps extends HTMLAttributes<HTMLDivElement> {
|
||||
label?: ReactNode;
|
||||
}
|
||||
|
||||
export const Spinner: FC<SpinnerProps> = ({
|
||||
label = "Loading",
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<div className={clsx(["ddbc-loading-placeholder", className])} {...props}>
|
||||
<AnimatedLoadingRingSvg className="ddbc-loading-placeholder__icon" />
|
||||
<span className="ddbc-loading-placeholder__label">{label}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
export const NavigationType = {
|
||||
HelpPage: "HelpPage",
|
||||
Page: "StandardPage",
|
||||
Section: "Section",
|
||||
External: "External",
|
||||
};
|
||||
|
||||
export const RouteKey = {
|
||||
QUICK_BUILD: "ROUTE:QUICK_BUILD",
|
||||
RANDOMIZE_BUILD: "ROUTE:RANDOMIZE_BUILD",
|
||||
|
||||
HOME_HELP: "ROUTE:HOME_HELP",
|
||||
HOME_INTRODUCTION: "ROUTE:HOME_INTRODUCTION",
|
||||
HOME_BASIC_INFO: "ROUTE:HOME_BASIC_INFO",
|
||||
|
||||
RACE_HELP: "ROUTE:RACE_HELP",
|
||||
RACE_CHOOSE: "ROUTE:RACE_CHOOSE",
|
||||
RACE_MANAGE: "ROUTE:RACE_MANAGE",
|
||||
|
||||
CLASS_HELP: "ROUTE:CLASS_HELP",
|
||||
CLASS_CHOOSE: "ROUTE:CLASS_CHOOSE",
|
||||
CLASS_MANAGE: "ROUTE:CLASS_MANAGE",
|
||||
|
||||
ABILITY_SCORES_HELP: "ROUTE:ABILITY_SCORES_HELP",
|
||||
ABILITY_SCORES_MANAGE: "ROUTE:ABILITY_SCORES_MANAGE",
|
||||
|
||||
DESCRIPTION_HELP: "ROUTE:DESCRIPTION_HELP",
|
||||
DESCRIPTION_MANAGE: "ROUTE:DESCRIPTION_MANAGE",
|
||||
|
||||
EQUIPMENT_HELP: "ROUTE:EQUIPMENT_HELP",
|
||||
EQUIPMENT_MANAGE: "ROUTE:EQUIPMENT_MANAGE",
|
||||
|
||||
WHATS_NEXT: "ROUTE:WHATS_NEXT",
|
||||
};
|
||||
|
||||
export const SectionKey = {
|
||||
HOME: "SECTION:HOME",
|
||||
RACE: "SECTION:RACE",
|
||||
CLASS: "SECTION:CLASS",
|
||||
ABILITY_SCORES: "SECTION:ABILITY_SCORES",
|
||||
DESCRIPTION: "SECTION:DESCRIPTION",
|
||||
EQUIPMENT: "SECTION:EQUIPMENT",
|
||||
QUICK_BUILD: "SECTION:QUICK_BUILD",
|
||||
RANDOMIZE: "SECTION:RANDOMIZE",
|
||||
WHATS_NEXT: "SECTION:WHATS_NEXT",
|
||||
};
|
||||
|
||||
export const BuilderMethod = {
|
||||
QUICK: "QUICK",
|
||||
STEP_BY_STEP: "STEP_BY_STEP",
|
||||
RANDOMIZE: "RANDOMIZE",
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import { createContext, FC, useContext, useState } from "react";
|
||||
|
||||
import { BuilderMethod } from "../../types";
|
||||
|
||||
export interface BuilderContextType {
|
||||
builderMethod: BuilderMethod | null;
|
||||
isCharacterLoading: boolean;
|
||||
isCharacterLoaded: boolean;
|
||||
pdfUrl: string | null;
|
||||
setBuilderMethod: (method: BuilderMethod) => void;
|
||||
suggestedNames: string[];
|
||||
}
|
||||
|
||||
export const BuilderContext = createContext<BuilderContextType>({
|
||||
builderMethod: null,
|
||||
isCharacterLoading: true,
|
||||
isCharacterLoaded: false,
|
||||
pdfUrl: null,
|
||||
setBuilderMethod: () => {},
|
||||
suggestedNames: [],
|
||||
});
|
||||
|
||||
export const BuilderProvider: FC = ({ children }) => {
|
||||
const [builderMethod, setBuilderMethodState] = useState<BuilderMethod | null>(
|
||||
null
|
||||
);
|
||||
const [isCharacterLoading, setIsCharacterLoading] = useState(true);
|
||||
const [isCharacterLoaded, setIsCharacterLoaded] = useState(false);
|
||||
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
|
||||
const [suggestedNames, setSuggestedNames] = useState<string[]>([]);
|
||||
|
||||
const setBuilderMethod = async (method: BuilderMethod) => {
|
||||
// Handle setting the builder method via fetch
|
||||
};
|
||||
|
||||
// const response = yield call(ApiRequests.getCharacterRuleData, {
|
||||
// params: { v: appConfig.version },
|
||||
// });
|
||||
// const data: UnpackMakeApiResponseData<
|
||||
// typeof ApiRequests.getCharacterRuleData
|
||||
// > | null = ApiAdapterUtils.getResponseData(response);
|
||||
|
||||
// if (data !== null) {
|
||||
// yield put(ruleDataActions.dataSet(data));
|
||||
// }
|
||||
// yield put(builderActions.builderMethodSetCommit(action.payload));
|
||||
|
||||
return (
|
||||
<BuilderContext.Provider
|
||||
value={{
|
||||
builderMethod,
|
||||
isCharacterLoading,
|
||||
isCharacterLoaded,
|
||||
pdfUrl,
|
||||
setBuilderMethod,
|
||||
suggestedNames,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</BuilderContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useBuilderContext = () => {
|
||||
return useContext(BuilderContext);
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
import { createContext, FC, useContext, useEffect, useState } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
|
||||
import { ApiAdapterUtils } from "@dndbeyond/character-rules-engine";
|
||||
|
||||
import { builderActions } from "~/tools/js/CharacterBuilder/actions";
|
||||
import { apiCreatorSelectors } from "~/tools/js/Shared/selectors";
|
||||
import { ClassDefinitionContract as ClassDef } from "~/types";
|
||||
|
||||
/**
|
||||
* A context to provide data to the application without causing a re-render or
|
||||
* unnecessary fetches and transforms. This context is used in places where
|
||||
* character-specific data is not needed.
|
||||
*/
|
||||
export interface ClassContextType {
|
||||
query: string;
|
||||
setQuery: (query: string) => void;
|
||||
filteredClasses: Array<ClassDef>;
|
||||
allClasses: Array<ClassDef>;
|
||||
handleSelectClass: (id: number) => void;
|
||||
isLoading: boolean;
|
||||
isModalShowing: boolean;
|
||||
closeModal: () => void;
|
||||
}
|
||||
|
||||
export const ClassContext = createContext<ClassContextType>(null!);
|
||||
|
||||
export const ClassProvider: FC = ({ children }) => {
|
||||
const dispatch = useDispatch();
|
||||
const [query, setQuery] = useState("");
|
||||
const [allClasses, setAllClasses] = useState<Array<ClassDef>>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isModalShowing, setIsModalShowing] = useState(false);
|
||||
const closeModal = () => setIsModalShowing(false);
|
||||
|
||||
const loadClasses = useSelector(apiCreatorSelectors.makeLoadAvailableClasses);
|
||||
|
||||
const handleSelectClass = (id: number) => {
|
||||
const entity = allClasses.find((c) => c.id === id);
|
||||
if (entity) {
|
||||
dispatch(builderActions.confirmClassSet(entity));
|
||||
setIsModalShowing(true);
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle searching through items
|
||||
const filteredClasses = query
|
||||
? allClasses.filter((c) =>
|
||||
c.name?.toLowerCase().includes(query.toLowerCase())
|
||||
)
|
||||
: allClasses;
|
||||
|
||||
useEffect(() => {
|
||||
const getAllData = async () => {
|
||||
// Load all classes
|
||||
const response = await loadClasses();
|
||||
const classes = ApiAdapterUtils.getResponseData(response) || [];
|
||||
|
||||
setAllClasses(classes);
|
||||
|
||||
if (classes.length > 0) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
getAllData();
|
||||
}, [loadClasses]);
|
||||
|
||||
return (
|
||||
<ClassContext.Provider
|
||||
value={{
|
||||
query,
|
||||
setQuery,
|
||||
filteredClasses,
|
||||
allClasses,
|
||||
handleSelectClass,
|
||||
isLoading,
|
||||
isModalShowing,
|
||||
closeModal,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ClassContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useClassContext = () => {
|
||||
return useContext(ClassContext);
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import { createContext, FC, useContext, useState } from "react";
|
||||
|
||||
import { ConfirmModal, ConfirmModalProps } from "~/components/ConfirmModal";
|
||||
|
||||
export interface ModalData {
|
||||
content: React.ReactNode;
|
||||
props: Omit<ConfirmModalProps, "open">;
|
||||
}
|
||||
export interface ModalManagerContextType {
|
||||
createModal: (modalData: ModalData) => void;
|
||||
}
|
||||
|
||||
export const ModalManagerContext = createContext<ModalManagerContextType>(
|
||||
null!
|
||||
);
|
||||
|
||||
export const ModalManagerProvider: FC = ({ children }) => {
|
||||
const [modalData, setModalData] = useState<ModalData | null>(null);
|
||||
|
||||
const createModal = (modalData: ModalData) => {
|
||||
setModalData(modalData);
|
||||
};
|
||||
|
||||
const removeModal = () => {
|
||||
setModalData(null);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
removeModal();
|
||||
if (modalData) {
|
||||
modalData.props.onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
removeModal();
|
||||
if (modalData) {
|
||||
modalData.props.onConfirm();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalManagerContext.Provider value={{ createModal }}>
|
||||
{children}
|
||||
<ConfirmModal
|
||||
{...modalData?.props}
|
||||
onConfirm={handleConfirm}
|
||||
onClose={handleClose}
|
||||
open={!!modalData}
|
||||
>
|
||||
{modalData?.content}
|
||||
</ConfirmModal>
|
||||
</ModalManagerContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useModalManager = () => {
|
||||
return useContext(ModalManagerContext);
|
||||
};
|
||||
@@ -0,0 +1,302 @@
|
||||
import { groupBy, keyBy, uniqBy } from "lodash";
|
||||
import { createContext, FC, useContext, useEffect, useState } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
import { useSource } from "~/hooks/useSource";
|
||||
import { apiCreatorSelectors } from "~/tools/js/Shared/selectors";
|
||||
import { TypeScriptUtils } from "~/tools/js/Shared/utils";
|
||||
import {
|
||||
HtmlSelectOption,
|
||||
HtmlSelectOptionGroup,
|
||||
RaceDefinitionContract as RaceDef,
|
||||
RaceDefinitionContract,
|
||||
SimpleSourcedDefinitionContract,
|
||||
} from "~/types";
|
||||
|
||||
import { GroupedListingItem, ListingItem } from "../../types";
|
||||
|
||||
export interface SpeciesContextType {
|
||||
closeModal: () => void;
|
||||
defaultSpeciesImageUrl: string;
|
||||
isLegacyShowing: boolean;
|
||||
isModalShowing: boolean;
|
||||
query: string;
|
||||
selectedSpecies?: RaceDef;
|
||||
setQuery: (query: string) => void;
|
||||
source: string;
|
||||
sourceOptions: (HtmlSelectOptionGroup | HtmlSelectOption)[];
|
||||
filteredSpecies: RaceDef[];
|
||||
allSpecies: RaceDef[];
|
||||
toggleLegacyContent: () => void;
|
||||
transformSpecies: (data: RaceDef[]) => ListingItem[];
|
||||
setSource: (source: string) => void;
|
||||
isLoading: boolean;
|
||||
getSpeciesInGroups: (
|
||||
species: ListingItem[]
|
||||
) => (ListingItem | GroupedListingItem)[];
|
||||
}
|
||||
|
||||
export const SpeciesContext = createContext<SpeciesContextType>(null!);
|
||||
|
||||
export const SpeciesProvider: FC = ({ children }) => {
|
||||
const {
|
||||
apiAdapterUtils: { getResponseData },
|
||||
raceUtils: { getSubRaceShortName, getBaseName, getIsHomebrew, getSources },
|
||||
ruleData,
|
||||
ruleDataUtils: { getDefaultRaceImageUrl, getRaceGroups },
|
||||
characterUtils: { isPrimarySource },
|
||||
} = useCharacterEngine();
|
||||
const { getSourceDescription, getGroupedOptionsBySourceCategory } =
|
||||
useSource();
|
||||
|
||||
const [allSpecies, setAllSpecies] = useState<Array<RaceDef>>([]);
|
||||
const [isLegacyShowing, setIsLegacyShowing] = useState(true);
|
||||
const [isModalShowing, setIsModalShowing] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [selectedSpecies, setSelectedSpecies] = useState<RaceDef>();
|
||||
const [source, setSource] = useState("");
|
||||
const [sourceOptions, setSourceOptions] = useState<
|
||||
(HtmlSelectOptionGroup | HtmlSelectOption)[]
|
||||
>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const loadSpecies = useSelector(apiCreatorSelectors.makeLoadAvailableRaces);
|
||||
|
||||
const defaultSpeciesImageUrl = getDefaultRaceImageUrl(ruleData) ?? "";
|
||||
|
||||
const handleFilteredSpecies = () => {
|
||||
let filtered = allSpecies;
|
||||
//Check legacy toggle
|
||||
if (!isLegacyShowing) {
|
||||
filtered = filtered.filter((s) => !s.isLegacy);
|
||||
}
|
||||
|
||||
// Check sources
|
||||
if (source !== "") {
|
||||
if (source === "HOMEBREW") {
|
||||
filtered = filtered.filter((s) => s.isHomebrew);
|
||||
} else {
|
||||
filtered = filtered.filter((s) =>
|
||||
s.sources?.some(({ sourceId }) => source === sourceId.toString())
|
||||
);
|
||||
}
|
||||
}
|
||||
// Check querys
|
||||
if (query !== "") {
|
||||
filtered = filtered.filter((s) => {
|
||||
return s.fullName?.toLowerCase().includes(query.toLowerCase());
|
||||
});
|
||||
}
|
||||
return filtered;
|
||||
};
|
||||
// Handle searching through items
|
||||
const filteredSpecies = handleFilteredSpecies();
|
||||
|
||||
const onSelectSpecies = (id: string) => {
|
||||
const entity = allSpecies.find(
|
||||
(s) => `${s.entityRaceId}-${s.entityRaceTypeId}` === id
|
||||
);
|
||||
if (entity) {
|
||||
setSelectedSpecies(entity);
|
||||
setIsModalShowing(true);
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
};
|
||||
|
||||
const closeModal = () => setIsModalShowing(false);
|
||||
|
||||
const toggleLegacyContent = () => {
|
||||
setIsLegacyShowing(!isLegacyShowing);
|
||||
};
|
||||
|
||||
//Transform Species data into ListingItems
|
||||
const transformSpecies = (data: RaceDef[]): ListingItem[] => {
|
||||
return data.map((species) => {
|
||||
const shortName = getSubRaceShortName(species);
|
||||
const baseName = getBaseName(species);
|
||||
const heading = shortName ? `${shortName} ${baseName}` : baseName || "";
|
||||
const text = getIsHomebrew(species)
|
||||
? "Homebrew"
|
||||
: getSourceDescription(species?.sources?.[0]?.sourceId || 0) || "";
|
||||
|
||||
return {
|
||||
type: "species",
|
||||
id: `${species.entityRaceId}-${species.entityRaceTypeId}`,
|
||||
entityTypeId: species.entityRaceTypeId,
|
||||
heading,
|
||||
text,
|
||||
image: species.portraitAvatarUrl || defaultSpeciesImageUrl,
|
||||
entity: species,
|
||||
isLegacy: species.isLegacy,
|
||||
onClick: onSelectSpecies,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// get the ListingItems for species, both grouped and individual
|
||||
const getSpeciesInGroups = (
|
||||
species: ListingItem[]
|
||||
): (ListingItem | GroupedListingItem)[] => {
|
||||
let groupedSpecies: ListingItem[] = [];
|
||||
let unGroupedSpecies: ListingItem[] = [];
|
||||
|
||||
//species that have a groupId go into the groupedSpecies array and species that don't have a groupId go into the unGroupedSpecies array
|
||||
species.forEach((s) => {
|
||||
const groupIds = (s.entity as RaceDefinitionContract)?.groupIds;
|
||||
if (groupIds && groupIds.length > 0) {
|
||||
groupIds.forEach((groupId) => {
|
||||
groupedSpecies.push({
|
||||
...s,
|
||||
groupId,
|
||||
});
|
||||
});
|
||||
} else {
|
||||
unGroupedSpecies.push(s);
|
||||
}
|
||||
});
|
||||
|
||||
//Create a lookup of grouped species by groupId
|
||||
const groupedSpeciesLookup = groupBy(groupedSpecies, "groupId");
|
||||
//Create a lookup from all Species Groups from rules data
|
||||
const groupDataLookup = keyBy(getRaceGroups(ruleData), "id");
|
||||
//This array will hold the final list of grouped species
|
||||
const groups: GroupedListingItem[] = [];
|
||||
|
||||
//Iterate through the grouped species lookup to create final groupings
|
||||
Object.keys(groupedSpeciesLookup).forEach((groupId) => {
|
||||
const groupItems = groupedSpeciesLookup[groupId];
|
||||
//If the group only has one item, it is not a group - put it in the unGroupedSpecies array, otherwise create the Grouped Listing Item for the group
|
||||
if (groupItems.length === 1) {
|
||||
unGroupedSpecies.push(groupItems[0]);
|
||||
} else {
|
||||
const groupInfo = groupDataLookup[parseInt(groupId)];
|
||||
groups.push({
|
||||
id: groupId,
|
||||
type: "group",
|
||||
image: groupInfo.avatarUrl ?? "",
|
||||
heading: groupInfo.name ?? "",
|
||||
groupId: parseInt(groupId),
|
||||
listItems: groupItems,
|
||||
onClick: undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return [...groups, ...unGroupedSpecies];
|
||||
};
|
||||
|
||||
const getSpecies = async () => {
|
||||
const species = getResponseData(await loadSpecies()) || [];
|
||||
|
||||
setAllSpecies(species);
|
||||
if (species.length > 0) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Set the source options when the species are loaded
|
||||
useEffect(() => {
|
||||
let shouldContaineHomebrewCategory = false;
|
||||
let sourceShouldReset = true;
|
||||
|
||||
const sourcesData: Array<SimpleSourcedDefinitionContract> = allSpecies
|
||||
.map((species) => {
|
||||
//Flag homebrew category
|
||||
if (species.isHomebrew) {
|
||||
if (!shouldContaineHomebrewCategory) {
|
||||
shouldContaineHomebrewCategory = true;
|
||||
}
|
||||
|
||||
//don't reset source state if we have a homebrew category and the current source is homebrew
|
||||
if (shouldContaineHomebrewCategory && source === "HOMEBREW") {
|
||||
sourceShouldReset = false;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const primarySourceId =
|
||||
getSources(species).filter(isPrimarySource)[0]?.sourceId;
|
||||
|
||||
//If there is no primary source and the above filter hasn't already returned null for isHombrew, return null
|
||||
if (!primarySourceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
//don't reset source state if the current source is the sourceId of a species
|
||||
if (primarySourceId.toString() === source) {
|
||||
sourceShouldReset = false;
|
||||
}
|
||||
|
||||
return {
|
||||
sources: getSources(species),
|
||||
name: getSourceDescription(primarySourceId) || "Unknown",
|
||||
id: primarySourceId,
|
||||
};
|
||||
})
|
||||
.filter(TypeScriptUtils.isNotNullOrUndefined);
|
||||
|
||||
const uniqueSources = uniqBy(sourcesData, "id");
|
||||
const groupedOptions = getGroupedOptionsBySourceCategory(uniqueSources);
|
||||
|
||||
const homebrewOptions: HtmlSelectOption[] = [
|
||||
{
|
||||
label: "Homebrew",
|
||||
value: "HOMEBREW",
|
||||
},
|
||||
{
|
||||
label: "---------",
|
||||
disabled: true,
|
||||
value: "-----",
|
||||
},
|
||||
];
|
||||
const sourceOptions: (HtmlSelectOptionGroup | HtmlSelectOption)[] = [];
|
||||
if (shouldContaineHomebrewCategory) {
|
||||
sourceOptions.push(...homebrewOptions);
|
||||
}
|
||||
|
||||
sourceOptions.push(...groupedOptions);
|
||||
|
||||
setSourceOptions(sourceOptions);
|
||||
|
||||
//reset source state if the source isn't valid for the list of current species so that the list is updated
|
||||
if (sourceShouldReset) {
|
||||
setSource("");
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [allSpecies, source]);
|
||||
|
||||
useEffect(() => {
|
||||
getSpecies();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [loadSpecies]);
|
||||
|
||||
return (
|
||||
<SpeciesContext.Provider
|
||||
value={{
|
||||
closeModal,
|
||||
defaultSpeciesImageUrl,
|
||||
isLegacyShowing,
|
||||
isModalShowing,
|
||||
query,
|
||||
selectedSpecies,
|
||||
setQuery,
|
||||
sourceOptions,
|
||||
filteredSpecies,
|
||||
allSpecies,
|
||||
toggleLegacyContent,
|
||||
transformSpecies,
|
||||
source,
|
||||
setSource,
|
||||
isLoading,
|
||||
getSpeciesInGroups,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</SpeciesContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useSpeciesContext = () => {
|
||||
return useContext(SpeciesContext);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { PrerequisiteFailure } from "../types";
|
||||
|
||||
// Format a group of PrerequisiteFailures into a human-readable string
|
||||
const formatReq = (group: Array<PrerequisiteFailure>) =>
|
||||
group.map(({ data }) => {
|
||||
const key = data.statKey?.toUpperCase() || "";
|
||||
const value = data.requiredValue;
|
||||
const amount = data.currentAmount === null ? "None" : data.currentAmount;
|
||||
|
||||
return `${key} ${value} (${amount})`;
|
||||
});
|
||||
|
||||
// Loop through groups of PrerequisiteFailures and format them into a human-readable string
|
||||
export const getMissingRequirements = (
|
||||
groups: Array<Array<PrerequisiteFailure>>
|
||||
) => groups.map((group) => formatReq(group).join(", ")).join(" or ");
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useDispatch } from "react-redux";
|
||||
|
||||
import { ContainerTypeEnum } from "~/constants";
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
import { useUnpropagatedClick } from "~/hooks/useUnpropagatedClick";
|
||||
import { useInventoryManager } from "~/tools/js/Shared/managers/InventoryManagerContext";
|
||||
import { Item } from "~/types";
|
||||
|
||||
import { useModalManager } from "../contexts/ModalManager";
|
||||
|
||||
export const useEquipmentMethods = () => {
|
||||
const dispatch = useDispatch();
|
||||
const {
|
||||
containerLookup,
|
||||
containerUtils,
|
||||
definitionUtils,
|
||||
helperUtils,
|
||||
itemUtils,
|
||||
infusionUtils,
|
||||
serviceDataActions,
|
||||
} = useCharacterEngine();
|
||||
const { inventoryManager } = useInventoryManager();
|
||||
const { createModal } = useModalManager();
|
||||
|
||||
const launchContainerRemovalModal = (item: Item) =>
|
||||
createModal({
|
||||
content: (
|
||||
<div className="equipment-manage__warning">
|
||||
<p>
|
||||
Removing the <strong>{itemUtils.getName(item)}</strong> will also
|
||||
remove all of its contents.
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
props: {
|
||||
heading: `Remove ${itemUtils.getName(item)}`,
|
||||
onConfirm: () => {
|
||||
inventoryManager.handleRemove({ item });
|
||||
},
|
||||
onClose: () => {}, //Do nothing
|
||||
variant: "remove",
|
||||
size: "fit-content",
|
||||
confirmButtonText: "Remove",
|
||||
},
|
||||
});
|
||||
|
||||
const onRemove = (item) => {
|
||||
const isContainer = itemUtils.isContainer(item);
|
||||
if (isContainer) {
|
||||
launchContainerRemovalModal(item);
|
||||
} else {
|
||||
inventoryManager.handleRemove({ item });
|
||||
}
|
||||
};
|
||||
|
||||
const onRemoveInfusion = (item) => {
|
||||
const infusion = itemUtils.getInfusion(item);
|
||||
if (infusion) {
|
||||
const infusionId = infusionUtils.getId(infusion);
|
||||
if (infusionId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (itemUtils.isContainer(item)) {
|
||||
const container = helperUtils.lookupDataOrFallback(
|
||||
containerLookup,
|
||||
definitionUtils.hack__generateDefinitionKey(
|
||||
ContainerTypeEnum.ITEM,
|
||||
itemUtils.getMappingId(item)
|
||||
)
|
||||
);
|
||||
if (container) {
|
||||
if (containerUtils.hasInfusions(container)) {
|
||||
// Pop up the infusion modal
|
||||
createModal({
|
||||
content: (
|
||||
<div className="equipment-manage__warning">
|
||||
<p>
|
||||
<strong>Notice:</strong> In accordance with Artificer
|
||||
guidance the Waste Management Guild of Sharn (the City of
|
||||
Towers) requires the removal of all Infusions prior to
|
||||
disposal of any items.
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
props: {
|
||||
heading: `Remove ${itemUtils.getName(item)}: Infusions?`,
|
||||
onConfirm: () => {
|
||||
dispatch(
|
||||
serviceDataActions.infusionMappingsDestroy(
|
||||
containerUtils.getInfusedItemMappingIds(container)
|
||||
)
|
||||
);
|
||||
launchContainerRemovalModal(item);
|
||||
},
|
||||
onClose: () => {}, //Do nothing
|
||||
variant: "remove",
|
||||
size: "fit-content",
|
||||
confirmButtonText: "Remove",
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// pop up the container modal
|
||||
launchContainerRemovalModal(item);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
dispatch(
|
||||
serviceDataActions.infusionMappingDestroy(
|
||||
infusionId,
|
||||
infusionUtils.getInventoryMappingId(infusion)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onUnequip = (item) => inventoryManager.handleUnequip({ item });
|
||||
|
||||
const onEquip = (item) => inventoryManager.handleEquip({ item });
|
||||
|
||||
const onAttune = (item) => inventoryManager.handleAttune({ item });
|
||||
|
||||
const onUnattune = (item) => inventoryManager.handleUnattune({ item });
|
||||
|
||||
const onQuantitySet = (item, quantity) =>
|
||||
inventoryManager.handleQuantitySet({ item, quantity });
|
||||
|
||||
const onToggleEquip = useUnpropagatedClick((e, item: Item) => {
|
||||
const isEquipped = itemUtils.isEquipped(item);
|
||||
if (isEquipped) {
|
||||
if (onUnequip) onUnequip(item);
|
||||
} else {
|
||||
if (onEquip) onEquip(item);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
onAttune,
|
||||
onEquip,
|
||||
onQuantitySet,
|
||||
onRemove,
|
||||
onRemoveInfusion,
|
||||
onToggleEquip,
|
||||
onUnattune,
|
||||
onUnequip,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,169 @@
|
||||
import clsx from "clsx";
|
||||
import { HTMLAttributes, ReactNode, useEffect, useState } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { useNavigate, Link } from "react-router-dom";
|
||||
|
||||
import {
|
||||
characterEnvSelectors,
|
||||
Constants,
|
||||
} from "@dndbeyond/character-rules-engine";
|
||||
import ChevronRight from "@dndbeyond/fontawesome-cache/svgs/solid/chevron-right.svg";
|
||||
|
||||
import { Button } from "~/components/Button";
|
||||
import { Checkbox } from "~/components/Checkbox";
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
import { builderActions } from "~/tools/js/CharacterBuilder/actions";
|
||||
import { navigationConfig } from "~/tools/js/CharacterBuilder/config";
|
||||
import { NavigationUtils } from "~/tools/js/CharacterBuilder/utils";
|
||||
|
||||
import { BuilderMethod, RouteKey } from "../../constants";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLDivElement> {
|
||||
isEnabled?: boolean;
|
||||
}
|
||||
|
||||
export const BuilderTypeChoicePage: React.FC<Props> = ({
|
||||
isEnabled = true,
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
const params = new URLSearchParams(globalThis.location.search);
|
||||
const isMobileApp = params.get("platform");
|
||||
const [showHelpText, setShowHelpText] = useState(false);
|
||||
const dispatch = useDispatch();
|
||||
const navigate = useNavigate();
|
||||
const { characterId } = useCharacterEngine();
|
||||
const characterIsLoading = useSelector(
|
||||
characterEnvSelectors.getLoadingStatus
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (characterIsLoading === Constants.CharacterLoadingStatusEnum.LOADED) {
|
||||
navigate(NavigationUtils.getCharacterBuilderUrl(characterId), {
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
}, [characterIsLoading, characterId, navigate]);
|
||||
|
||||
const handleMethodSet = (method: string) => {
|
||||
if (method === BuilderMethod.STEP_BY_STEP) {
|
||||
dispatch(builderActions.stepBuildRequest(showHelpText));
|
||||
} else {
|
||||
dispatch(builderActions.builderMethodSet(method));
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyUp = (
|
||||
evt: React.KeyboardEvent<HTMLDivElement>,
|
||||
method: string
|
||||
): void => {
|
||||
if (evt.key === "Enter") {
|
||||
handleMethodSet(method);
|
||||
}
|
||||
};
|
||||
|
||||
const renderFooter = (text: string = "Start Building") => (
|
||||
<div className={clsx([styles.cardFooter, styles.font])}>
|
||||
<p>{text}</p>
|
||||
<ChevronRight className={styles.icon} />
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderCardContent = (
|
||||
title: string,
|
||||
description: string,
|
||||
extraNode?: ReactNode
|
||||
) => (
|
||||
<div className={styles.cardContent}>
|
||||
<h3 className={clsx([styles.cardTitle, styles.font])}>{title}</h3>
|
||||
<p className={clsx([styles.cardDescription, styles.font])}>
|
||||
{description}
|
||||
</p>
|
||||
{extraNode}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={clsx([styles.page, className])} {...props}>
|
||||
<h1 className={styles.header}>Character Creation Method</h1>
|
||||
<h2 className={clsx([styles.header])}>
|
||||
Choose how you would like to create your character
|
||||
</h2>
|
||||
<section className={styles.grid}>
|
||||
{/* STANDARD BUILD */}
|
||||
<div
|
||||
onClick={() => handleMethodSet(BuilderMethod.STEP_BY_STEP)}
|
||||
onKeyUp={(event) => onKeyUp(event, BuilderMethod.STEP_BY_STEP)}
|
||||
className={styles.card}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className={clsx([styles.img, styles.standardBuild])} />
|
||||
{renderCardContent(
|
||||
"Standard",
|
||||
"Create a character using a step-by-step approach",
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (isEnabled) setShowHelpText(!showHelpText);
|
||||
}}
|
||||
className={styles.helpCheckbox}
|
||||
>
|
||||
<Checkbox
|
||||
id="q-include-help"
|
||||
aria-labelledby="include-help-text"
|
||||
checked={showHelpText}
|
||||
disabled={!isEnabled}
|
||||
darkMode
|
||||
/>
|
||||
<label
|
||||
id="include-help-text"
|
||||
htmlFor="q-include-help"
|
||||
className={styles.helpText}
|
||||
>
|
||||
<strong>Beginner?</strong> Show help text
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
{renderFooter()}
|
||||
</div>
|
||||
{/* PREMADES */}
|
||||
{!isMobileApp && (
|
||||
<Button href="/characters/premade" className={styles.card}>
|
||||
<div className={clsx([styles.img, styles.premades])} />
|
||||
{renderCardContent(
|
||||
"Premade",
|
||||
"Browse a selection of ready-to-play, premade characters and claim one to your account."
|
||||
)}
|
||||
{renderFooter("Start Browsing")}
|
||||
</Button>
|
||||
)}
|
||||
{/* QUICK BUILD */}
|
||||
<Link
|
||||
className={styles.card}
|
||||
to={navigationConfig.getRouteDefPath(RouteKey.QUICK_BUILD)}
|
||||
onClick={() => handleMethodSet(BuilderMethod.QUICK)}
|
||||
>
|
||||
{renderCardContent(
|
||||
"Quick Build",
|
||||
"Choose a species and class to quickly create a level 1 character."
|
||||
)}
|
||||
{renderFooter()}
|
||||
</Link>
|
||||
{/* RANDOM BUILD */}
|
||||
<Link
|
||||
className={styles.card}
|
||||
to={navigationConfig.getRouteDefPath(RouteKey.RANDOMIZE_BUILD)}
|
||||
onClick={() => handleMethodSet(BuilderMethod.RANDOMIZE)}
|
||||
>
|
||||
{renderCardContent(
|
||||
"Random",
|
||||
"Roll up a randomized character! You can optionally set some parameters such as level, species, and class."
|
||||
)}
|
||||
{renderFooter()}
|
||||
</Link>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,330 @@
|
||||
import clsx from "clsx";
|
||||
import { FC, HTMLAttributes, ReactNode } from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
|
||||
import {
|
||||
characterActions,
|
||||
CharacterUtils,
|
||||
CharClass,
|
||||
ClassUtils,
|
||||
Constants,
|
||||
FormatUtils,
|
||||
HelperUtils,
|
||||
HtmlSelectOption,
|
||||
} from "@dndbeyond/character-rules-engine";
|
||||
import CloseIcon from "@dndbeyond/fontawesome-cache/svgs/solid/x.svg";
|
||||
|
||||
import { Button } from "~/components/Button";
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
import { useModalManager } from "~/subApps/builder/contexts/ModalManager";
|
||||
import { Select } from "~/tools/js/smartComponents/legacy";
|
||||
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
export interface ClassHeaderProps extends HTMLAttributes<HTMLDivElement> {
|
||||
charClass: CharClass;
|
||||
isMulticlass: boolean;
|
||||
levelsRemaining: number;
|
||||
}
|
||||
|
||||
export const ClassHeader: FC<ClassHeaderProps> = ({
|
||||
charClass,
|
||||
isMulticlass,
|
||||
levelsRemaining,
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const { preferences, ruleData, classes, currentLevel, totalClassLevel } =
|
||||
useCharacterEngine();
|
||||
const { createModal } = useModalManager();
|
||||
|
||||
const portraitAvatarUrl = ClassUtils.getPortraitUrl(charClass);
|
||||
const isStartingClass = ClassUtils.isStartingClass(charClass);
|
||||
const level = ClassUtils.getLevel(charClass);
|
||||
const name = ClassUtils.getName(charClass);
|
||||
const subclassDefinition = ClassUtils.getSubclass(charClass);
|
||||
const classId = ClassUtils.getActiveId(charClass);
|
||||
|
||||
const levelsDiff: number = currentLevel - totalClassLevel;
|
||||
const isTypeMilestone: boolean =
|
||||
preferences.progressionType ===
|
||||
Constants.PreferenceProgressionTypeEnum.MILESTONE;
|
||||
const isTypeXp: boolean =
|
||||
preferences.progressionType === Constants.PreferenceProgressionTypeEnum.XP;
|
||||
const hasLevelXpDiff: boolean = levelsDiff !== 0 && isTypeXp;
|
||||
|
||||
let levelOptions: Array<HtmlSelectOption> = [];
|
||||
for (let i = 1; i <= level + levelsRemaining; i++) {
|
||||
levelOptions.push({
|
||||
label: "" + i,
|
||||
value: i,
|
||||
});
|
||||
}
|
||||
|
||||
const handleLevelChangePromise = (
|
||||
newLevel: string,
|
||||
oldLevel: string,
|
||||
accept: () => void,
|
||||
reject: () => void
|
||||
): void => {
|
||||
const newLevelValue = HelperUtils.parseInputInt(newLevel, 0);
|
||||
const oldLevelValue = HelperUtils.parseInputInt(oldLevel, 0);
|
||||
|
||||
const newTotalClassLevel: number = classes.reduce(
|
||||
(acc: number, oldClass) =>
|
||||
(acc += oldClass.id === charClass.id ? newLevelValue : oldClass.level),
|
||||
0
|
||||
);
|
||||
const isLevelUp: boolean = newLevelValue > oldLevelValue;
|
||||
const isLevelDown: boolean = newLevelValue < oldLevelValue;
|
||||
|
||||
// Modals to handle milestone
|
||||
if (isLevelUp && isTypeMilestone) {
|
||||
dispatch(characterActions.classLevelSetRequest(classId, newLevelValue));
|
||||
accept();
|
||||
}
|
||||
|
||||
if (isLevelDown && isTypeMilestone) {
|
||||
createModal({
|
||||
content: (
|
||||
<div>
|
||||
{renderModalIntro(currentLevel, newTotalClassLevel)}
|
||||
<p>Are you sure you want to level down in the {name} class?</p>
|
||||
<p>
|
||||
Your hit points will be reduced by the fixed amount and class
|
||||
feature choices you have made for the higher levels will be lost.
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
props: {
|
||||
heading: "Level Down",
|
||||
color: "secondary",
|
||||
confirmButtonText: "Level Down",
|
||||
size: "fit-content",
|
||||
onConfirm: () => {
|
||||
dispatch(
|
||||
characterActions.classLevelSetRequest(classId, newLevelValue)
|
||||
);
|
||||
accept();
|
||||
},
|
||||
onClose: () => {
|
||||
reject();
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Modals to handle progression
|
||||
if (isLevelUp && isTypeXp) {
|
||||
if (newTotalClassLevel <= currentLevel) {
|
||||
dispatch(characterActions.classLevelSetRequest(classId, newLevelValue));
|
||||
accept();
|
||||
} else {
|
||||
createModal({
|
||||
content: (
|
||||
<div>
|
||||
{renderModalIntro(currentLevel, newTotalClassLevel)}
|
||||
<p>Are you sure you want to level up in the {name} class?</p>
|
||||
<p>
|
||||
Your XP total will be increased to{" "}
|
||||
{FormatUtils.renderLocaleNumber(
|
||||
CharacterUtils.deriveCurrentLevelXp(
|
||||
newTotalClassLevel,
|
||||
ruleData
|
||||
)
|
||||
)}{" "}
|
||||
to match your new level.
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
props: {
|
||||
heading: "Level Up",
|
||||
confirmButtonText: "Level Up",
|
||||
size: "fit-content",
|
||||
onConfirm: () => {
|
||||
dispatch(
|
||||
characterActions.classLevelSetRequest(
|
||||
classId,
|
||||
newLevelValue,
|
||||
CharacterUtils.deriveCurrentLevelXp(
|
||||
newTotalClassLevel,
|
||||
ruleData
|
||||
)
|
||||
)
|
||||
);
|
||||
accept();
|
||||
},
|
||||
onClose: () => {
|
||||
reject();
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (isLevelDown && isTypeXp) {
|
||||
createModal({
|
||||
content: (
|
||||
<div>
|
||||
{renderModalIntro(currentLevel, newTotalClassLevel)}
|
||||
<p>Are you sure you want to level down in the {name} class?</p>
|
||||
<p>
|
||||
Your hit points will be reduced by the fixed amount and class
|
||||
feature choices you have made for the higher levels will be lost.
|
||||
</p>
|
||||
<p>
|
||||
Your XP total will be decreased to{" "}
|
||||
{FormatUtils.renderLocaleNumber(
|
||||
CharacterUtils.deriveCurrentLevelXp(
|
||||
newTotalClassLevel,
|
||||
ruleData
|
||||
)
|
||||
)}{" "}
|
||||
to match your new level.
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
props: {
|
||||
heading: "Level Down",
|
||||
confirmButtonText: "Level Down",
|
||||
color: "secondary",
|
||||
size: "fit-content",
|
||||
onConfirm: () => {
|
||||
dispatch(
|
||||
characterActions.classLevelSetRequest(
|
||||
classId,
|
||||
newLevelValue,
|
||||
CharacterUtils.deriveCurrentLevelXp(
|
||||
newTotalClassLevel,
|
||||
ruleData
|
||||
)
|
||||
)
|
||||
);
|
||||
accept();
|
||||
},
|
||||
onClose: () => {
|
||||
reject();
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveClass = (): void => {
|
||||
const newTotalClassLevel: number = classes.reduce(
|
||||
(acc: number, oldClass) =>
|
||||
(acc += oldClass.id === charClass.id ? 0 : oldClass.level),
|
||||
0
|
||||
);
|
||||
|
||||
createModal({
|
||||
content: (
|
||||
<div>
|
||||
{renderModalIntro()}
|
||||
<p>
|
||||
Are you sure you want to remove all your levels in the {name} class?
|
||||
</p>
|
||||
<p>
|
||||
Your hit points will be reduced by the fixed amount and class
|
||||
feature choices you have made for the higher levels will be lost.
|
||||
</p>
|
||||
{isTypeXp && (
|
||||
<p>Your XP total will be decreased to match your new level.</p>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
props: {
|
||||
heading: "Remove Class",
|
||||
variant: "remove",
|
||||
size: "fit-content",
|
||||
confirmButtonText: "Remove",
|
||||
onConfirm: () => {
|
||||
let newCharacterXp: number | null = isTypeXp
|
||||
? CharacterUtils.deriveCurrentLevelXp(newTotalClassLevel, ruleData)
|
||||
: null;
|
||||
dispatch(
|
||||
characterActions.classRemoveRequest(charClass.id, newCharacterXp)
|
||||
);
|
||||
},
|
||||
onClose: () => {}, //Do nothing
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const renderModalIntro = (
|
||||
currentLevel: number | null = null,
|
||||
newLevel: number | null = null
|
||||
): React.ReactNode => {
|
||||
return (
|
||||
<div className={styles.modalIntro}>
|
||||
{classPortraitName}
|
||||
{currentLevel !== null && newLevel !== null && (
|
||||
<div className={styles.modalLevels}>
|
||||
<div className={styles.modalLevel}>
|
||||
Current Level: {currentLevel}
|
||||
</div>
|
||||
<div className={styles.modalLevel}>New Level: {newLevel}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const classPortraitName: ReactNode = (
|
||||
<>
|
||||
<img
|
||||
className={styles.classImg}
|
||||
src={portraitAvatarUrl}
|
||||
alt={`${name} Class avatar`}
|
||||
/>
|
||||
<div className={styles.nameGroup}>
|
||||
{subclassDefinition && (
|
||||
<div className={styles.subclassName}>{subclassDefinition.name}</div>
|
||||
)}
|
||||
<div className={styles.name} data-testid="class-name">
|
||||
{name}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx([styles.classHeader, styles.container, className])}
|
||||
{...props}
|
||||
>
|
||||
{isMulticlass && isStartingClass && (
|
||||
<div className={styles.startingClass}>Starting Class</div>
|
||||
)}
|
||||
{classPortraitName}
|
||||
<div className={styles.container}>
|
||||
<div className={styles.levelManager}>
|
||||
<label
|
||||
className={styles.levelLabel}
|
||||
htmlFor={`class-level-${classId}`}
|
||||
>
|
||||
{hasLevelXpDiff && <span className={styles.todoIcon}>!</span>}
|
||||
Level
|
||||
</label>
|
||||
<Select
|
||||
id={`class-level-${classId}`}
|
||||
value={level}
|
||||
options={levelOptions}
|
||||
initialOptionRemoved={true}
|
||||
onChangePromise={handleLevelChangePromise}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className={styles.removeClassButton}
|
||||
size="x-small"
|
||||
variant="text"
|
||||
onClick={handleRemoveClass}
|
||||
forceThemeMode="light"
|
||||
>
|
||||
<CloseIcon className={styles.icon} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,410 @@
|
||||
import clsx from "clsx";
|
||||
import { FC, HTMLAttributes, useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { Accordion } from "~/components/Accordion";
|
||||
import { HtmlContent } from "~/components/HtmlContent";
|
||||
import { Link } from "~/components/Link";
|
||||
import { PreferenceProgressionTypeEnum as progressionType } from "~/constants";
|
||||
import { orderBy } from "~/helpers/sortUtils";
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
import { useSource } from "~/hooks/useSource";
|
||||
import { navigationConfig } from "~/tools/js/CharacterBuilder/config";
|
||||
|
||||
import { Button } from "../../../../components/Button";
|
||||
import { ConfirmClassModal } from "../../components/ConfirmClassModal";
|
||||
import { Listing } from "../../components/Listing";
|
||||
import { PortraitName } from "../../components/PortraitName";
|
||||
import { Search } from "../../components/Search";
|
||||
import { Spinner } from "../../components/Spinner";
|
||||
import { RouteKey } from "../../constants";
|
||||
import { useClassContext } from "../../contexts/Class";
|
||||
import { getMissingRequirements } from "../../helpers/getMissingRequirements";
|
||||
import {
|
||||
ClassDefinitionContract,
|
||||
ClassItems,
|
||||
ClassRequirements,
|
||||
ListingItem,
|
||||
MulticlassAvailability,
|
||||
} from "../../types";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
//showHeader and OnQuickSelect are only be used from QuickBuild.tsx and RandomBuild.tsx
|
||||
export interface ClassChooseProps extends HTMLAttributes<HTMLDivElement> {
|
||||
showHeader?: boolean;
|
||||
onQuickSelect?: (charClass: ClassDefinitionContract) => void;
|
||||
}
|
||||
|
||||
export interface SourceGroupMapping {
|
||||
name: string;
|
||||
id: number;
|
||||
items: ClassDefinitionContract[];
|
||||
sortOrder: number | undefined;
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The page which lists all available classes for the user to choose
|
||||
* from when building or updating a character. Can be found at
|
||||
* `/characters/:characterId/builder/class/choose`.
|
||||
*/
|
||||
export const ClassChoose: FC<ClassChooseProps> = ({
|
||||
showHeader = true,
|
||||
className,
|
||||
onQuickSelect,
|
||||
...props
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
classes: charClasses,
|
||||
preferences,
|
||||
prerequisiteData,
|
||||
startingClass,
|
||||
totalClassLevel,
|
||||
currentLevel,
|
||||
characterId,
|
||||
classUtils,
|
||||
helperUtils,
|
||||
prerequisiteUtils: {
|
||||
validatePrerequisiteGrouping: validateGrouping,
|
||||
getPrerequisiteGroupingFailures: getGroupingFailures,
|
||||
},
|
||||
} = useCharacterEngine();
|
||||
const { allSources, getSourceCategoryDescription, getSourceCategoryGroups } =
|
||||
useSource();
|
||||
const {
|
||||
filteredClasses,
|
||||
allClasses,
|
||||
handleSelectClass,
|
||||
query,
|
||||
setQuery,
|
||||
isLoading,
|
||||
isModalShowing,
|
||||
closeModal,
|
||||
} = useClassContext();
|
||||
|
||||
const [mappedSourceCategoryGroups, setMappedSourceCategoryGroups] = useState<
|
||||
SourceGroupMapping[]
|
||||
>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const sourceCategoryGroups = getSourceCategoryGroups(filteredClasses);
|
||||
setMappedSourceCategoryGroups(
|
||||
sourceCategoryGroups.map((group) => ({
|
||||
...group,
|
||||
isOpen: true,
|
||||
}))
|
||||
);
|
||||
}, [filteredClasses]);
|
||||
|
||||
// Determine if the character's XP will be adjusted when adding a new class
|
||||
const willXpBeAdjusted =
|
||||
charClasses.length > 0 &&
|
||||
preferences.progressionType === progressionType.XP &&
|
||||
totalClassLevel + 1 > currentLevel;
|
||||
|
||||
// Navigate back to the class manage page
|
||||
const handleNavigate = (): void =>
|
||||
navigate(
|
||||
navigationConfig
|
||||
.getRouteDefPath(RouteKey.CLASS_MANAGE)
|
||||
.replace(":characterId", characterId)
|
||||
);
|
||||
|
||||
// Return requirements for multiclassing based on existing classes
|
||||
const getMulticlassAvailability = (
|
||||
classes: ClassItems
|
||||
): Array<MulticlassAvailability> => {
|
||||
const { enforceMulticlassRules } = preferences;
|
||||
let canStartingClassMulticlass = false;
|
||||
let startingClassRequirements: ClassRequirements = [];
|
||||
|
||||
if (enforceMulticlassRules) {
|
||||
if (startingClass) {
|
||||
canStartingClassMulticlass = validateGrouping(
|
||||
classUtils.getPrerequisites(startingClass),
|
||||
prerequisiteData
|
||||
);
|
||||
startingClassRequirements = getGroupingFailures(
|
||||
classUtils.getPrerequisites(startingClass),
|
||||
prerequisiteData
|
||||
);
|
||||
}
|
||||
} else {
|
||||
canStartingClassMulticlass = true;
|
||||
}
|
||||
|
||||
return classes.map(
|
||||
({ id: classId, prerequisites }): MulticlassAvailability => {
|
||||
let canMulticlass = false;
|
||||
const missingRequirements = getGroupingFailures(
|
||||
prerequisites,
|
||||
prerequisiteData
|
||||
);
|
||||
|
||||
if (enforceMulticlassRules) {
|
||||
canMulticlass =
|
||||
canStartingClassMulticlass &&
|
||||
validateGrouping(prerequisites, prerequisiteData);
|
||||
} else {
|
||||
canMulticlass = true;
|
||||
}
|
||||
|
||||
return {
|
||||
classId,
|
||||
canStartingClassMulticlass,
|
||||
startingClassRequirements,
|
||||
canMulticlass,
|
||||
missingRequirements,
|
||||
};
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const getDisabledIds = (classes: ClassItems): Array<number> => {
|
||||
if (startingClass === null || !classes.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// const classDefinitions = classes.map((item) => item.entity);
|
||||
const multiclassAvailability = getMulticlassAvailability(classes);
|
||||
|
||||
// disable any current classes
|
||||
let currentClassIds = charClasses.map((charClass) =>
|
||||
classUtils.getId(charClass)
|
||||
);
|
||||
|
||||
// disable any multiclass options that don't meet required prerequisites
|
||||
let canMulticlassIds = multiclassAvailability
|
||||
.filter((classInfo) => !classInfo.canMulticlass)
|
||||
.map((classInfo) => classInfo.classId);
|
||||
|
||||
return [...currentClassIds, ...canMulticlassIds];
|
||||
};
|
||||
|
||||
// Transform the class items into a format that listing can use
|
||||
const transformClassItems = (data: ClassItems): Array<ListingItem> => {
|
||||
let classDefinitions = [...data];
|
||||
|
||||
// Get the multiclass availability for each class
|
||||
const availability = getMulticlassAvailability(classDefinitions);
|
||||
|
||||
return classDefinitions.map((classItem: ClassDefinitionContract) => {
|
||||
const { id, sources, name, portraitAvatarUrl } = classItem;
|
||||
// Find the class in the character's classes
|
||||
const existingClass = charClasses.find(
|
||||
(cls) => classUtils.getId(cls) === id
|
||||
);
|
||||
|
||||
// Find the multiclass info for the class
|
||||
const multiclassInfo = availability.find((cls) => cls.classId === id);
|
||||
let metaItems: Array<{ type: string; text: string }> = [];
|
||||
|
||||
// Add the source descriptions to the meta items
|
||||
if (sources)
|
||||
sources.forEach(({ sourceId }) => {
|
||||
let source = helperUtils.lookupDataOrFallback(allSources, sourceId);
|
||||
|
||||
// If a source has a description and is toggleable, add it to the meta items
|
||||
if (
|
||||
source?.description !== null &&
|
||||
source?.sourceCategory?.isToggleable
|
||||
)
|
||||
metaItems.push({
|
||||
type: "normal",
|
||||
text: source.description,
|
||||
});
|
||||
});
|
||||
|
||||
// If the character is already multiclassed
|
||||
if (charClasses && charClasses.length >= 1) {
|
||||
if (existingClass) {
|
||||
const { level, isStartingClass } = existingClass;
|
||||
const currentLevel = `Level ${level}`;
|
||||
const startingClass = isStartingClass ? " • Starting Class" : "";
|
||||
|
||||
// Add the class level to the meta items
|
||||
metaItems.push({
|
||||
type: "normal",
|
||||
text: `${currentLevel}${startingClass}`,
|
||||
});
|
||||
}
|
||||
// If the class is the starting class
|
||||
else if (multiclassInfo) {
|
||||
const {
|
||||
canMulticlass,
|
||||
canStartingClassMulticlass,
|
||||
missingRequirements,
|
||||
startingClassRequirements,
|
||||
} = multiclassInfo;
|
||||
// Add an error message to the meta items
|
||||
if (!canMulticlass || !canStartingClassMulticlass)
|
||||
metaItems.push({
|
||||
type: "error",
|
||||
text: !canStartingClassMulticlass
|
||||
? `Starting class does not meet multiclass prerequisites: ${getMissingRequirements(
|
||||
startingClassRequirements
|
||||
)}`
|
||||
: !canMulticlass
|
||||
? `Prerequisites not met: ${getMissingRequirements(
|
||||
missingRequirements
|
||||
)}`
|
||||
: "",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
metaItems,
|
||||
entityTypeId: -1,
|
||||
heading: name || "",
|
||||
image: portraitAvatarUrl || null,
|
||||
onClick: handleSelectClass,
|
||||
entity: classItem,
|
||||
type: "class",
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleOverrideClick = (override): void => {
|
||||
const updatedGroups = mappedSourceCategoryGroups.map((group) => ({
|
||||
...group,
|
||||
isOpen: override,
|
||||
}));
|
||||
|
||||
setMappedSourceCategoryGroups(updatedGroups);
|
||||
};
|
||||
|
||||
const handleSourceCategoryClick = (id: string, state: boolean): void => {
|
||||
const parsedId = parseInt(id);
|
||||
const updatedGroups = mappedSourceCategoryGroups.map((group) => ({
|
||||
...group,
|
||||
isOpen: group.id === parsedId ? state : group.isOpen,
|
||||
}));
|
||||
|
||||
setMappedSourceCategoryGroups(updatedGroups);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={clsx([styles.page, className])} {...props}>
|
||||
{showHeader && (
|
||||
<>
|
||||
<PortraitName />
|
||||
<hr className={styles.divider} />
|
||||
<h2 className={styles.title}>
|
||||
Choose a Class{startingClass && " to Multiclass"}
|
||||
</h2>
|
||||
{startingClass && (
|
||||
<>
|
||||
<p className={styles.text}>
|
||||
Learn more about multiclassing{" "}
|
||||
<Link
|
||||
href="/sources/dnd/free-rules/creating-a-character#Multiclassing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
here
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
<p className={styles.multiclassMessage}>
|
||||
Classes from the 2024 and 2014 rulebooks are not designed to be
|
||||
multiclassed together.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{charClasses.length > 0 && (
|
||||
<div className={styles.buttons}>
|
||||
{willXpBeAdjusted && (
|
||||
<p className={styles.error}>
|
||||
Your XP total will be adjusted when you add your new class.
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
className={styles.navigateButton}
|
||||
variant="builder"
|
||||
onClick={handleNavigate}
|
||||
>
|
||||
Cancel Class Add
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{isLoading ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<>
|
||||
<Search
|
||||
className={styles.search}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
|
||||
<div className={styles.collapseExpand}>
|
||||
<Button
|
||||
variant="text"
|
||||
size="x-small"
|
||||
className={styles.collapseExpandButton}
|
||||
onClick={() => handleOverrideClick(false)}
|
||||
forceThemeMode="light"
|
||||
>
|
||||
Collapse All
|
||||
</Button>
|
||||
<Button
|
||||
variant="text"
|
||||
size="x-small"
|
||||
className={styles.collapseExpandButton}
|
||||
onClick={() => handleOverrideClick(true)}
|
||||
forceThemeMode="light"
|
||||
>
|
||||
Expand All
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{mappedSourceCategoryGroups.length > 0 ? (
|
||||
mappedSourceCategoryGroups.map((category) => {
|
||||
const description = getSourceCategoryDescription(category.id);
|
||||
const metaItems = description
|
||||
? [
|
||||
<HtmlContent
|
||||
html={description}
|
||||
className={styles.accordionText}
|
||||
/>,
|
||||
]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<Accordion
|
||||
id={category.id.toString()}
|
||||
className={styles.accordion}
|
||||
summary={<h3 className={styles.heading}>{category.name}</h3>}
|
||||
summaryMetaItems={metaItems}
|
||||
variant="text"
|
||||
resetOpen={allClasses.length !== filteredClasses.length}
|
||||
key={category.id}
|
||||
handleIsOpen={handleSourceCategoryClick}
|
||||
override={category.isOpen}
|
||||
forceShow
|
||||
>
|
||||
<Listing
|
||||
items={orderBy(
|
||||
transformClassItems(category.items),
|
||||
"heading"
|
||||
)}
|
||||
disabledIds={getDisabledIds(category.items)}
|
||||
onQuickSelect={onQuickSelect}
|
||||
/>
|
||||
</Accordion>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<p className={styles.notFound}>No Results Found</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<ConfirmClassModal open={isModalShowing} onClose={closeModal} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
import clsx from "clsx";
|
||||
import { FC, HTMLAttributes, useState } from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
|
||||
import { Accordion } from "~/components/Accordion";
|
||||
import { Button } from "~/components/Button";
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
import { builderActions } from "~/tools/js/CharacterBuilder/actions";
|
||||
import ClassDisplaySimple from "~/tools/js/CharacterBuilder/components/ClassDisplaySimple";
|
||||
import SpeciesDisplaySimple from "~/tools/js/CharacterBuilder/components/SpeciesDisplaySimple";
|
||||
import { ClassDefinitionContract, RaceDefinitionContract } from "~/types";
|
||||
|
||||
import { PortraitName } from "../../components/PortraitName";
|
||||
import { ClassChoose } from "../ClassChoose";
|
||||
import { SpeciesChoose } from "../SpeciesChoose";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
export interface QuickBuildProps extends HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
export const QuickBuild: FC<QuickBuildProps> = ({ className, ...props }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { raceUtils, ruleData } = useCharacterEngine();
|
||||
|
||||
const [species, setSpecies] = useState<RaceDefinitionContract | null>(null);
|
||||
const [charClass, setCharClass] = useState<ClassDefinitionContract | null>(
|
||||
null
|
||||
);
|
||||
const [name, setName] = useState("");
|
||||
const [isClassesOpen, setIsClassesOpen] = useState(false);
|
||||
const [isSpeciesOpen, setIsSpeciesOpen] = useState(false);
|
||||
|
||||
const handleSubmit = (): void => {
|
||||
dispatch(
|
||||
builderActions.quickBuildRequest(
|
||||
species ? raceUtils.getEntityRaceId(species) : null,
|
||||
species ? raceUtils.getEntityRaceTypeId(species) : null,
|
||||
charClass ? charClass.id : null,
|
||||
name
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const handleSelectSpecies = (species: RaceDefinitionContract): void => {
|
||||
setSpecies(species);
|
||||
};
|
||||
const handleRemoveSpecies = (): void => {
|
||||
setSpecies(null);
|
||||
setIsSpeciesOpen(true);
|
||||
};
|
||||
const handleSelectClass = (charClass: ClassDefinitionContract): void => {
|
||||
setCharClass(charClass);
|
||||
};
|
||||
const handleRemoveClass = (): void => {
|
||||
setCharClass(null);
|
||||
setIsClassesOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={clsx([styles.page, className])} {...props}>
|
||||
<h1 className={styles.title}>Quick Build</h1>
|
||||
<p>
|
||||
To create a level 1 character with recommended starting options, choose
|
||||
a class and species below. You can provide a character name or leave it
|
||||
blank to randomize one.
|
||||
</p>
|
||||
<hr className={styles.divider} />
|
||||
{charClass ? (
|
||||
<ClassDisplaySimple
|
||||
onRequestChange={handleRemoveClass}
|
||||
charClass={charClass}
|
||||
/>
|
||||
) : (
|
||||
<Accordion
|
||||
className={styles.accordion}
|
||||
summary={<h3 className={styles.accordionHeading}>Choose Class</h3>}
|
||||
variant="text"
|
||||
resetOpen={isClassesOpen}
|
||||
>
|
||||
<ClassChoose showHeader={false} onQuickSelect={handleSelectClass} />
|
||||
</Accordion>
|
||||
)}
|
||||
{species ? (
|
||||
<SpeciesDisplaySimple
|
||||
onRequestAction={handleRemoveSpecies}
|
||||
headingText="Selected Species"
|
||||
species={species}
|
||||
ruleData={ruleData}
|
||||
/>
|
||||
) : (
|
||||
<Accordion
|
||||
className={styles.accordion}
|
||||
summary={<h3 className={styles.accordionHeading}>Choose Species</h3>}
|
||||
variant="text"
|
||||
resetOpen={isSpeciesOpen}
|
||||
>
|
||||
<SpeciesChoose
|
||||
showHeader={false}
|
||||
onQuickSelect={handleSelectSpecies}
|
||||
/>
|
||||
</Accordion>
|
||||
)}
|
||||
<PortraitName hidePortrait fullWidth handleNameUpdate={setName} />
|
||||
<Button
|
||||
className={styles.submitButton}
|
||||
onClick={handleSubmit}
|
||||
disabled={!species || !charClass}
|
||||
variant="builder"
|
||||
>
|
||||
Create Character
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,174 @@
|
||||
import clsx from "clsx";
|
||||
import { FC, HTMLAttributes, useState } from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
|
||||
import { Select } from "@dndbeyond/character-components/es";
|
||||
|
||||
import { Accordion } from "~/components/Accordion";
|
||||
import { Button } from "~/components/Button";
|
||||
import { Toggle } from "~/components/Toggle";
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
import { builderActions } from "~/tools/js/CharacterBuilder/actions";
|
||||
import ClassDisplaySimple from "~/tools/js/CharacterBuilder/components/ClassDisplaySimple";
|
||||
import SpeciesDisplaySimple from "~/tools/js/CharacterBuilder/components/SpeciesDisplaySimple";
|
||||
import { ClassDefinitionContract, RaceDefinitionContract } from "~/types";
|
||||
|
||||
import { PortraitName } from "../../components/PortraitName";
|
||||
import { ClassChoose } from "../ClassChoose";
|
||||
import { SpeciesChoose } from "../SpeciesChoose";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
export interface RandomBuildProps extends HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
export const RandomBuild: FC<RandomBuildProps> = ({ className, ...props }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { raceUtils, ruleData, helperUtils } = useCharacterEngine();
|
||||
|
||||
const [species, setSpecies] = useState<RaceDefinitionContract | null>(null);
|
||||
const [charClass, setCharClass] = useState<ClassDefinitionContract | null>(
|
||||
null
|
||||
);
|
||||
const [name, setName] = useState("");
|
||||
const [level, setLevel] = useState<number | null>(null);
|
||||
const [allowMulticlass, setAllowMulticlass] = useState(false);
|
||||
const [allowFeats, setAllowFeats] = useState(false);
|
||||
const [isClassesOpen, setIsClassesOpen] = useState(false);
|
||||
const [isSpeciesOpen, setIsSpeciesOpen] = useState(false);
|
||||
|
||||
const handleSubmit = (): void => {
|
||||
dispatch(
|
||||
builderActions.randomBuildRequest(
|
||||
level,
|
||||
species ? raceUtils.getEntityRaceId(species) : null,
|
||||
species ? raceUtils.getEntityRaceTypeId(species) : null,
|
||||
charClass ? charClass.id : null,
|
||||
allowMulticlass,
|
||||
allowFeats,
|
||||
name
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const handleSelectSpecies = (species: RaceDefinitionContract): void => {
|
||||
setSpecies(species);
|
||||
};
|
||||
const handleRemoveSpecies = (): void => {
|
||||
setSpecies(null);
|
||||
setIsSpeciesOpen(true);
|
||||
};
|
||||
const handleSelectClass = (charClass: ClassDefinitionContract): void => {
|
||||
setCharClass(charClass);
|
||||
};
|
||||
const handleRemoveClass = (): void => {
|
||||
setCharClass(null);
|
||||
setIsClassesOpen(true);
|
||||
};
|
||||
const handleLevelChange = (level: string): void => {
|
||||
setLevel(helperUtils.parseInputInt(level));
|
||||
};
|
||||
const handleToggleAllowMulticlass = (): void => {
|
||||
setAllowMulticlass(!allowMulticlass);
|
||||
};
|
||||
const handleToggleAllowFeats = (): void => {
|
||||
setAllowFeats(!allowFeats);
|
||||
};
|
||||
|
||||
const levels = Array.from(
|
||||
new Array(ruleData.maxCharacterLevel),
|
||||
(val, index) => index + 1
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={clsx([styles.page, className])} {...props}>
|
||||
<h1 className={styles.title}>Random Build</h1>
|
||||
<p>
|
||||
Create a randomized character. Make it completely random, or make
|
||||
choices in some categories and randomize the rest. Once you've made your
|
||||
selections, click <strong>Create Character</strong> to generate a
|
||||
character sheet and start playing!
|
||||
</p>
|
||||
<hr className={styles.divider} />
|
||||
<div className={styles.levelSelect}>
|
||||
<label className={styles.label} id="choose-level">
|
||||
Choose Level
|
||||
</label>
|
||||
<Select
|
||||
className={styles.select}
|
||||
placeholder={"--"}
|
||||
options={levels.map((level) => ({
|
||||
value: level,
|
||||
label: level,
|
||||
}))}
|
||||
onChange={handleLevelChange}
|
||||
aria-labelledby="choose-level"
|
||||
value={level}
|
||||
/>
|
||||
</div>
|
||||
{charClass ? (
|
||||
<ClassDisplaySimple
|
||||
onRequestChange={handleRemoveClass}
|
||||
charClass={charClass}
|
||||
/>
|
||||
) : (
|
||||
<Accordion
|
||||
className={styles.accordion}
|
||||
summary={<h3 className={styles.accordionHeading}>Choose Class</h3>}
|
||||
variant="text"
|
||||
resetOpen={isClassesOpen}
|
||||
>
|
||||
<ClassChoose showHeader={false} onQuickSelect={handleSelectClass} />
|
||||
</Accordion>
|
||||
)}
|
||||
{species ? (
|
||||
<SpeciesDisplaySimple
|
||||
onRequestAction={handleRemoveSpecies}
|
||||
headingText="Selected Species"
|
||||
species={species}
|
||||
ruleData={ruleData}
|
||||
/>
|
||||
) : (
|
||||
<Accordion
|
||||
className={styles.accordion}
|
||||
summary={<h3 className={styles.accordionHeading}>Choose Species</h3>}
|
||||
variant="text"
|
||||
resetOpen={isSpeciesOpen}
|
||||
>
|
||||
<SpeciesChoose
|
||||
showHeader={false}
|
||||
onQuickSelect={handleSelectSpecies}
|
||||
/>
|
||||
</Accordion>
|
||||
)}
|
||||
<div className={styles.toggle}>
|
||||
<label className={styles.label} id="allow-multiclass-label">
|
||||
Allow Multiclass
|
||||
</label>
|
||||
<Toggle
|
||||
onClick={handleToggleAllowMulticlass}
|
||||
checked={allowMulticlass}
|
||||
color="secondary"
|
||||
aria-labelledby="allow-multiclass-label"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.toggle}>
|
||||
<label className={styles.label} id="allow-feats-label">
|
||||
Allow Feats
|
||||
</label>
|
||||
<Toggle
|
||||
onClick={handleToggleAllowFeats}
|
||||
checked={allowFeats}
|
||||
color="secondary"
|
||||
aria-labelledby="allow-feats-label"
|
||||
/>
|
||||
</div>
|
||||
<PortraitName hidePortrait fullWidth handleNameUpdate={setName} />
|
||||
<Button
|
||||
className={styles.submitButton}
|
||||
onClick={handleSubmit}
|
||||
variant="builder"
|
||||
>
|
||||
Create Character
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,244 @@
|
||||
import clsx from "clsx";
|
||||
import { FC, HTMLAttributes, useEffect, useState } from "react";
|
||||
|
||||
import CircleInfo from "@dndbeyond/fontawesome-cache/svgs/regular/circle-info.svg";
|
||||
|
||||
import { Accordion } from "~/components/Accordion";
|
||||
import { HtmlContent } from "~/components/HtmlContent";
|
||||
import { Link } from "~/components/Link";
|
||||
import { Toggle } from "~/components/Toggle";
|
||||
import { Tooltip } from "~/components/Tooltip";
|
||||
import { orderBy } from "~/helpers/sortUtils";
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
import { useSource } from "~/hooks/useSource";
|
||||
import { Select } from "~/tools/js/smartComponents/legacy";
|
||||
import { RaceDefinitionContract } from "~/types";
|
||||
|
||||
import { Button } from "../../../../components/Button";
|
||||
import { ConfirmSpeciesModal } from "../../components/ConfirmSpeciesModal";
|
||||
import { Listing } from "../../components/Listing";
|
||||
import { PortraitName } from "../../components/PortraitName";
|
||||
import { Search } from "../../components/Search";
|
||||
import { SpeciesDisplay } from "../../components/SpeciesDisplay";
|
||||
import { Spinner } from "../../components/Spinner";
|
||||
import { useSpeciesContext } from "../../contexts/Species";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
//showHeader and OnQuickSelect are only be used from QuickBuild.tsx and RandomBuild.tsx
|
||||
export interface SpeciesChooseProps extends HTMLAttributes<HTMLDivElement> {
|
||||
showHeader?: boolean;
|
||||
onQuickSelect?: (species: RaceDefinitionContract) => void;
|
||||
}
|
||||
|
||||
export interface SourceGroupMapping {
|
||||
name: string;
|
||||
id: number;
|
||||
items: RaceDefinitionContract[];
|
||||
sortOrder: number | undefined;
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
export const SpeciesChoose: FC<SpeciesChooseProps> = ({
|
||||
showHeader = true,
|
||||
onQuickSelect,
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
const { race: currentSpecies } = useCharacterEngine();
|
||||
const {
|
||||
allSpecies,
|
||||
closeModal,
|
||||
filteredSpecies,
|
||||
isLegacyShowing,
|
||||
isModalShowing,
|
||||
setSource,
|
||||
source,
|
||||
sourceOptions,
|
||||
toggleLegacyContent,
|
||||
transformSpecies,
|
||||
query,
|
||||
setQuery,
|
||||
isLoading,
|
||||
getSpeciesInGroups,
|
||||
} = useSpeciesContext();
|
||||
const { getSourceCategoryGroups, getSourceCategoryDescription } = useSource();
|
||||
const [mappedSourceCategoryGroups, setMappedSourceCategoryGroups] = useState<
|
||||
SourceGroupMapping[]
|
||||
>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const sourceCategoryGroups = getSourceCategoryGroups(filteredSpecies);
|
||||
setMappedSourceCategoryGroups(
|
||||
sourceCategoryGroups.map((group) => ({
|
||||
...group,
|
||||
isOpen: true,
|
||||
}))
|
||||
);
|
||||
}, [filteredSpecies]);
|
||||
|
||||
const handleOverrideClick = (override): void => {
|
||||
const updatedGroups = mappedSourceCategoryGroups.map((group) => ({
|
||||
...group,
|
||||
isOpen: override,
|
||||
}));
|
||||
|
||||
setMappedSourceCategoryGroups(updatedGroups);
|
||||
};
|
||||
|
||||
const handleSourceCategoryClick = (id: string, state: boolean): void => {
|
||||
const parsedId = parseInt(id);
|
||||
const updatedGroups = mappedSourceCategoryGroups.map((group) => ({
|
||||
...group,
|
||||
isOpen: group.id === parsedId ? state : group.isOpen,
|
||||
}));
|
||||
|
||||
setMappedSourceCategoryGroups(updatedGroups);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={clsx([styles.speciesChoose, className])} {...props}>
|
||||
{showHeader && (
|
||||
<>
|
||||
<PortraitName />
|
||||
<hr className={styles.divider} />
|
||||
<h2 className={styles.title}>
|
||||
{currentSpecies ? "Change Origin: " : "Choose Origin: "}
|
||||
{"Species"}
|
||||
</h2>
|
||||
{currentSpecies && (
|
||||
<>
|
||||
<SpeciesDisplay
|
||||
headingText="Current Species"
|
||||
actionText="Keep Species"
|
||||
/>
|
||||
<h3 className={styles.title}>Select New Species</h3>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className={styles.filters}>
|
||||
<div>
|
||||
<div className={styles.label}>Filter Species Source(s)</div>
|
||||
<Select
|
||||
placeholder={"-- All Sources --"}
|
||||
options={sourceOptions}
|
||||
value={source}
|
||||
onChange={(value) => setSource(value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.legacy}>
|
||||
<Toggle
|
||||
className={styles.toggle}
|
||||
onClick={toggleLegacyContent}
|
||||
checked={isLegacyShowing}
|
||||
color="secondary"
|
||||
aria-labelledby="legacy-content-label"
|
||||
/>
|
||||
<label id="legacy-content-label">
|
||||
Show Legacy Content{" "}
|
||||
<>
|
||||
<CircleInfo
|
||||
data-tooltip-id="legacy-info"
|
||||
data-tooltip-place="bottom"
|
||||
className={styles.infoIcon}
|
||||
/>
|
||||
<Tooltip id="legacy-info" clickable>
|
||||
Legacy content doesn't reflect the latest rules and lore.{" "}
|
||||
<Link
|
||||
href="https://dndbeyond.com/legacy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Learn More
|
||||
</Link>
|
||||
</Tooltip>
|
||||
</>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<hr className={styles.divider} />
|
||||
<div className={clsx([styles.text, styles.marketplace])}>
|
||||
Looking for something not in the list below? Unlock all official options
|
||||
in the <Link href="/marketplace">Marketplace</Link>.
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<>
|
||||
<Search
|
||||
className={styles.search}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<div className={styles.collapseExpand}>
|
||||
<Button
|
||||
variant="text"
|
||||
size="x-small"
|
||||
className={styles.collapseExpandButton}
|
||||
onClick={() => handleOverrideClick(false)}
|
||||
forceThemeMode="light"
|
||||
>
|
||||
Collapse All
|
||||
</Button>
|
||||
<Button
|
||||
variant="text"
|
||||
size="x-small"
|
||||
className={styles.collapseExpandButton}
|
||||
onClick={() => handleOverrideClick(true)}
|
||||
forceThemeMode="light"
|
||||
>
|
||||
Expand All
|
||||
</Button>
|
||||
</div>
|
||||
{mappedSourceCategoryGroups.length > 0 ? (
|
||||
mappedSourceCategoryGroups.map((category) => {
|
||||
const name = category.name;
|
||||
const id = category.id;
|
||||
const description = getSourceCategoryDescription(category.id);
|
||||
|
||||
const currentSpeciesId = currentSpecies
|
||||
? [
|
||||
`${currentSpecies.entityRaceId}-${currentSpecies.entityRaceTypeId}`,
|
||||
]
|
||||
: [];
|
||||
|
||||
const groupedSpecies = getSpeciesInGroups(
|
||||
transformSpecies(category.items)
|
||||
);
|
||||
|
||||
const metaItems = description
|
||||
? [<HtmlContent html={description} className={styles.text} />]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<Accordion
|
||||
id={category.id.toString()}
|
||||
className={styles.accordion}
|
||||
summary={<h3 className={styles.heading}>{name}</h3>}
|
||||
summaryMetaItems={metaItems}
|
||||
variant="text"
|
||||
resetOpen={allSpecies.length !== filteredSpecies.length}
|
||||
key={id}
|
||||
forceShow
|
||||
override={category.isOpen}
|
||||
handleIsOpen={handleSourceCategoryClick}
|
||||
>
|
||||
<Listing
|
||||
items={orderBy(groupedSpecies, "heading")}
|
||||
disabledIds={currentSpeciesId}
|
||||
onQuickSelect={onQuickSelect}
|
||||
/>
|
||||
</Accordion>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<p className={styles.notFound}>No Results Found</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<ConfirmSpeciesModal open={isModalShowing} onClose={closeModal} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
+116
@@ -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>
|
||||
);
|
||||
};
|
||||
+57
@@ -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'll try again!
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className={styles.noResultsTitle}>
|
||||
Looks like you haven'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&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>
|
||||
);
|
||||
};
|
||||
+127
@@ -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&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&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;
|
||||
};
|
||||
+40
@@ -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&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&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>
|
||||
);
|
||||
};
|
||||
+136
@@ -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>
|
||||
);
|
||||
};
|
||||
+28
@@ -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>
|
||||
);
|
||||
};
|
||||
+25
@@ -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>
|
||||
);
|
||||
};
|
||||
+42
@@ -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
|
||||
);
|
||||
};
|
||||
+156
@@ -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>
|
||||
);
|
||||
};
|
||||
+214
@@ -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>
|
||||
);
|
||||
};
|
||||
+137
@@ -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>
|
||||
);
|
||||
};
|
||||
+69
@@ -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>
|
||||
);
|
||||
};
|
||||
+247
@@ -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>
|
||||
);
|
||||
};
|
||||
+280
@@ -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>
|
||||
);
|
||||
};
|
||||
+385
@@ -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>
|
||||
);
|
||||
};
|
||||
+105
@@ -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>
|
||||
);
|
||||
};
|
||||
+35
@@ -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>
|
||||
);
|
||||
};
|
||||
+131
@@ -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;
|
||||
+220
@@ -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>
|
||||
);
|
||||
};
|
||||
+88
@@ -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>
|
||||
);
|
||||
};
|
||||
+537
@@ -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>
|
||||
);
|
||||
};
|
||||
+104
@@ -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>
|
||||
);
|
||||
};
|
||||
+150
@@ -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>
|
||||
);
|
||||
};
|
||||
+84
@@ -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
Reference in New Issue
Block a user