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