New source found from dndbeyond.com

This commit is contained in:
2026-07-08 01:00:09 -07:00
parent 9a983a6d7b
commit dcefa0d2f2
2865 changed files with 222467 additions and 49053 deletions
@@ -0,0 +1,157 @@
import {
autoUpdate,
flip,
FloatingFocusManager,
offset,
Placement,
shift,
useClick,
useDismiss,
useFloating,
useInteractions,
useRole,
} from "@floating-ui/react";
import clsx from "clsx";
import {
FC,
Fragment,
HTMLAttributes,
PropsWithChildren,
ReactNode,
useState,
} from "react";
import { GroupedMenuOption } from "@dndbeyond/character-rules-engine";
import ChevronDown from "@dndbeyond/fontawesome-cache/svgs/solid/chevron-down.svg";
import { Button, ButtonProps } from "~/components/Button";
import styles from "./styles.module.css";
/**
* A button that displays a menu of options when clicked. If there is only one option, it will
* render as a simple button that triggers the onSelect callback when clicked unless the
* `showSingleOption` prop is set to true, in which case it will still show the dropdown with only
* one option.
*/
export interface ButtonWithMenuProps
extends PropsWithChildren,
Omit<HTMLAttributes<HTMLDivElement>, "onSelect"> {
placement?: Placement;
groupedOptions: Array<GroupedMenuOption>;
showSingleOption?: boolean;
onSelect: (containerDefinitionKey: string) => void;
variant?: ButtonProps["variant"];
}
export const ButtonWithMenu: FC<ButtonWithMenuProps> = ({
className,
children,
groupedOptions,
showSingleOption,
placement,
onSelect,
variant,
...props
}) => {
const [isOpen, setIsOpen] = useState(false);
// Set up floating element and trigger
const { refs, floatingStyles, context } = useFloating({
placement,
strategy: "fixed",
open: isOpen,
onOpenChange: setIsOpen,
middleware: [offset(), flip(), shift()],
whileElementsMounted: autoUpdate,
});
// Handle interactions for the floating element
const { getReferenceProps, getFloatingProps } = useInteractions([
useClick(context),
useDismiss(context),
useRole(context),
]);
// Determine whether we should show the dropdown
const hasSingleOption =
groupedOptions.length === 1 &&
groupedOptions[0].options?.length === 1 &&
!showSingleOption;
const handleClickTrigger = (e) => {
e.stopPropagation();
if (hasSingleOption) {
onSelect(groupedOptions[0].options[0]?.value);
} else {
setIsOpen((prev) => !prev);
}
};
const handleClickOption = (e, value: string) => {
e.stopPropagation();
onSelect(value);
setIsOpen(false);
};
const getTestId = (value: ReactNode) => {
const content = value?.toString() ?? "Move";
return `theme-button-with-menu-${content}`
.toLowerCase()
.replace(/\s/g, "-");
};
return (
<div className={clsx([styles.buttonWithMenu, className])} {...props}>
<span
className={styles.trigger}
ref={refs.setReference}
{...getReferenceProps()}
>
<Button
size="xx-small"
variant={variant}
themed
data-testid={getTestId(children)}
onClick={handleClickTrigger}
>
{children}
{!hasSingleOption && <ChevronDown className={styles.buttonIcon} />}
</Button>
</span>
{!hasSingleOption && isOpen && (
<FloatingFocusManager context={context} modal={false}>
<menu
className={styles.menu}
ref={refs.setFloating}
style={floatingStyles}
{...getFloatingProps()}
>
{groupedOptions.map((option) => (
<Fragment key={option.label}>
<li
className={styles.menuHeading}
data-testid={getTestId(`subheader-${option.label}`)}
>
{option.label}
</li>
<ul>
{option.options?.map((item) => (
<li key={item.value}>
<button
className={styles.menuButton}
onClick={(e) => handleClickOption(e, item.value)}
data-testid={getTestId(`item-${item.label}`)}
>
{item.label}
</button>
</li>
))}
</ul>
</Fragment>
))}
</menu>
</FloatingFocusManager>
)}
</div>
);
};
@@ -41,6 +41,7 @@ export const Pane: FC<PaneProps> = ({
forceDarkMode && styles.dark,
isFullWidth && styles.fullWidth,
])}
data-scrollable-container="true"
>
<PaneControls handlePrevious={handlePrevious} handleNext={handleNext} />
<PaneContent activePane={activePane} />
@@ -28,7 +28,7 @@ export const PaneContent: FC<PaneContentProps> = ({ activePane, ...props }) => {
// Return correct component
return (
<div className={styles.maxHeight} {...props}>
<div className={styles.wrapper} {...props}>
<Component
theme={isDarkMode ? "DARK" : "LIGHT"}
identifiers={activePane.identifiers}
@@ -1,28 +1,38 @@
import { FC, useEffect } from "react";
import { useDispatch } from "react-redux";
import { useSidebar } from "~/contexts/Sidebar";
import { useDispatch } from "~/hooks/useDispatch";
import { ToastMessageMeta } from "~/tools/js/Shared/stores/typings";
import { toastMessageActions } from "../../../../../../tools/js/Shared/actions";
interface Props {
errorTitle?: string;
errorMessage?: string;
toastMeta?: ToastMessageMeta;
}
export const PaneInitFailureContent: FC<Props> = ({ errorMessage }) => {
export const PaneInitFailureContent: FC<Props> = ({
errorTitle,
errorMessage,
toastMeta,
}) => {
const dispatch = useDispatch();
const {
sidebar: { setIsVisible },
pane: { resetActivePane },
} = useSidebar();
useEffect(() => {
resetActivePane();
setIsVisible(false);
dispatch(
toastMessageActions.toastError(
"Content Error",
errorMessage ?? "We couldn't find that content! Please try again."
errorTitle ?? "Content Error",
errorMessage ?? "We couldn't find that content! Please try again.",
toastMeta
)
);
}, [errorMessage, dispatch]);
}, [errorMessage, errorTitle, toastMeta]);
return null;
};
@@ -1,7 +1,9 @@
import { CreaturePane } from "~/subApps/sheet/components/Sidebar/panes/CreaturePane";
import { GameLogPane } from "~/subApps/sheet/components/Sidebar/panes/GameLogPane";
import { ItemPane } from "~/subApps/sheet/components/Sidebar/panes/ItemPane";
import { ItemPlansManagePane } from "~/subApps/sheet/components/Sidebar/panes/ItemPlansManagePane";
import { LongRestPane } from "~/subApps/sheet/components/Sidebar/panes/LongRestPane";
import { SpeciesTraitPane } from "~/subApps/sheet/components/Sidebar/panes/SpeciesTraitPane";
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";
@@ -18,8 +20,6 @@ import EquipmentManagePane from "~/tools/js/Shared/containers/panes/EquipmentMan
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";
@@ -66,6 +66,9 @@ export const getActiveEntryComponent = (
if (!componentType) return null;
switch (componentType) {
case PaneComponentEnum.ITEM_PLANS_MANAGE:
return ItemPlansManagePane;
case PaneComponentEnum.HEALTH_MANAGE:
return HitPointsManagePane;
@@ -237,8 +240,7 @@ export const getActiveEntryComponent = (
case PaneComponentEnum.SETTINGS:
return SettingsPane;
case PaneComponentEnum.BLESSING_DETAIL:
default:
return BlessingPane;
return CharacterManagePane;
}
};
@@ -1,9 +1,8 @@
import { ComponentType, FC, HTMLAttributes, ReactNode } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useSelector } from "react-redux";
import {
AbilityIcon,
Collapsible,
DarkModeNegativeBonusNegativeSvg,
DarkModePositiveBonusPositiveSvg,
NegativeBonusNegativeSvg,
@@ -18,12 +17,14 @@ import {
ValueUtils,
} from "@dndbeyond/character-rules-engine/es";
import { Accordion } from "~/components/Accordion";
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 { useDispatch } from "~/hooks/useDispatch";
import { useRuleData } from "~/hooks/useRuleData";
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
import { Preview } from "~/subApps/sheet/components/Sidebar/components/Preview";
@@ -126,11 +127,7 @@ export const AbilitySavingThrowsPane: FC<Props> = ({
</span>
</Header>
{!isReadonly && (
<Collapsible
layoutType={"minimal"}
header="Customize"
className={styles.customize}
>
<Accordion variant="minimal" size="x-small" summary="Customize" themed>
<EditorBox className={styles.editorBox}>
<ValueEditor
dataLookup={ValueUtils.getEntityData(
@@ -148,7 +145,7 @@ export const AbilitySavingThrowsPane: FC<Props> = ({
ruleData={ruleData}
/>
</EditorBox>
</Collapsible>
</Accordion>
)}
{situationalBonusSavingThrowsInfo && (
<div className={styles.situational}>
@@ -1,5 +1,5 @@
import { FC, HTMLAttributes, ReactNode } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useSelector } from "react-redux";
import { ActionName } from "@dndbeyond/character-components/es";
import {
@@ -16,6 +16,7 @@ import { ItemName } from "~/components/ItemName";
import { DataOriginTypeEnum } from "~/constants";
import { useSidebar } from "~/contexts/Sidebar";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { useDispatch } from "~/hooks/useDispatch";
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
import { BaseInventoryContract } from "~/types";
@@ -71,7 +71,7 @@ export const ArmorClassDetail: FC<ArmorClassDetailProps> = ({
amountDisplay = <NumberDisplay type="signed" number={amount} />;
}
let extraDisplay: ReactNode;
let extraDisplay;
switch (type) {
case ArmorClassTypeEnum.DEX_BONUS:
extraDisplay = extra === null ? "" : `Max ${extra}`;
@@ -1,15 +1,16 @@
import { FC, HTMLAttributes } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useSelector } from "react-redux";
import { Collapsible } from "@dndbeyond/character-components/es";
import {
characterActions,
ValueUtils,
} from "@dndbeyond/character-rules-engine/es";
import { Accordion } from "~/components/Accordion";
import { HtmlContent } from "~/components/HtmlContent";
import { AdjustmentTypeEnum, RuleKeyEnum } from "~/constants";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { useDispatch } from "~/hooks/useDispatch";
import { useRuleData } from "~/hooks/useRuleData";
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
import EditorBox from "~/tools/js/Shared/components/EditorBox";
@@ -75,10 +76,12 @@ export const ArmorManagePane: FC<ArmorManagePaneProps> = ({ ...props }) => {
<ArmorClassDetail />
{/* AC CUSTOMIZATION */}
{!isReadonly && (
<Collapsible
layoutType={"minimal"}
header="Customize"
<Accordion
className={styles.customize}
summary="Customize"
variant="minimal"
size="x-small"
themed
>
<EditorBox className={styles.editorBox}>
<ValueEditor
@@ -91,7 +94,7 @@ export const ArmorManagePane: FC<ArmorManagePaneProps> = ({ ...props }) => {
ruleData={ruleData}
/>
</EditorBox>
</Collapsible>
</Accordion>
)}
{/* AC Description */}
{acRule && (
@@ -1,12 +1,10 @@
import { ComponentType, FC, HTMLAttributes, ReactNode } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useNavigate } from "react-router-dom";
import { useSelector } from "react-redux";
import { useLocation, useNavigate } from "react-router-dom";
import {
LightLinkOutSvg,
ThemedBackdropSvg,
ThemedBuilderSvg,
ThemedExportSvg,
ThemedFrameSvg,
ThemedLongRestSvg,
ThemedManageLevelSvg,
@@ -16,9 +14,10 @@ import {
ThemedShareSvg,
ThemedShortRestSvg,
ThemedThemeIconSvg,
ThemedChatBubbleSvg,
} from "@dndbeyond/character-components/es";
import D6 from "@dndbeyond/fontawesome-cache/svgs/regular/dice-d6.svg";
import PdfSvg from "@dndbeyond/fontawesome-cache/svgs/light/file-pdf.svg";
import ListTimeline from "@dndbeyond/fontawesome-cache/svgs/light/list-timeline.svg";
import LinkIcon from "@dndbeyond/fontawesome-cache/svgs/solid/arrow-up-right-from-square.svg";
import { Button } from "~/components/Button";
import config from "~/config";
@@ -49,6 +48,8 @@ export const CharacterManagePane: FC<CharacterManagePaneProps> = ({
...props
}) => {
const navigate = useNavigate();
const { search } = useLocation();
const isVttView = new URLSearchParams(search).get("view") === "vtt";
const isReadonly = useSelector(appEnvSelectors.getIsReadonly);
const builderUrl = useSelector(sheetAppSelectors.getBuilderUrl);
@@ -107,15 +108,23 @@ export const CharacterManagePane: FC<CharacterManagePaneProps> = ({
share: ThemedShareSvg,
managelevel: ThemedManageLevelSvg,
managexp: ThemedManageXpSvg,
gamelog: ThemedChatBubbleSvg,
frame: ThemedFrameSvg,
backdrop: ThemedBackdropSvg,
theme: ThemedThemeIconSvg,
portrait: ThemedPortraitSvg,
preferences: ThemedPreferencesSvg,
downloadpdf: ThemedExportSvg,
downloadpdf: PdfSvg,
};
if (menuKey === "gamelog") {
return (
<ListTimeline
className="ddbc-svg ddbc-svg--themed"
style={{ fill: characterTheme.themeColor }}
/>
);
}
const IconNode: ComponentType<any> | null =
menuIconLookup[menuKey] &&
helperUtils.lookupDataOrFallback(menuIconLookup, menuKey);
@@ -124,17 +133,12 @@ export const CharacterManagePane: FC<CharacterManagePaneProps> = ({
return null;
}
if (menuKey === "gamelog") {
return (
<D6
className="ddbc-svg ddbc-svg--themed"
style={{ fill: characterTheme.themeColor }}
/>
);
}
return (
<IconNode theme={decorationUtils.getCharacterTheme(decorationInfo)} />
<IconNode
className="ddbc-svg ddbc-svg--themed"
style={{ fill: characterTheme.themeColor }}
theme={decorationUtils.getCharacterTheme(decorationInfo)}
/>
);
};
@@ -144,7 +148,7 @@ export const CharacterManagePane: FC<CharacterManagePaneProps> = ({
data-testid="character-manage-pane"
{...props}
>
<Overview />
<Overview isVttView={isVttView} />
{/* Display Readonly CTA or MENU */}
{isReadonly ? (
<div className={styles.readonlyBackground}>
@@ -162,25 +166,27 @@ export const CharacterManagePane: FC<CharacterManagePaneProps> = ({
variant="solid"
target="_blank"
themed
href={`${BASE_PATHNAME}/builder`}
href={`${BASE_PATHNAME}/builder${search}`}
forceThemeMode="dark"
>
<span>Create a Character</span>
<LightLinkOutSvg />
<LinkIcon className={styles.linkIcon} />
</Button>
</div>
</div>
) : (
<PaneMenu>
<PaneMenuGroup label="My Character">
<PaneMenuItem
menukey="builder"
prefixIcon={getMenuIconNode("builder")}
suffixIcon={<LightLinkOutSvg />}
onClick={handleBuilderClick}
>
Manage Character & Levels
</PaneMenuItem>
{!isVttView && (
<PaneMenuItem
menukey="builder"
prefixIcon={getMenuIconNode("builder")}
suffixIcon={<LinkIcon className={styles.linkIcon} />}
onClick={handleBuilderClick}
>
Manage Character & Levels
</PaneMenuItem>
)}
{preferences.progressionType ===
PreferenceProgressionTypeEnum.XP && (
<PaneMenuItem
@@ -199,47 +205,52 @@ export const CharacterManagePane: FC<CharacterManagePaneProps> = ({
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>
{!isVttView && (
<>
<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>
@@ -8,11 +8,10 @@ import {
useRef,
useState,
} from "react";
import { useDispatch, useSelector } from "react-redux";
import { useSelector } from "react-redux";
import {
CharacterAvatarPortrait,
CharacterName,
CharacterProgressionSummary,
CharacterSummary,
LightPencilSvg,
@@ -20,16 +19,14 @@ import {
} from "@dndbeyond/character-components/es";
import { Button } from "~/components/Button/Button";
import {
CharacterNameLimitMsg,
DeathCauseEnum,
InputLimits,
} from "~/constants";
import { CharacterNameLimitMsg, InputLimits } from "~/constants";
import { useCharacterTheme } from "~/contexts/CharacterTheme";
import { useSidebar } from "~/contexts/Sidebar";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { useDispatch } from "~/hooks/useDispatch";
import { useMaxLengthErrorHandling } from "~/hooks/useErrorHandling/useMaxLengthErrorHandling";
import useUserId from "~/hooks/useUserId";
import { CharacterName } from "~/subApps/sheet/components/CharacterName";
import { appEnvActions } from "~/tools/js/Shared/actions";
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
import { CharacterStatusSlug } from "~/types";
@@ -39,16 +36,21 @@ import { PopoverContent } from "../../../../../../../components/PopoverContent";
import { PaneComponentEnum } from "../../../types";
import styles from "./styles.module.css";
export interface OverviewProps extends HTMLAttributes<HTMLDivElement> {}
export interface OverviewProps extends HTMLAttributes<HTMLDivElement> {
isVttView?: boolean;
}
export const Overview: FC<OverviewProps> = ({ className, ...props }) => {
export const Overview: FC<OverviewProps> = ({
isVttView = false,
className,
...props
}) => {
const {
ruleData,
characterName,
characterGender: gender,
race: species,
experienceInfo: xpInfo,
deathCause,
preferences,
decorationInfo,
playerName,
@@ -138,14 +140,16 @@ export const Overview: FC<OverviewProps> = ({ className, ...props }) => {
{
<div
className={clsx([
styles.characterName,
styles.characterNameContainer,
!isReadonly && !isVttView && styles.editableName,
isEditingCharacterName && styles.editName,
])}
onClick={isReadonly ? undefined : handleNameClick}
onClick={isReadonly || isVttView ? undefined : handleNameClick}
>
{isEditingCharacterName ? (
<>
<input
className={styles.nameInput}
ref={inputRef}
type="text"
defaultValue={characterName ?? ""}
@@ -158,12 +162,8 @@ export const Overview: FC<OverviewProps> = ({ className, ...props }) => {
</>
) : (
<>
<CharacterName
name={characterName ?? ""}
isDead={deathCause !== DeathCauseEnum.NONE}
isFaceMenu={true}
/>
{!isReadonly && <LightPencilSvg />}
<CharacterName />
{!isReadonly && !isVttView && <LightPencilSvg />}
</>
)}
</div>
@@ -1,15 +1,17 @@
import { FC, HTMLAttributes, useContext } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useSelector } from "react-redux";
import { FeatManager } from "@dndbeyond/character-rules-engine";
import { FeatureChoices } from "~/components/FeatureChoices";
import { useSidebar } from "~/contexts/Sidebar";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { useDispatch } from "~/hooks/useDispatch";
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
import { Preview } from "~/subApps/sheet/components/Sidebar/components/Preview";
import { ClassFeatureSnippet } from "~/tools/js/CharacterSheet/components/FeatureSnippet";
import { CharacterFeaturesManagerContext } from "~/tools/js/Shared/managers/CharacterFeaturesManagerContext";
import { InventoryManagerContext } from "~/tools/js/Shared/managers/InventoryManagerContext";
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
import { PaneIdentifierUtils } from "~/tools/js/Shared/utils";
import {
@@ -49,6 +51,7 @@ export const ClassFeaturePane: FC<Props> = ({ identifiers, ...props }) => {
const { characterFeaturesManager } = useContext(
CharacterFeaturesManagerContext
);
const { inventoryManager } = useContext(InventoryManagerContext);
const {
pane: { paneHistoryPush },
@@ -87,6 +90,7 @@ export const ClassFeaturePane: FC<Props> = ({ identifiers, ...props }) => {
if (!charClass || !classFeature) {
return;
}
dispatch(
characterActions.classFeatureChoiceSetRequest(
classUtils.getActiveId(charClass),
@@ -94,7 +98,10 @@ export const ClassFeaturePane: FC<Props> = ({ identifiers, ...props }) => {
type,
id,
helperUtils.parseInputInt(value),
parentChoiceId
parentChoiceId,
classFeatureUtils.getHasItemMappings(classFeature)
? inventoryManager.handleAcceptOnSuccess(true)
: undefined
)
);
};
@@ -215,6 +222,7 @@ export const ClassFeaturePane: FC<Props> = ({ identifiers, ...props }) => {
<FeatureChoices
choices={classFeatureUtils.getChoices(classFeature)}
charClass={charClass}
classFeature={classFeature}
onChoiceChange={handleChoiceChange}
collapseDescription={true}
showHeading={true}
@@ -7,14 +7,13 @@ import {
useEffect,
useState,
} from "react";
import { useDispatch, useSelector } from "react-redux";
import { useSelector } from "react-redux";
import {
Collapsible,
CollapsibleHeaderCallout,
CollapsibleHeaderContent,
CreatureName,
InfusionPreview,
} from "@dndbeyond/character-components/es";
import {
characterActions,
@@ -30,10 +29,12 @@ import {
import { Button } from "~/components/Button";
import { EditableName } from "~/components/EditableName";
import { HtmlContent } from "~/components/HtmlContent";
import { InfusionPreview } from "~/components/InfusionPreview";
import { Reference } from "~/components/Reference";
import { TagGroup } from "~/components/TagGroup";
import { useSidebar } from "~/contexts/Sidebar";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { useDispatch } from "~/hooks/useDispatch";
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";
@@ -67,9 +68,6 @@ export const CreaturePane: FC<Props> = ({ identifiers, ...props }) => {
ruleData,
creatures,
entityValueLookup,
snippetData,
infusionChoiceLookup,
proficiencyBonus,
characterTheme: theme,
hpInfo,
deathCause,
@@ -498,14 +496,7 @@ export const CreaturePane: FC<Props> = ({ identifiers, ...props }) => {
if (infusion) {
return (
<div className={styles.hasSeparator}>
<InfusionPreview
infusion={infusion}
snippetData={snippetData}
ruleData={ruleData}
infusionChoiceLookup={infusionChoiceLookup}
onClick={handleInfusionClick}
proficiencyBonus={proficiencyBonus}
/>
<InfusionPreview infusion={infusion} onClick={handleInfusionClick} />
</div>
);
}
@@ -1,5 +1,5 @@
import { FC, HTMLAttributes, useContext } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useSelector } from "react-redux";
import { ChoiceUtils } from "@dndbeyond/character-rules-engine";
@@ -7,6 +7,7 @@ import { Button } from "~/components/Button";
import { HelperTextAccordion } from "~/components/HelperTextAccordion";
import { useSidebar } from "~/contexts/Sidebar";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { useDispatch } from "~/hooks/useDispatch";
import { FeatFeatureSnippet } from "~/tools/js/CharacterSheet/components/FeatureSnippet";
import { DetailChoice } from "~/tools/js/Shared/containers/DetailChoice";
import { CharacterFeaturesManagerContext } from "~/tools/js/Shared/managers/CharacterFeaturesManagerContext";
@@ -145,7 +146,7 @@ export const FeatPane: FC<Props> = ({ identifiers, ...props }) => {
<HelperTextAccordion
builderHelperText={feat.getHelperText()}
size="small"
useTheme
themed
/>
{prerequisiteDescription && (
<div className={styles.prereq}>
@@ -198,6 +199,7 @@ export const FeatPane: FC<Props> = ({ identifiers, ...props }) => {
choiceInfo={choiceInfo}
showBackgroundProficiencyOptions={true}
description={description}
collapseDescription={true}
/>
);
})}
@@ -1,105 +0,0 @@
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>
);
};
@@ -1,5 +1,4 @@
import { FC, HTMLAttributes } from "react";
import { useDispatch } from "react-redux";
import {
characterActions,
@@ -9,6 +8,7 @@ import {
import { HtmlContent } from "~/components/HtmlContent";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { useDispatch } from "~/hooks/useDispatch";
import { DetailChoice } from "~/tools/js/Shared/containers/DetailChoice";
import styles from "./styles.module.css";
@@ -1,23 +1,14 @@
import { FC } from "react";
import { useSelector } from "react-redux";
import { useFeatureFlags } from "~/contexts/FeatureFlag";
import { SettingsButton } from "~/subApps/sheet/components/SettingsButton";
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;
@@ -27,40 +18,14 @@ export const FeatsManagePane: FC<{}> = () => {
<div>
<Header
callout={
<SettingsButton
context={SettingsContextsEnum.FEATURES}
isReadonly={isReadonly}
/>
!isReadonly && (
<SettingsButton context={SettingsContextsEnum.FEATURES} />
)
}
>
Manage {gfsBlessingsUiFlag ? "Features" : "Feats"}
Manage 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>
)}
<FeatShoppe />
</div>
);
};
@@ -1,10 +1,10 @@
import clsx from "clsx";
import { useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useSelector } from "react-redux";
import { Dice } from "@dndbeyond/dice";
import { GameLog } from "@dndbeyond/game-log-components";
import { useDispatch } from "~/hooks/useDispatch";
import { appEnvActions } from "~/tools/js/Shared/actions/appEnv";
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
@@ -36,9 +36,6 @@ export const GameLogPane = () => {
lastMessageTime.toString()
);
} catch (e) {}
// turn the dice notifications off if the panel is open
Dice.setDiceNotificationEnabled(!isOpen);
};
updateOpenState(true);
@@ -52,8 +49,8 @@ export const GameLogPane = () => {
);
return (
<div className={clsx(styles.gameLogPane)} data-testid="gamelog-pane">
{characterId && <GameLog />}
<div className={styles.gameLogPane} data-testid="gamelog-pane">
<GameLog isVisible={Boolean(characterId)} />
</div>
);
};
@@ -1,17 +1,23 @@
import clsx from "clsx";
import { FC, HTMLAttributes, useContext } from "react";
import { useDispatch, useSelector } from "react-redux";
import { FC, HTMLAttributes } from "react";
import { 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 {
RollKinds,
RollTypes,
} from "@dndbeyond/pocket-dimension-dice/constants";
import {
RollDicePayload,
RollKind,
} from "@dndbeyond/pocket-dimension-dice/types";
import { useSidebar } from "~/contexts/Sidebar";
import { isNotNullOrUndefined } from "~/helpers/validation";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { useDispatch } from "~/hooks/useDispatch";
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";
@@ -46,8 +52,6 @@ export const DeathSavesManager: FC<Props> = ({
const characterRollContext = useSelector(
characterRollContextSelectors.getCharacterRollContext
);
const [{ messageTargetOptions, defaultMessageTargetOption, userId }] =
useContext(GameLogContext);
const dispatch = useDispatch();
const {
@@ -60,12 +64,12 @@ export const DeathSavesManager: FC<Props> = ({
const hasAdvantage = deathSaveInfo.advantageAdjustments.length > 0;
const hasDisadvantage = deathSaveInfo.disadvantageAdjustments.length > 0;
let rollKind: RollKind | undefined;
let rollKind = RollKind.None;
if (hasAdvantage && !hasDisadvantage) {
rollKind = RollKind.Advantage;
rollKind = RollKinds.Advantage;
} else if (hasDisadvantage && !hasAdvantage) {
rollKind = RollKind.Disadvantage;
rollKind = RollKinds.Disadvantage;
}
/* --- On Click Functions --- */
@@ -134,7 +138,7 @@ export const DeathSavesManager: FC<Props> = ({
{isDiceEnabled && (
<DigitalDiceWrapper
diceNotation={"1d20"}
onRollResults={(rollRequest: RollRequest) => {
rollCallback={(rollRequest: RollDicePayload["data"]) => {
const roll = rollRequest.rolls[0].result?.total;
if (roll) {
@@ -149,20 +153,13 @@ export const DeathSavesManager: FC<Props> = ({
}
}
}}
rollType={RollType.Save}
rollType={RollTypes.Save}
rollKind={rollKind}
rollAction={"Death"}
diceEnabled={isDiceEnabled}
rollContext={characterRollContext}
rollTargetOptions={
messageTargetOptions?.entities
? Object.values(messageTargetOptions.entities).filter(
isNotNullOrUndefined
)
: undefined
}
rollTargetDefault={defaultMessageTargetOption}
userId={Number(userId)}
action="Death"
isDiceEnabled={isDiceEnabled}
entityId={String(characterRollContext.entityId)}
entityType={String(characterRollContext.entityType)}
name={String(characterRollContext.name)}
>
<LightDiceSvg />
<span>Roll</span>
@@ -1,6 +1,5 @@
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";
@@ -9,6 +8,7 @@ import { Button } from "~/components/Button";
import { Checkbox } from "~/components/Checkbox";
import { ItemName } from "~/components/ItemName";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { useDispatch } from "~/hooks/useDispatch";
import { HP_DAMAGE_TAKEN_VALUE } from "~/subApps/sheet/constants";
import { Item, HitPointInfo, Creature } from "~/types";
@@ -378,7 +378,7 @@ export const HitPointsAdjuster: FC<Props> = ({
themed
darkMode={characterTheme.isDarkMode}
id={`${protectionSupplier.key}-checkbox`}
onClick={() => {
onChange={() => {
isEnabled = !isEnabled;
setActiveProtectionSupplierKey(
isEnabled ? protectionSupplier.key : null
@@ -1,11 +1,11 @@
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 { useDispatch } from "~/hooks/useDispatch";
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";
@@ -1,8 +1,8 @@
import clsx from "clsx";
import { createRef, FC, HTMLAttributes, useState } from "react";
import { useDispatch } from "react-redux";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { useDispatch } from "~/hooks/useDispatch";
import {
HP_BONUS_VALUE,
HP_DAMAGE_TAKEN_VALUE,
@@ -0,0 +1,212 @@
import { FC, HTMLAttributes, useContext, useState } from "react";
import { useSelector } from "react-redux";
import {
Action,
ActionUtils,
Constants,
DataOrigin,
EntityUtils,
HelperUtils,
Infusion,
InfusionUtils,
ItemPlanUtils,
ItemUtils,
Spell,
} from "@dndbeyond/character-rules-engine/es";
import { EditableName } from "~/components/EditableName";
import { ItemName } from "~/components/ItemName";
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 {
getDataOriginComponentInfo,
getSpellComponentInfo,
} from "~/subApps/sheet/components/Sidebar/helpers/paneUtils";
import {
PaneComponentEnum,
PaneIdentifiersItem,
} from "~/subApps/sheet/components/Sidebar/types";
import ItemDetail from "~/tools/js/Shared/components/ItemDetail";
import ItemListInformationCollapsible from "~/tools/js/Shared/components/ItemListInformationCollapsible";
import { InventoryManagerContext } from "~/tools/js/Shared/managers/InventoryManagerContext";
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
import { PaneIdentifierUtils } from "~/tools/js/Shared/utils";
import { PaneInitFailureContent } from "../../components/PaneInitFailureContent";
interface Props extends HTMLAttributes<HTMLDivElement> {
identifiers: PaneIdentifiersItem | null;
}
export const ItemPane: FC<Props> = ({ identifiers, ...props }) => {
const { inventoryManager } = useContext(InventoryManagerContext);
const {
pane: { paneHistoryPush },
} = useSidebar();
const {
ruleData,
allInventoryItems: items,
weaponSpellDamageGroups,
entityValueLookup,
snippetData,
infusionChoiceLookup,
characterTheme: theme,
containerLookup,
partyInfo,
itemPlans,
} = useCharacterEngine();
const isReadonly = useSelector(appEnvSelectors.getIsReadonly);
const [isCustomizeClosed, setIsCustomizeClosed] = useState(true);
const [shouldShowPaneInitFailure, setShouldShowPaneInitFailure] =
useState(true);
const item =
items.find((item) => identifiers?.id === ItemUtils.getMappingId(item)) ??
null;
const handleToggleCustomize = () => {
setIsCustomizeClosed(!isCustomizeClosed);
};
const handleCustomItemEdit = (adjustments: Record<string, any>): void => {
if (item === null) {
return;
}
if (adjustments.notes === "") {
adjustments.notes = null;
}
inventoryManager.handleCustomEdit({
item,
adjustments,
});
};
const handleCustomDataUpdate = (
adjustmentType: Constants.AdjustmentTypeEnum,
value: any
): void => {
if (item) {
inventoryManager.handleCustomizationSet({
item,
adjustmentType,
value,
});
}
};
const handleRemoveCustomizations = (): void => {
if (item) {
inventoryManager.handleCustomizationsRemove({ item });
}
};
const handleDataOriginClick = (dataOrigin: DataOrigin) => {
let component = getDataOriginComponentInfo(dataOrigin);
if (component.type !== PaneComponentEnum.ERROR_404) {
paneHistoryPush(component.type, component.identifiers);
}
};
const handleSpellClick = (spell: Spell): void => {
let component = getSpellComponentInfo(spell);
if (component.type) {
paneHistoryPush(component.type, component.identifiers);
}
};
const handleInfusionClick = (infusion: Infusion): void => {
const choiceKey = InfusionUtils.getChoiceKey(infusion);
if (choiceKey !== null) {
paneHistoryPush(
PaneComponentEnum.INFUSION_CHOICE,
PaneIdentifierUtils.generateInfusionChoice(choiceKey)
);
}
};
const handleActionClick = (action: Action): void => {
const dataOrigin = ActionUtils.getDataOrigin(action);
let component = getDataOriginComponentInfo(dataOrigin);
if (component.type !== PaneComponentEnum.ERROR_404) {
paneHistoryPush(component.type, component.identifiers);
}
};
const handleParentClick = (): void => {
if (item) {
const itemPlan = ItemUtils.getItemPlan(item, itemPlans);
if (itemPlan) {
const dataOrigin = ItemPlanUtils.getDataOrigin(itemPlan);
let component = getDataOriginComponentInfo(dataOrigin);
if (component.type !== PaneComponentEnum.ERROR_404) {
paneHistoryPush(component.type, component.identifiers);
}
}
}
};
if (item === null) {
return shouldShowPaneInitFailure ? (
<PaneInitFailureContent
errorTitle="Item Not Found"
errorMessage="That item is no longer in your inventory! Please try again."
toastMeta={{ level: "info" }}
/>
) : null;
}
let parentName: string | null = null;
const itemPlan = ItemUtils.getItemPlan(item, itemPlans);
if (itemPlan) {
const dataOrigin = ItemPlanUtils.getDataOrigin(itemPlan);
parentName = EntityUtils.getDataOriginName(dataOrigin);
}
return (
<div {...props}>
<Header
parent={parentName}
onClick={handleParentClick}
preview={<Preview imageUrl={ItemUtils.getAvatarUrl(item)} />}
>
<EditableName onClick={handleToggleCustomize}>
<ItemName item={item} />
</EditableName>
</Header>
<ItemListInformationCollapsible item={item} ruleData={ruleData} />
<ItemDetail
theme={theme}
key={ItemUtils.getUniqueKey(item)}
item={item}
weaponSpellDamageGroups={weaponSpellDamageGroups}
ruleData={ruleData}
snippetData={snippetData}
onCustomDataUpdate={handleCustomDataUpdate}
onCustomizationsRemove={handleRemoveCustomizations}
onDataOriginClick={handleDataOriginClick}
onSpellClick={handleSpellClick}
onInfusionClick={handleInfusionClick}
onMasteryActionClick={handleActionClick}
entityValueLookup={entityValueLookup}
infusionChoiceLookup={infusionChoiceLookup}
isReadonly={isReadonly}
container={HelperUtils.lookupDataOrFallback(
containerLookup,
ItemUtils.getContainerDefinitionKey(item)
)}
showCustomize={!isReadonly}
onPostRemoveNavigation={PaneComponentEnum.EQUIPMENT_MANAGE}
partyInfo={partyInfo}
onCustomItemEdit={handleCustomItemEdit}
isCustomizeClosed={isCustomizeClosed}
onCustomizeClick={handleToggleCustomize}
onRemoveItem={() => setShouldShowPaneInitFailure(false)}
/>
</div>
);
};
@@ -0,0 +1,265 @@
import clsx from "clsx";
import { sortBy } from "lodash";
import { FC, HTMLAttributes, useContext, useEffect, useState } from "react";
import {
characterActions,
CharClass,
ClassFeature,
ClassFeatureUtils,
ClassUtils,
ContainerManager,
HelperUtils,
} from "@dndbeyond/character-rules-engine";
import ArrowRightIcon from "@dndbeyond/fontawesome-cache/svgs/solid/arrow-right.svg";
import { Button } from "~/components/Button";
import { FeatureChoices } from "~/components/FeatureChoices";
import { useSidebar } from "~/contexts/Sidebar";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { useDispatch } from "~/hooks/useDispatch";
import DataLoadingStatusEnum from "~/tools/js/Shared/constants/DataLoadingStatusEnum";
import { InventoryManagerContext } from "~/tools/js/Shared/managers/InventoryManagerContext";
import { AppLoggerUtils, PaneIdentifierUtils } from "~/tools/js/Shared/utils";
import Collapsible from "~/tools/js/smartComponents/Collapsible";
import LoadingPlaceholder from "~/tools/js/smartComponents/LoadingPlaceholder";
import { Header } from "../../components/Header";
import { Heading } from "../../components/Heading";
import { PaneComponentEnum } from "../../types";
import styles from "./styles.module.css";
export interface ItemPlansManagePaneProps
extends HTMLAttributes<HTMLDivElement> {}
export const ItemPlansManagePane: FC<ItemPlansManagePaneProps> = ({
className,
...props
}) => {
const dispatch = useDispatch();
const { classes } = useCharacterEngine();
const {
pane: { paneHistoryPush },
} = useSidebar();
const { inventoryManager } = useContext(InventoryManagerContext);
const [itemShoppe, setItemShoppe] = useState<ContainerManager | null>(null);
const [itemsLoadingStatus, setItemsLoadingStatus] =
useState<DataLoadingStatusEnum>(DataLoadingStatusEnum.NOT_INITIALIZED);
useEffect(() => {
const abortController = new AbortController();
if (
itemShoppe === null &&
itemsLoadingStatus !== DataLoadingStatusEnum.LOADED
) {
if (inventoryManager) {
setItemsLoadingStatus(DataLoadingStatusEnum.LOADING);
inventoryManager
.getInventoryShoppe({
onSuccess: (shoppeContainer: ContainerManager) => {
setItemShoppe(shoppeContainer);
setItemsLoadingStatus(DataLoadingStatusEnum.LOADED);
},
additionalApiConfig: {
signal: abortController.signal,
},
})
.catch((e) => {
if (abortController.signal.aborted) return;
AppLoggerUtils.handleAdhocApiError(e);
});
} else {
setItemsLoadingStatus(DataLoadingStatusEnum.LOADED);
}
}
return () => {
abortController.abort();
};
}, [itemsLoadingStatus, inventoryManager, itemShoppe]);
const handleClassFeatureClick = (
charClass: CharClass,
classFeature: ClassFeature
): void => {
paneHistoryPush(
PaneComponentEnum.CLASS_FEATURE_DETAIL,
PaneIdentifierUtils.generateClassFeature(
ClassFeatureUtils.getId(classFeature),
ClassUtils.getMappingId(charClass)
)
);
};
const handleChoiceChange = (
charClass: CharClass,
classFeature: ClassFeature,
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,
ClassFeatureUtils.getHasItemMappings(classFeature)
? inventoryManager.handleAcceptOnSuccess(true)
: undefined
)
);
};
return (
<div className={clsx([styles.itemPlansManagePane, className])} {...props}>
<Header>Manage Magic Item Plans</Header>
{classes.map((charClass) => {
const subclass = ClassUtils.getSubclass(charClass);
const subclassId = subclass?.id;
const subclassName = subclass?.name;
const baseClassId = ClassUtils.getId(charClass);
const activeFeaturesWithItemMappings =
ClassUtils.getActiveClassFeatures(charClass).filter(
ClassFeatureUtils.getHasItemMappings
);
const baseClassFeatures: ClassFeature[] = [];
const subClassFeatures: ClassFeature[] = [];
activeFeaturesWithItemMappings.forEach((feature) => {
const featureClassId = ClassFeatureUtils.getClassId(feature);
if (featureClassId === baseClassId) {
baseClassFeatures.push(feature);
} else if (featureClassId === subclassId) {
subClassFeatures.push(feature);
}
});
const sortedBaseFeatures = sortBy(baseClassFeatures, [
(feature) => ClassFeatureUtils.getName(feature),
]);
const sortedSubclassFeatures = sortBy(subClassFeatures, [
(feature) => ClassFeatureUtils.getName(feature),
]);
return (
<div key={ClassUtils.getMappingId(charClass)}>
<Heading>{ClassUtils.getName(charClass)}</Heading>
{itemsLoadingStatus !== DataLoadingStatusEnum.LOADED && (
<LoadingPlaceholder />
)}
{itemsLoadingStatus === DataLoadingStatusEnum.LOADED &&
sortedBaseFeatures.map((feature) => {
return (
<Collapsible
className={styles.classFeature}
key={ClassFeatureUtils.getUniqueKey(feature)}
header={`${ClassFeatureUtils.getName(feature)} (${
ClassFeatureUtils.getChoices(feature).length
})`}
initiallyCollapsed={false}
>
<div className={styles.buttonContainer}>
<Button
themed
size="xx-small"
className={styles.viewDetailsButton}
onClick={() =>
handleClassFeatureClick(charClass, feature)
}
>
View {ClassFeatureUtils.getName(feature)} Details
<ArrowRightIcon className={styles.arrowIcon} />
</Button>
</div>
<FeatureChoices
className={styles.featureChoices}
choices={ClassFeatureUtils.getChoices(feature)}
charClass={charClass}
classFeature={feature}
onChoiceChange={(id, type, value, parentChoiceId) =>
handleChoiceChange(
charClass,
feature,
id,
type,
value,
parentChoiceId
)
}
collapseDescription={true}
shouldFetch={false}
itemShoppe={itemShoppe}
/>
</Collapsible>
);
})}
{itemsLoadingStatus === DataLoadingStatusEnum.LOADED &&
sortedSubclassFeatures.length > 0 && (
<>
<Heading>{subclassName}</Heading>
{sortedSubclassFeatures.map((feature) => {
return (
<Collapsible
className={styles.classFeature}
key={ClassFeatureUtils.getUniqueKey(feature)}
header={`${ClassFeatureUtils.getName(feature)} (${
ClassFeatureUtils.getChoices(feature).length
})`}
initiallyCollapsed={false}
>
<div className={styles.buttonContainer}>
<Button
themed
size="xx-small"
className={styles.viewDetailsButton}
onClick={() =>
handleClassFeatureClick(charClass, feature)
}
>
View {ClassFeatureUtils.getName(feature)} Details
<ArrowRightIcon className={styles.arrowIcon} />
</Button>
</div>
<FeatureChoices
className={styles.featureChoices}
choices={ClassFeatureUtils.getChoices(feature)}
charClass={charClass}
classFeature={feature}
onChoiceChange={(id, type, value, parentChoiceId) =>
handleChoiceChange(
charClass,
feature,
id,
type,
value,
parentChoiceId
)
}
collapseDescription={true}
shouldFetch={false}
itemShoppe={itemShoppe}
/>
</Collapsible>
);
})}
</>
)}
</div>
);
})}
</div>
);
};
@@ -0,0 +1,267 @@
import { FC, HTMLAttributes, useEffect, useState } from "react";
import {
ShortModelInfoContract,
ApiRequests,
serviceDataActions,
} from "@dndbeyond/character-rules-engine/es";
import { Checkbox } from "~/components/Checkbox";
import { RadioGroup } from "~/components/RadioGroup";
import { PreferenceLongRestTypeEnum } from "~/constants";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { useDispatch } from "~/hooks/useDispatch";
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
import { toastMessageActions } from "~/tools/js/Shared/actions";
import ThemeButton from "~/tools/js/Shared/components/common/Button/ThemeButton/ThemeButton";
import DataLoadingStatusEnum from "~/tools/js/Shared/constants/DataLoadingStatusEnum";
import { AppLoggerUtils } from "~/tools/js/Shared/utils";
import { RestoreLifeManager } from "../HitPointsManagePane/RestoreLifeManager";
import styles from "./styles.module.css";
interface LongRestPaneProps extends HTMLAttributes<HTMLDivElement> {}
export const LongRestPane: FC<LongRestPaneProps> = ({ ...props }) => {
const [resetMaxHpModifier, setResetMaxHpModifier] = useState(true);
const [adjustConditionLevel, setAdjustConditionLevel] = useState(false);
const [restMessageLoadingStatus, setRestMessageLoadingStatus] =
useState<DataLoadingStatusEnum>(DataLoadingStatusEnum.NOT_INITIALIZED);
const {
activeConditions,
isDead,
preferences,
characterUtils,
helperUtils,
apiAdapterUtils,
characterActions,
longRestText,
} = useCharacterEngine();
const dispatch = useDispatch();
const handleSave = () => {
dispatch(
characterActions.longRest(resetMaxHpModifier, adjustConditionLevel)
);
dispatch(
toastMessageActions.toastSuccess(
"Long Rest Taken",
"You have completed a long rest. Relevant abilities have been reset."
)
);
};
const handleRestoreToLife = (restoreType: ShortModelInfoContract) => {
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 handleChangeLongRestType = (value: number | null) => {
const typedPrefKey = characterUtils.getPreferenceKey("longRestType");
if (typedPrefKey !== null) {
dispatch(characterActions.preferenceChoose(typedPrefKey, value));
}
};
useEffect(() => {
const abortController = new AbortController();
if (restMessageLoadingStatus !== DataLoadingStatusEnum.LOADED) {
setRestMessageLoadingStatus(DataLoadingStatusEnum.LOADING);
ApiRequests.getCharacterRestLong({
signal: abortController.signal,
})
.then((response) => {
let message = apiAdapterUtils.getResponseData(response);
if (message !== null) {
dispatch(serviceDataActions.longRestTextSet(message));
}
setRestMessageLoadingStatus(DataLoadingStatusEnum.LOADED);
})
.catch((e) => {
if (abortController.signal.aborted) return;
AppLoggerUtils.handleAdhocApiError(e);
});
}
// If the pane is quickly opened and closed multiple times, we cancel any in-flight requests.
return () => {
abortController.abort();
};
}, [restMessageLoadingStatus]);
const renderRecover = () => {
if (isDead) {
return <p>Your character is dead</p>;
}
const exhaustionIsActive = activeConditions.find(
(condition) => condition.level !== null
);
return (
<>
<p>
{longRestText === null ||
restMessageLoadingStatus === DataLoadingStatusEnum.LOADING
? "Asking the server what will be reset..."
: longRestText}
</p>
<div className={styles.recoverMaxHp}>
<Checkbox
className={styles.checkbox}
id="reset-max-hp"
label="Reset Maximum HP changes during this rest"
checked={resetMaxHpModifier}
onChange={() => setResetMaxHpModifier(!resetMaxHpModifier)}
themed
/>
</div>
{exhaustionIsActive && (
<div className={styles.recoverExhaustion}>
<Checkbox
className={styles.checkbox}
id="recover-exhaustion"
label="Recover 1 Level of Exhaustion during this rest (requires food and drink)"
checked={adjustConditionLevel}
onChange={() => setAdjustConditionLevel(!adjustConditionLevel)}
themed
/>
</div>
)}
</>
);
};
const renderActions = () => {
if (isDead) {
return (
<>
<RestoreLifeManager onSave={handleRestoreToLife} />
</>
);
}
return (
<div className={styles.recoverActions}>
<ThemeButton onClick={handleSave} enableConfirm={true}>
Take Long Rest
</ThemeButton>
<ThemeButton
onClick={() => {
setResetMaxHpModifier(true);
setAdjustConditionLevel(false);
}}
style="outline"
>
Reset
</ThemeButton>
</div>
);
};
return (
<>
<Header>Long Rest</Header>
<div>
<p>
A Long Rest is a period of extended downtimeat least 8
hoursavailable to any creature. During a Long Rest, you sleep for at
least 6 hours and perform no more than 2 hours of light activity, such
as reading, talking, eating, or standing watch.
</p>
<p>
While asleep, you have the Unconscious condition. After you finish a
Long Rest, you must wait at least 16 hours before starting another
one.
</p>
</div>
<div className={styles.longRestType}>
<RadioGroup
className={styles.radioGroup}
themed
title="Long Rest Rules"
name="long-rest-type"
initialValue={preferences.longRestType}
onChange={(e) =>
handleChangeLongRestType(
helperUtils.parseInputInt(e?.target?.value)
)
}
options={[
{
label: "Recover 1/2 Hit Dice",
description: "Use 5e Rules",
value: PreferenceLongRestTypeEnum.HALF,
},
{
label: "Recover all Hit Dice",
description: "Use 5.5e Rules",
value: PreferenceLongRestTypeEnum.FULL,
},
]}
/>
</div>
<div className={styles.recover}>
<h2 className={styles.recoverHeading}>Recover</h2>
{renderRecover()}
</div>
{renderActions()}
<div className={styles.longRestInfo}>
<p>
<strong>
<em>Benefits of the Rest.</em>
</strong>{" "}
To start a Long Rest, you must have at least 1 HP. When you finish the
rest, you gain the following benefits:
</p>
<ul>
<li>
You regain all lost HP and all spent Hit Point Dice. If your HP
maximum was reduced, it returns to normal.
</li>
<li>
If any of your ability scores were reduced, they return to normal.
</li>
<li>
If you have the Exhaustion condition, its level decreases by 1.
</li>
<li>
If you have a feature that is recharged by a Long Rest, it recharges
in the way specified in its description.
</li>
</ul>
<p>
<strong>
<em>Interrupting the Rest.</em>
</strong>{" "}
A Long Rest is stopped by the following interruptions:
</p>
<ul>
<li>Rolling Initiative</li>
<li>Casting a spell other than a cantrip</li>
<li>Taking any damage</li>
<li>1 hour of walking or other physical exertion</li>
</ul>
<p>
If you rested at least 1 hour before the interruption, you gain the
benefits of a Short Rest.
</p>
<p>
You can resume a Long Rest immediately after an interruption. If you
do so, the rest requires 1 additional hour per interruption to finish.
</p>
</div>
</>
);
};
@@ -1,5 +1,5 @@
import { FC, HTMLAttributes, useContext } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useSelector } from "react-redux";
import {
Action,
@@ -17,6 +17,7 @@ import {
import { FeatureChoices } from "~/components/FeatureChoices";
import { useSidebar } from "~/contexts/Sidebar";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { useDispatch } from "~/hooks/useDispatch";
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
import { Preview } from "~/subApps/sheet/components/Sidebar/components/Preview";
import {
@@ -1,10 +1,10 @@
import { FC, HTMLAttributes, useEffect, useState } from "react";
import { useDispatch } from "react-redux";
import { characterActions } from "@dndbeyond/character-rules-engine/es";
import { Button } from "~/components/Button";
import { XpManager } from "~/components/XpManager";
import { useDispatch } from "~/hooks/useDispatch";
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
import { toastMessageActions } from "~/tools/js/Shared/actions";
@@ -3,14 +3,7 @@ import { FC, HTMLAttributes } from "react";
type SvgProps = HTMLAttributes<SVGElement>;
export const ArrowsLeftIcon: FC<SvgProps> = (props) => (
<svg
viewBox="0 0 16 16"
width="1em"
height="1em"
aria-labelledby="leftArrowsTitle"
{...props}
>
<title id="leftArrowsTitle">Left Arrows Icon</title>
<svg viewBox="0 0 16 16" width="1em" height="1em" {...props}>
<path d="M11,2.48,5,8l6,5.52a1.3,1.3,0,0,1-.21,2.12h0a2.25,2.25,0,0,1-2.68-.17L0,8,8.11.53A2.25,2.25,0,0,1,10.79.36h0A1.3,1.3,0,0,1,11,2.48Z" />
<polygon points="6.92 8 16 0 16 16 6.92 8" />
</svg>
@@ -3,14 +3,7 @@ import { FC, HTMLAttributes } from "react";
type SvgProps = HTMLAttributes<SVGElement>;
export const ArrowsRightIcon: FC<SvgProps> = (props) => (
<svg
viewBox="0 0 16 16"
width="1em"
height="1em"
aria-labelledby="rightArrowsTitle"
{...props}
>
<title id="rightArrowsTitle">Right Arrows Icon</title>
<svg viewBox="0 0 16 16" width="1em" height="1em" {...props}>
<path d="M5.21.36h0A2.25,2.25,0,0,1,7.89.53L16,8,7.89,15.47a2.25,2.25,0,0,1-2.68.17h0A1.3,1.3,0,0,1,5,13.52L11,8,5,2.48A1.3,1.3,0,0,1,5.21.36Z" />
<polygon points="9.09 8 0 0 0 16 9.09 8" />
</svg>
@@ -90,10 +90,6 @@ export interface PaneIdentifiersSettingsContext {
context: string;
}
export interface PaneIdentifiersBlessing {
id: string;
}
export interface PaneIdentifiersTrait {
type: Constants.TraitTypeEnum;
}
@@ -125,7 +121,6 @@ export type PaneIdentifiers =
| PaneIdentifiersContainer
| PaneIdentifiersCurrencyContext
| PaneIdentifiersSettingsContext
| PaneIdentifiersBlessing
| PaneIdentifiersTrait;
export interface PaneComponentProperties {
@@ -148,7 +143,6 @@ export enum PaneComponentEnum {
ARMOR_MANAGE = "ARMOR_MANAGE",
BASIC_ACTION = "BASIC_ACTION",
BACKGROUND = "BACKGROUND",
BLESSING_DETAIL = "BLESSING_DETAIL",
CAMPAIGN = "CAMPAIGN",
CHARACTER_MANAGE = "CHARACTER_MANAGE",
CHARACTER_SPELL_DETAIL = "CHARACTER_SPELL_DETAIL",
@@ -176,6 +170,7 @@ export enum PaneComponentEnum {
INITIATIVE = "INITIATIVE",
INSPIRATION = "INSPIRATION",
ITEM_DETAIL = "ITEM_DETAIL",
ITEM_PLANS_MANAGE = "ITEM_PLANS_MANAGE",
LONG_REST = "LONG_REST",
NOTE_MANAGE = "NOTE_MANAGE",
POSSESSIONS_MANAGE = "POSSESSIONS_MANAGE",